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; /// /// Default implementation of . /// Per CLI-CORE-41-001, renders output in json/yaml/table formats. /// 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; } /// public async Task RenderAsync(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); } /// public async Task RenderTableAsync( IEnumerable items, TextWriter writer, IReadOnlyList>? 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(); var table = BuildTable(itemList, effectiveColumns); await writer.WriteLineAsync(table.AsMemory(), cancellationToken).ConfigureAwait(false); } /// 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); } /// 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); } /// 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 value) { return JsonSerializer.Serialize(value, JsonOptions); } private static string RenderYaml(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 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> InferColumns() { 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( 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(IReadOnlyList items, IReadOnlyList> 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(); 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) }; } }