Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Output/OutputRenderer.cs
2026-02-01 21:37:40 +02:00

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)
};
}
}