397 lines
13 KiB
C#
397 lines
13 KiB
C#
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace StellaOps.Cli.Output;
|
|
|
|
/// <summary>
|
|
/// Default implementation of <see cref="IOutputRenderer"/>.
|
|
/// Per CLI-CORE-41-001, renders output in json/yaml/table formats.
|
|
/// </summary>
|
|
public sealed class OutputRenderer : IOutputRenderer
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }
|
|
};
|
|
|
|
public OutputFormat Format { get; }
|
|
|
|
public OutputRenderer(OutputFormat format = OutputFormat.Table)
|
|
{
|
|
Format = format;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task RenderAsync<T>(T value, TextWriter writer, CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var output = Format switch
|
|
{
|
|
OutputFormat.Json => RenderJson(value),
|
|
OutputFormat.Yaml => RenderYaml(value),
|
|
OutputFormat.Table => RenderObject(value),
|
|
_ => RenderObject(value)
|
|
};
|
|
|
|
await writer.WriteLineAsync(output.AsMemory(), cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task RenderTableAsync<T>(
|
|
IEnumerable<T> items,
|
|
TextWriter writer,
|
|
IReadOnlyList<ColumnDefinition<T>>? columns = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var itemList = items.ToList();
|
|
|
|
if (Format == OutputFormat.Json)
|
|
{
|
|
var json = RenderJson(itemList);
|
|
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
if (Format == OutputFormat.Yaml)
|
|
{
|
|
var yaml = RenderYaml(itemList);
|
|
await writer.WriteLineAsync(yaml.AsMemory(), cancellationToken).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
// Table format
|
|
if (itemList.Count == 0)
|
|
{
|
|
await writer.WriteLineAsync("(no results)".AsMemory(), cancellationToken).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
var effectiveColumns = columns ?? InferColumns<T>();
|
|
var table = BuildTable(itemList, effectiveColumns);
|
|
await writer.WriteLineAsync(table.AsMemory(), cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task RenderSuccessAsync(string message, TextWriter writer, CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
if (Format == OutputFormat.Json)
|
|
{
|
|
var obj = new { status = "success", message };
|
|
await writer.WriteLineAsync(RenderJson(obj).AsMemory(), cancellationToken).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
if (Format == OutputFormat.Yaml)
|
|
{
|
|
await writer.WriteLineAsync($"status: success\nmessage: {EscapeYamlString(message)}".AsMemory(), cancellationToken).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
await writer.WriteLineAsync($"✓ {message}".AsMemory(), cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task RenderErrorAsync(string message, TextWriter writer, string? errorCode = null, string? traceId = null, CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
if (Format == OutputFormat.Json)
|
|
{
|
|
var obj = new { status = "error", error_code = errorCode, message, trace_id = traceId };
|
|
await writer.WriteLineAsync(RenderJson(obj).AsMemory(), cancellationToken).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
if (Format == OutputFormat.Yaml)
|
|
{
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine("status: error");
|
|
if (!string.IsNullOrWhiteSpace(errorCode))
|
|
sb.AppendLine($"error_code: {errorCode}");
|
|
sb.AppendLine($"message: {EscapeYamlString(message)}");
|
|
if (!string.IsNullOrWhiteSpace(traceId))
|
|
sb.AppendLine($"trace_id: {traceId}");
|
|
await writer.WriteLineAsync(sb.ToString().AsMemory(), cancellationToken).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
var output = new StringBuilder();
|
|
output.Append("✗ ");
|
|
if (!string.IsNullOrWhiteSpace(errorCode))
|
|
output.Append($"[{errorCode}] ");
|
|
output.Append(message);
|
|
if (!string.IsNullOrWhiteSpace(traceId))
|
|
output.Append($" (trace: {traceId})");
|
|
|
|
await writer.WriteLineAsync(output.ToString().AsMemory(), cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task RenderWarningAsync(string message, TextWriter writer, CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
if (Format == OutputFormat.Json)
|
|
{
|
|
var obj = new { status = "warning", message };
|
|
await writer.WriteLineAsync(RenderJson(obj).AsMemory(), cancellationToken).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
if (Format == OutputFormat.Yaml)
|
|
{
|
|
await writer.WriteLineAsync($"status: warning\nmessage: {EscapeYamlString(message)}".AsMemory(), cancellationToken).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
await writer.WriteLineAsync($"⚠ {message}".AsMemory(), cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private static string RenderJson<T>(T value)
|
|
{
|
|
return JsonSerializer.Serialize(value, JsonOptions);
|
|
}
|
|
|
|
private static string RenderYaml<T>(T value)
|
|
{
|
|
// Simple YAML rendering via JSON conversion for now
|
|
// A full YAML library would be used in production
|
|
var json = JsonSerializer.Serialize(value, JsonOptions);
|
|
using var doc = JsonDocument.Parse(json);
|
|
return ConvertJsonElementToYaml(doc.RootElement, 0);
|
|
}
|
|
|
|
private static string ConvertJsonElementToYaml(JsonElement element, int indent)
|
|
{
|
|
var indentStr = new string(' ', indent * 2);
|
|
var sb = new StringBuilder();
|
|
|
|
switch (element.ValueKind)
|
|
{
|
|
case JsonValueKind.Object:
|
|
foreach (var prop in element.EnumerateObject())
|
|
{
|
|
if (prop.Value.ValueKind == JsonValueKind.Object || prop.Value.ValueKind == JsonValueKind.Array)
|
|
{
|
|
sb.AppendLine($"{indentStr}{prop.Name}:");
|
|
sb.Append(ConvertJsonElementToYaml(prop.Value, indent + 1));
|
|
}
|
|
else
|
|
{
|
|
sb.AppendLine($"{indentStr}{prop.Name}: {FormatYamlValue(prop.Value)}");
|
|
}
|
|
}
|
|
break;
|
|
|
|
case JsonValueKind.Array:
|
|
foreach (var item in element.EnumerateArray())
|
|
{
|
|
if (item.ValueKind == JsonValueKind.Object || item.ValueKind == JsonValueKind.Array)
|
|
{
|
|
sb.AppendLine($"{indentStr}-");
|
|
sb.Append(ConvertJsonElementToYaml(item, indent + 1));
|
|
}
|
|
else
|
|
{
|
|
sb.AppendLine($"{indentStr}- {FormatYamlValue(item)}");
|
|
}
|
|
}
|
|
break;
|
|
|
|
default:
|
|
sb.AppendLine($"{indentStr}{FormatYamlValue(element)}");
|
|
break;
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static string FormatYamlValue(JsonElement element)
|
|
{
|
|
return element.ValueKind switch
|
|
{
|
|
JsonValueKind.String => EscapeYamlString(element.GetString() ?? ""),
|
|
JsonValueKind.Number => element.GetRawText(),
|
|
JsonValueKind.True => "true",
|
|
JsonValueKind.False => "false",
|
|
JsonValueKind.Null => "null",
|
|
_ => element.GetRawText()
|
|
};
|
|
}
|
|
|
|
private static string EscapeYamlString(string value)
|
|
{
|
|
if (string.IsNullOrEmpty(value))
|
|
return "\"\"";
|
|
|
|
if (value.Contains('\n') || value.Contains(':') || value.Contains('#') ||
|
|
value.StartsWith(' ') || value.EndsWith(' ') ||
|
|
value.StartsWith('"') || value.StartsWith('\''))
|
|
{
|
|
return $"\"{value.Replace("\\", "\\\\").Replace("\"", "\\\"")}\"";
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
private static string RenderObject<T>(T value)
|
|
{
|
|
if (value is null)
|
|
return "(null)";
|
|
|
|
var type = typeof(T);
|
|
var properties = type.GetProperties()
|
|
.Where(p => p.CanRead)
|
|
.ToList();
|
|
|
|
if (properties.Count == 0)
|
|
return value.ToString() ?? "(empty)";
|
|
|
|
var sb = new StringBuilder();
|
|
var maxNameLength = properties.Max(p => p.Name.Length);
|
|
|
|
foreach (var prop in properties)
|
|
{
|
|
var propValue = prop.GetValue(value);
|
|
var displayValue = propValue?.ToString() ?? "(null)";
|
|
sb.AppendLine($"{prop.Name.PadRight(maxNameLength)} : {displayValue}");
|
|
}
|
|
|
|
return sb.ToString().TrimEnd();
|
|
}
|
|
|
|
private static IReadOnlyList<ColumnDefinition<T>> InferColumns<T>()
|
|
{
|
|
var properties = typeof(T).GetProperties()
|
|
.Where(p => p.CanRead && IsSimpleType(p.PropertyType))
|
|
.Take(8) // Limit to 8 columns for readability
|
|
.ToList();
|
|
|
|
return properties.Select(p => new ColumnDefinition<T>(
|
|
ToHeaderCase(p.Name),
|
|
item => p.GetValue(item)?.ToString()
|
|
)).ToList();
|
|
}
|
|
|
|
private static bool IsSimpleType(Type type)
|
|
{
|
|
var underlying = Nullable.GetUnderlyingType(type) ?? type;
|
|
return underlying.IsPrimitive ||
|
|
underlying == typeof(string) ||
|
|
underlying == typeof(DateTime) ||
|
|
underlying == typeof(DateTimeOffset) ||
|
|
underlying == typeof(Guid) ||
|
|
underlying == typeof(decimal) ||
|
|
underlying.IsEnum;
|
|
}
|
|
|
|
private static string ToHeaderCase(string name)
|
|
{
|
|
if (string.IsNullOrEmpty(name))
|
|
return name;
|
|
|
|
var sb = new StringBuilder();
|
|
for (var i = 0; i < name.Length; i++)
|
|
{
|
|
var c = name[i];
|
|
if (i > 0 && char.IsUpper(c))
|
|
{
|
|
sb.Append(' ');
|
|
}
|
|
sb.Append(i == 0 ? char.ToUpperInvariant(c) : c);
|
|
}
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static string BuildTable<T>(IReadOnlyList<T> items, IReadOnlyList<ColumnDefinition<T>> columns)
|
|
{
|
|
if (columns.Count == 0 || items.Count == 0)
|
|
return "(no data)";
|
|
|
|
// Calculate column widths
|
|
var widths = new int[columns.Count];
|
|
for (var i = 0; i < columns.Count; i++)
|
|
{
|
|
widths[i] = columns[i].Header.Length;
|
|
if (columns[i].MinWidth is { } minWidth)
|
|
widths[i] = Math.Max(widths[i], minWidth);
|
|
}
|
|
|
|
// Get all values and update widths
|
|
var rows = new List<string[]>();
|
|
foreach (var item in items)
|
|
{
|
|
var row = new string[columns.Count];
|
|
for (var i = 0; i < columns.Count; i++)
|
|
{
|
|
var value = columns[i].ValueSelector(item) ?? "";
|
|
if (columns[i].MaxWidth is { } maxWidth && value.Length > maxWidth)
|
|
{
|
|
value = value[..(maxWidth - 3)] + "...";
|
|
}
|
|
row[i] = value;
|
|
widths[i] = Math.Max(widths[i], value.Length);
|
|
}
|
|
rows.Add(row);
|
|
}
|
|
|
|
// Build output
|
|
var sb = new StringBuilder();
|
|
|
|
// Header
|
|
for (var i = 0; i < columns.Count; i++)
|
|
{
|
|
if (i > 0) sb.Append(" ");
|
|
sb.Append(PadColumn(columns[i].Header.ToUpperInvariant(), widths[i], columns[i].Alignment));
|
|
}
|
|
sb.AppendLine();
|
|
|
|
// Separator
|
|
for (var i = 0; i < columns.Count; i++)
|
|
{
|
|
if (i > 0) sb.Append(" ");
|
|
sb.Append(new string('-', widths[i]));
|
|
}
|
|
sb.AppendLine();
|
|
|
|
// Rows
|
|
foreach (var row in rows)
|
|
{
|
|
for (var i = 0; i < columns.Count; i++)
|
|
{
|
|
if (i > 0) sb.Append(" ");
|
|
sb.Append(PadColumn(row[i], widths[i], columns[i].Alignment));
|
|
}
|
|
sb.AppendLine();
|
|
}
|
|
|
|
return sb.ToString().TrimEnd();
|
|
}
|
|
|
|
private static string PadColumn(string value, int width, ColumnAlignment alignment)
|
|
{
|
|
return alignment switch
|
|
{
|
|
ColumnAlignment.Right => value.PadLeft(width),
|
|
ColumnAlignment.Center => value.PadLeft((width + value.Length) / 2).PadRight(width),
|
|
_ => value.PadRight(width)
|
|
};
|
|
}
|
|
}
|