up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
This commit is contained in:
396
src/Cli/StellaOps.Cli/Output/OutputRenderer.cs
Normal file
396
src/Cli/StellaOps.Cli/Output/OutputRenderer.cs
Normal file
@@ -0,0 +1,396 @@
|
||||
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>
|
||||
{
|
||||
Header = ToHeaderCase(p.Name),
|
||||
ValueSelector = 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.HasValue)
|
||||
widths[i] = Math.Max(widths[i], columns[i].MinWidth.Value);
|
||||
}
|
||||
|
||||
// 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.HasValue && value.Length > columns[i].MaxWidth.Value)
|
||||
{
|
||||
value = value[..(columns[i].MaxWidth.Value - 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)
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user