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:
353
src/Cli/StellaOps.Cli/Output/CliError.cs
Normal file
353
src/Cli/StellaOps.Cli/Output/CliError.cs
Normal 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";
|
||||
}
|
||||
211
src/Cli/StellaOps.Cli/Output/CliErrorRenderer.cs
Normal file
211
src/Cli/StellaOps.Cli/Output/CliErrorRenderer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
98
src/Cli/StellaOps.Cli/Output/IOutputRenderer.cs
Normal file
98
src/Cli/StellaOps.Cli/Output/IOutputRenderer.cs
Normal 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
|
||||
}
|
||||
17
src/Cli/StellaOps.Cli/Output/OutputFormat.cs
Normal file
17
src/Cli/StellaOps.Cli/Output/OutputFormat.cs
Normal 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
|
||||
}
|
||||
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