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

This commit is contained in:
master
2025-11-28 18:21:46 +02:00
parent 05da719048
commit d1cbb905f8
103 changed files with 49604 additions and 105 deletions

View File

@@ -0,0 +1,353 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StellaOps.Cli.Services.Models.Transport;
namespace StellaOps.Cli.Output;
/// <summary>
/// Structured CLI error with code, message, and optional details.
/// Per CLI-CORE-41-001, provides error mapping for standardized API error envelopes.
/// CLI-SDK-62-002: Enhanced to surface error.code and trace_id from API responses.
/// </summary>
public sealed record CliError(
string Code,
string Message,
string? TraceId = null,
string? Detail = null,
IReadOnlyDictionary<string, string>? Metadata = null,
string? RequestId = null,
string? HelpUrl = null,
int? RetryAfter = null,
string? Target = null)
{
/// <summary>
/// Exit code to use when this error occurs.
/// </summary>
public int ExitCode => GetExitCode(Code);
/// <summary>
/// Maps error code prefixes to exit codes.
/// </summary>
private static int GetExitCode(string code)
{
if (string.IsNullOrWhiteSpace(code))
return 1;
// Authentication/authorization errors
if (code.StartsWith("AUTH_", StringComparison.OrdinalIgnoreCase) ||
code.StartsWith("ERR_AUTH_", StringComparison.OrdinalIgnoreCase))
return 2;
// Invalid scope errors
if (code.Contains("SCOPE", StringComparison.OrdinalIgnoreCase))
return 3;
// Not found errors
if (code.StartsWith("NOT_FOUND", StringComparison.OrdinalIgnoreCase) ||
code.StartsWith("ERR_NOT_FOUND", StringComparison.OrdinalIgnoreCase))
return 4;
// Validation errors
if (code.StartsWith("VALIDATION_", StringComparison.OrdinalIgnoreCase) ||
code.StartsWith("ERR_VALIDATION_", StringComparison.OrdinalIgnoreCase))
return 5;
// Rate limit errors
if (code.StartsWith("RATE_LIMIT", StringComparison.OrdinalIgnoreCase) ||
code.StartsWith("ERR_RATE_LIMIT", StringComparison.OrdinalIgnoreCase))
return 6;
// Air-gap errors
if (code.StartsWith("AIRGAP_", StringComparison.OrdinalIgnoreCase) ||
code.StartsWith("ERR_AIRGAP_", StringComparison.OrdinalIgnoreCase))
return 7;
// AOC errors
if (code.StartsWith("ERR_AOC_", StringComparison.OrdinalIgnoreCase))
return 8;
// Aggregation errors
if (code.StartsWith("ERR_AGG_", StringComparison.OrdinalIgnoreCase))
return 9;
// Forensic verification errors
if (code.StartsWith("ERR_FORENSIC_", StringComparison.OrdinalIgnoreCase))
return 12;
// Determinism errors
if (code.StartsWith("ERR_DETER_", StringComparison.OrdinalIgnoreCase))
return 13;
// Observability errors
if (code.StartsWith("ERR_OBS_", StringComparison.OrdinalIgnoreCase))
return 14;
// Pack errors
if (code.StartsWith("ERR_PACK_", StringComparison.OrdinalIgnoreCase))
return 15;
// Exception governance errors
if (code.StartsWith("ERR_EXC_", StringComparison.OrdinalIgnoreCase))
return 16;
// Orchestrator errors
if (code.StartsWith("ERR_ORCH_", StringComparison.OrdinalIgnoreCase))
return 17;
// SBOM errors
if (code.StartsWith("ERR_SBOM_", StringComparison.OrdinalIgnoreCase))
return 18;
// Notify errors
if (code.StartsWith("ERR_NOTIFY_", StringComparison.OrdinalIgnoreCase))
return 19;
// Sbomer errors
if (code.StartsWith("ERR_SBOMER_", StringComparison.OrdinalIgnoreCase))
return 20;
// Network/connectivity errors
if (code.StartsWith("NETWORK_", StringComparison.OrdinalIgnoreCase) ||
code.StartsWith("ERR_NETWORK_", StringComparison.OrdinalIgnoreCase) ||
code.StartsWith("CONNECTION_", StringComparison.OrdinalIgnoreCase))
return 10;
// Timeout errors
if (code.Contains("TIMEOUT", StringComparison.OrdinalIgnoreCase))
return 11;
// Generic errors
return 1;
}
/// <summary>
/// Creates an error from an exception.
/// </summary>
public static CliError FromException(Exception ex, string? traceId = null)
{
var code = ex switch
{
UnauthorizedAccessException => "ERR_AUTH_UNAUTHORIZED",
TimeoutException => "ERR_TIMEOUT",
OperationCanceledException => "ERR_CANCELLED",
InvalidOperationException => "ERR_INVALID_OPERATION",
ArgumentException => "ERR_VALIDATION_ARGUMENT",
_ => "ERR_UNKNOWN"
};
return new CliError(code, ex.Message, traceId, ex.InnerException?.Message);
}
/// <summary>
/// Creates an error from an HTTP status code.
/// </summary>
public static CliError FromHttpStatus(int statusCode, string? message = null, string? traceId = null)
{
var (code, defaultMessage) = statusCode switch
{
400 => ("ERR_VALIDATION_BAD_REQUEST", "Bad request"),
401 => ("ERR_AUTH_UNAUTHORIZED", "Unauthorized"),
403 => ("ERR_AUTH_FORBIDDEN", "Forbidden"),
404 => ("ERR_NOT_FOUND", "Resource not found"),
409 => ("ERR_CONFLICT", "Resource conflict"),
422 => ("ERR_VALIDATION_UNPROCESSABLE", "Unprocessable entity"),
429 => ("ERR_RATE_LIMIT", "Rate limit exceeded"),
500 => ("ERR_SERVER_INTERNAL", "Internal server error"),
502 => ("ERR_SERVER_BAD_GATEWAY", "Bad gateway"),
503 => ("ERR_SERVER_UNAVAILABLE", "Service unavailable"),
504 => ("ERR_SERVER_TIMEOUT", "Gateway timeout"),
_ => ($"ERR_HTTP_{statusCode}", $"HTTP error {statusCode}")
};
return new CliError(code, message ?? defaultMessage, traceId);
}
/// <summary>
/// Creates an error from a parsed API error.
/// CLI-SDK-62-002: Surfaces standardized API error envelope fields.
/// </summary>
public static CliError FromParsedApiError(ParsedApiError error)
{
ArgumentNullException.ThrowIfNull(error);
Dictionary<string, string>? metadata = null;
if (error.Metadata is not null && error.Metadata.Count > 0)
{
metadata = error.Metadata
.Where(kvp => kvp.Value is not null)
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value?.ToString() ?? "");
}
return new CliError(
Code: error.Code,
Message: error.Message,
TraceId: error.TraceId,
Detail: error.Detail,
Metadata: metadata,
RequestId: error.RequestId,
HelpUrl: error.HelpUrl,
RetryAfter: error.RetryAfter,
Target: error.Target);
}
/// <summary>
/// Creates an error from an API error envelope.
/// CLI-SDK-62-002: Direct conversion from envelope format.
/// </summary>
public static CliError FromApiErrorEnvelope(ApiErrorEnvelope envelope, int httpStatus)
{
ArgumentNullException.ThrowIfNull(envelope);
var errorDetail = envelope.Error;
var code = errorDetail?.Code ?? $"ERR_HTTP_{httpStatus}";
var message = errorDetail?.Message ?? $"HTTP error {httpStatus}";
Dictionary<string, string>? metadata = null;
if (errorDetail?.Metadata is not null && errorDetail.Metadata.Count > 0)
{
metadata = errorDetail.Metadata
.Where(kvp => kvp.Value is not null)
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value?.ToString() ?? "");
}
return new CliError(
Code: code,
Message: message,
TraceId: envelope.TraceId,
Detail: errorDetail?.Detail,
Metadata: metadata,
RequestId: envelope.RequestId,
HelpUrl: errorDetail?.HelpUrl,
RetryAfter: errorDetail?.RetryAfter,
Target: errorDetail?.Target);
}
}
/// <summary>
/// Well-known CLI error codes.
/// </summary>
public static class CliErrorCodes
{
public const string Unauthorized = "ERR_AUTH_UNAUTHORIZED";
public const string Forbidden = "ERR_AUTH_FORBIDDEN";
public const string InvalidScope = "ERR_AUTH_INVALID_SCOPE";
public const string NotFound = "ERR_NOT_FOUND";
public const string ValidationFailed = "ERR_VALIDATION_FAILED";
public const string RateLimited = "ERR_RATE_LIMIT";
public const string AirGapBlocked = "ERR_AIRGAP_EGRESS_BLOCKED";
public const string AocViolation = "ERR_AOC_001";
public const string NetworkError = "ERR_NETWORK_FAILED";
public const string Timeout = "ERR_TIMEOUT";
public const string Cancelled = "ERR_CANCELLED";
public const string ConfigurationMissing = "ERR_CONFIG_MISSING";
public const string ProfileNotFound = "ERR_PROFILE_NOT_FOUND";
// CLI-LNM-22-001: Aggregation error codes (exit code 9)
public const string AggNoObservations = "ERR_AGG_NO_OBSERVATIONS";
public const string AggConflictDetected = "ERR_AGG_CONFLICT_DETECTED";
public const string AggLinksetEmpty = "ERR_AGG_LINKSET_EMPTY";
public const string AggSourceMissing = "ERR_AGG_SOURCE_MISSING";
public const string AggExportFailed = "ERR_AGG_EXPORT_FAILED";
// CLI-FORENSICS-54-001: Forensic verification error codes (exit code 12)
public const string ForensicBundleNotFound = "ERR_FORENSIC_BUNDLE_NOT_FOUND";
public const string ForensicBundleInvalid = "ERR_FORENSIC_BUNDLE_INVALID";
public const string ForensicChecksumMismatch = "ERR_FORENSIC_CHECKSUM_MISMATCH";
public const string ForensicSignatureInvalid = "ERR_FORENSIC_SIGNATURE_INVALID";
public const string ForensicSignatureUntrusted = "ERR_FORENSIC_SIGNATURE_UNTRUSTED";
public const string ForensicChainOfCustodyBroken = "ERR_FORENSIC_CHAIN_BROKEN";
public const string ForensicTimelineInvalid = "ERR_FORENSIC_TIMELINE_INVALID";
public const string ForensicTrustRootMissing = "ERR_FORENSIC_TRUST_ROOT_MISSING";
// CLI-DETER-70-003: Determinism error codes (exit code 13)
public const string DeterminismDockerUnavailable = "ERR_DETER_DOCKER_UNAVAILABLE";
public const string DeterminismNoImages = "ERR_DETER_NO_IMAGES";
public const string DeterminismScannerMissing = "ERR_DETER_SCANNER_MISSING";
public const string DeterminismThresholdFailed = "ERR_DETER_THRESHOLD_FAILED";
public const string DeterminismRunFailed = "ERR_DETER_RUN_FAILED";
public const string DeterminismManifestInvalid = "ERR_DETER_MANIFEST_INVALID";
// CLI-OBS-51-001: Observability error codes (exit code 14)
public const string ObsConnectionFailed = "ERR_OBS_CONNECTION_FAILED";
public const string ObsServiceUnavailable = "ERR_OBS_SERVICE_UNAVAILABLE";
public const string ObsNoData = "ERR_OBS_NO_DATA";
public const string ObsInvalidFilter = "ERR_OBS_INVALID_FILTER";
public const string ObsOfflineViolation = "ERR_OBS_OFFLINE_VIOLATION";
// CLI-PACKS-42-001: Pack error codes (exit code 15)
public const string PackNotFound = "ERR_PACK_NOT_FOUND";
public const string PackValidationFailed = "ERR_PACK_VALIDATION_FAILED";
public const string PackPlanFailed = "ERR_PACK_PLAN_FAILED";
public const string PackRunFailed = "ERR_PACK_RUN_FAILED";
public const string PackPushFailed = "ERR_PACK_PUSH_FAILED";
public const string PackPullFailed = "ERR_PACK_PULL_FAILED";
public const string PackVerifyFailed = "ERR_PACK_VERIFY_FAILED";
public const string PackSignatureInvalid = "ERR_PACK_SIGNATURE_INVALID";
public const string PackApprovalRequired = "ERR_PACK_APPROVAL_REQUIRED";
public const string PackOfflineViolation = "ERR_PACK_OFFLINE_VIOLATION";
// CLI-EXC-25-001: Exception governance error codes (exit code 16)
public const string ExcNotFound = "ERR_EXC_NOT_FOUND";
public const string ExcValidationFailed = "ERR_EXC_VALIDATION_FAILED";
public const string ExcCreateFailed = "ERR_EXC_CREATE_FAILED";
public const string ExcPromoteFailed = "ERR_EXC_PROMOTE_FAILED";
public const string ExcRevokeFailed = "ERR_EXC_REVOKE_FAILED";
public const string ExcImportFailed = "ERR_EXC_IMPORT_FAILED";
public const string ExcExportFailed = "ERR_EXC_EXPORT_FAILED";
public const string ExcApprovalRequired = "ERR_EXC_APPROVAL_REQUIRED";
public const string ExcExpired = "ERR_EXC_EXPIRED";
public const string ExcConflict = "ERR_EXC_CONFLICT";
// CLI-ORCH-32-001: Orchestrator error codes (exit code 17)
public const string OrchSourceNotFound = "ERR_ORCH_SOURCE_NOT_FOUND";
public const string OrchSourcePaused = "ERR_ORCH_SOURCE_PAUSED";
public const string OrchSourceThrottled = "ERR_ORCH_SOURCE_THROTTLED";
public const string OrchTestFailed = "ERR_ORCH_TEST_FAILED";
public const string OrchQuotaExceeded = "ERR_ORCH_QUOTA_EXCEEDED";
public const string OrchConnectionFailed = "ERR_ORCH_CONNECTION_FAILED";
// CLI-PARITY-41-001: SBOM error codes (exit code 18)
public const string SbomNotFound = "ERR_SBOM_NOT_FOUND";
public const string SbomConnectionFailed = "ERR_SBOM_CONNECTION_FAILED";
public const string SbomExportFailed = "ERR_SBOM_EXPORT_FAILED";
public const string SbomCompareFailed = "ERR_SBOM_COMPARE_FAILED";
public const string SbomInvalidFormat = "ERR_SBOM_INVALID_FORMAT";
public const string SbomOfflineViolation = "ERR_SBOM_OFFLINE_VIOLATION";
// CLI-PARITY-41-002: Notify error codes (exit code 19)
public const string NotifyChannelNotFound = "ERR_NOTIFY_CHANNEL_NOT_FOUND";
public const string NotifyDeliveryNotFound = "ERR_NOTIFY_DELIVERY_NOT_FOUND";
public const string NotifyConnectionFailed = "ERR_NOTIFY_CONNECTION_FAILED";
public const string NotifySendFailed = "ERR_NOTIFY_SEND_FAILED";
public const string NotifyTestFailed = "ERR_NOTIFY_TEST_FAILED";
public const string NotifyRetryFailed = "ERR_NOTIFY_RETRY_FAILED";
public const string NotifyOfflineViolation = "ERR_NOTIFY_OFFLINE_VIOLATION";
// CLI-SBOM-60-001: Sbomer error codes (exit code 20)
public const string SbomerLayerNotFound = "ERR_SBOMER_LAYER_NOT_FOUND";
public const string SbomerCompositionNotFound = "ERR_SBOMER_COMPOSITION_NOT_FOUND";
public const string SbomerDsseInvalid = "ERR_SBOMER_DSSE_INVALID";
public const string SbomerContentHashMismatch = "ERR_SBOMER_CONTENT_HASH_MISMATCH";
public const string SbomerMerkleProofInvalid = "ERR_SBOMER_MERKLE_PROOF_INVALID";
public const string SbomerComposeFailed = "ERR_SBOMER_COMPOSE_FAILED";
public const string SbomerVerifyFailed = "ERR_SBOMER_VERIFY_FAILED";
public const string SbomerNonDeterministic = "ERR_SBOMER_NON_DETERMINISTIC";
public const string SbomerOfflineViolation = "ERR_SBOMER_OFFLINE_VIOLATION";
// CLI-POLICY-27-006: Policy Studio scope error codes (exit code 3)
public const string PolicyStudioScopeRequired = "ERR_SCOPE_POLICY_STUDIO_REQUIRED";
public const string PolicyStudioScopeInvalid = "ERR_SCOPE_POLICY_STUDIO_INVALID";
public const string PolicyStudioScopeWorkflowRequired = "ERR_SCOPE_POLICY_WORKFLOW_REQUIRED";
public const string PolicyStudioScopePublishRequired = "ERR_SCOPE_POLICY_PUBLISH_REQUIRED";
public const string PolicyStudioScopeSignRequired = "ERR_SCOPE_POLICY_SIGN_REQUIRED";
// CLI-RISK-66-001: Risk scope error codes (exit code 3)
public const string RiskScopeRequired = "ERR_SCOPE_RISK_REQUIRED";
public const string RiskScopeProfileRequired = "ERR_SCOPE_RISK_PROFILE_REQUIRED";
public const string RiskScopeSimulateRequired = "ERR_SCOPE_RISK_SIMULATE_REQUIRED";
// CLI-SIG-26-001: Reachability scope error codes (exit code 3)
public const string ReachabilityScopeRequired = "ERR_SCOPE_REACHABILITY_REQUIRED";
public const string ReachabilityScopeUploadRequired = "ERR_SCOPE_REACHABILITY_UPLOAD_REQUIRED";
}

View File

@@ -0,0 +1,211 @@
using System.Text.Json;
using Spectre.Console;
namespace StellaOps.Cli.Output;
/// <summary>
/// Helper for rendering CLI errors consistently.
/// CLI-SDK-62-002: Provides standardized error output with error.code and trace_id.
/// </summary>
internal static class CliErrorRenderer
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
/// Renders an error to the console.
/// </summary>
public static void Render(CliError error, bool verbose = false, bool asJson = false)
{
if (asJson)
{
RenderJson(error);
}
else
{
RenderConsole(error, verbose);
}
}
/// <summary>
/// Renders an error as JSON.
/// </summary>
public static void RenderJson(CliError error)
{
var output = new
{
error = new
{
code = error.Code,
message = error.Message,
detail = error.Detail,
target = error.Target,
help_url = error.HelpUrl,
retry_after = error.RetryAfter,
metadata = error.Metadata
},
trace_id = error.TraceId,
request_id = error.RequestId,
exit_code = error.ExitCode
};
var json = JsonSerializer.Serialize(output, JsonOptions);
AnsiConsole.WriteLine(json);
}
/// <summary>
/// Renders an error to the console with formatting.
/// </summary>
public static void RenderConsole(CliError error, bool verbose = false)
{
// Main error message
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error.Message)}");
// Error code
AnsiConsole.MarkupLine($"[grey]Code:[/] {Markup.Escape(error.Code)}");
// Detail (if present)
if (!string.IsNullOrWhiteSpace(error.Detail))
{
AnsiConsole.MarkupLine($"[grey]Detail:[/] {Markup.Escape(error.Detail)}");
}
// Target (if present)
if (!string.IsNullOrWhiteSpace(error.Target))
{
AnsiConsole.MarkupLine($"[grey]Target:[/] {Markup.Escape(error.Target)}");
}
// Help URL (if present)
if (!string.IsNullOrWhiteSpace(error.HelpUrl))
{
AnsiConsole.MarkupLine($"[grey]Help:[/] [link]{Markup.Escape(error.HelpUrl)}[/]");
}
// Retry-after (if present)
if (error.RetryAfter.HasValue)
{
AnsiConsole.MarkupLine($"[yellow]Retry after:[/] {error.RetryAfter} seconds");
}
// Trace/Request IDs (shown in verbose mode or always for debugging)
if (verbose || !string.IsNullOrWhiteSpace(error.TraceId))
{
AnsiConsole.WriteLine();
if (!string.IsNullOrWhiteSpace(error.TraceId))
{
AnsiConsole.MarkupLine($"[grey]Trace ID:[/] {Markup.Escape(error.TraceId)}");
}
if (!string.IsNullOrWhiteSpace(error.RequestId))
{
AnsiConsole.MarkupLine($"[grey]Request ID:[/] {Markup.Escape(error.RequestId)}");
}
}
// Metadata (shown in verbose mode)
if (verbose && error.Metadata is not null && error.Metadata.Count > 0)
{
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[grey]Metadata:[/]");
foreach (var (key, value) in error.Metadata)
{
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(key)}:[/] {Markup.Escape(value)}");
}
}
}
/// <summary>
/// Renders a simple error message.
/// </summary>
public static void RenderSimple(string message, string? code = null)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(message)}");
if (!string.IsNullOrWhiteSpace(code))
{
AnsiConsole.MarkupLine($"[grey]Code:[/] {Markup.Escape(code)}");
}
}
/// <summary>
/// Renders a warning message.
/// </summary>
public static void RenderWarning(string message)
{
AnsiConsole.MarkupLine($"[yellow]Warning:[/] {Markup.Escape(message)}");
}
/// <summary>
/// Renders scope guidance for invalid_scope errors.
/// </summary>
public static void RenderScopeGuidance(CliError error)
{
if (!error.Code.Contains("SCOPE", StringComparison.OrdinalIgnoreCase))
return;
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[yellow]The requested operation requires additional OAuth scopes.[/]");
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("To resolve this issue:");
AnsiConsole.MarkupLine(" 1. Check your CLI profile configuration: [cyan]stella profile show[/]");
AnsiConsole.MarkupLine(" 2. Update your profile with required scopes: [cyan]stella profile edit <name>[/]");
AnsiConsole.MarkupLine(" 3. Request additional scopes from your administrator");
AnsiConsole.MarkupLine(" 4. Re-authenticate with the updated scopes: [cyan]stella auth login[/]");
}
/// <summary>
/// Renders rate limit guidance.
/// </summary>
public static void RenderRateLimitGuidance(CliError error)
{
if (!error.Code.Contains("RATE_LIMIT", StringComparison.OrdinalIgnoreCase))
return;
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[yellow]Rate limit exceeded.[/]");
if (error.RetryAfter.HasValue)
{
AnsiConsole.MarkupLine($"Please wait [cyan]{error.RetryAfter}[/] seconds before retrying.");
}
else
{
AnsiConsole.MarkupLine("Please wait before retrying your request.");
}
}
/// <summary>
/// Renders authentication guidance.
/// </summary>
public static void RenderAuthGuidance(CliError error)
{
if (!error.Code.StartsWith("ERR_AUTH_", StringComparison.OrdinalIgnoreCase))
return;
AnsiConsole.WriteLine();
if (error.Code == CliErrorCodes.Unauthorized)
{
AnsiConsole.MarkupLine("[yellow]Authentication required.[/]");
AnsiConsole.MarkupLine("Please authenticate using: [cyan]stella auth login[/]");
}
else if (error.Code == CliErrorCodes.Forbidden)
{
AnsiConsole.MarkupLine("[yellow]Access denied.[/]");
AnsiConsole.MarkupLine("You do not have permission to perform this operation.");
AnsiConsole.MarkupLine("Contact your administrator to request access.");
}
}
/// <summary>
/// Renders contextual guidance based on error code.
/// </summary>
public static void RenderGuidance(CliError error)
{
RenderScopeGuidance(error);
RenderRateLimitGuidance(error);
RenderAuthGuidance(error);
}
}

View File

@@ -0,0 +1,98 @@
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cli.Output;
/// <summary>
/// Interface for rendering CLI output in multiple formats.
/// Per CLI-CORE-41-001, supports json/yaml/table rendering.
/// </summary>
public interface IOutputRenderer
{
/// <summary>
/// Gets the current output format.
/// </summary>
OutputFormat Format { get; }
/// <summary>
/// Renders a single object to the output stream.
/// </summary>
/// <typeparam name="T">Type of the object to render.</typeparam>
/// <param name="value">The value to render.</param>
/// <param name="writer">The text writer to output to.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task RenderAsync<T>(T value, TextWriter writer, CancellationToken cancellationToken = default);
/// <summary>
/// Renders a collection as a table or list.
/// </summary>
/// <typeparam name="T">Type of items in the collection.</typeparam>
/// <param name="items">The items to render.</param>
/// <param name="writer">The text writer to output to.</param>
/// <param name="columns">Optional column definitions for table format.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task RenderTableAsync<T>(
IEnumerable<T> items,
TextWriter writer,
IReadOnlyList<ColumnDefinition<T>>? columns = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Renders a success message.
/// </summary>
Task RenderSuccessAsync(string message, TextWriter writer, CancellationToken cancellationToken = default);
/// <summary>
/// Renders an error message.
/// </summary>
Task RenderErrorAsync(string message, TextWriter writer, string? errorCode = null, string? traceId = null, CancellationToken cancellationToken = default);
/// <summary>
/// Renders a warning message.
/// </summary>
Task RenderWarningAsync(string message, TextWriter writer, CancellationToken cancellationToken = default);
}
/// <summary>
/// Column definition for table rendering.
/// </summary>
/// <typeparam name="T">Type of the row item.</typeparam>
public sealed class ColumnDefinition<T>
{
/// <summary>
/// Column header text.
/// </summary>
public required string Header { get; init; }
/// <summary>
/// Function to extract the column value from an item.
/// </summary>
public required Func<T, string?> ValueSelector { get; init; }
/// <summary>
/// Optional minimum width for the column.
/// </summary>
public int? MinWidth { get; init; }
/// <summary>
/// Optional maximum width for the column (truncates with ellipsis).
/// </summary>
public int? MaxWidth { get; init; }
/// <summary>
/// Alignment for the column content.
/// </summary>
public ColumnAlignment Alignment { get; init; } = ColumnAlignment.Left;
}
/// <summary>
/// Column alignment for table rendering.
/// </summary>
public enum ColumnAlignment
{
Left,
Right,
Center
}

View File

@@ -0,0 +1,17 @@
namespace StellaOps.Cli.Output;
/// <summary>
/// Output format for CLI commands.
/// Per CLI-CORE-41-001, supports json/yaml/table formats.
/// </summary>
public enum OutputFormat
{
/// <summary>Human-readable table format (default).</summary>
Table,
/// <summary>JSON format for automation/scripting.</summary>
Json,
/// <summary>YAML format for configuration/scripting.</summary>
Yaml
}

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