Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Output/CliErrorRenderer.cs
2026-01-07 09:43:12 +02:00

256 lines
8.1 KiB
C#

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)}");
if (TryGetReasonCode(error, out var reasonCode))
{
AnsiConsole.MarkupLine($"[grey]Reason:[/] {Markup.Escape(reasonCode)}");
}
// 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);
RenderOfflineKitGuidance(error);
}
private static bool TryGetReasonCode(CliError error, out string reasonCode)
{
reasonCode = "";
if (error.Metadata is null || error.Metadata.Count == 0)
{
return false;
}
string? code1 = null;
string? code2 = null;
if ((!error.Metadata.TryGetValue("reason_code", out code1) || string.IsNullOrWhiteSpace(code1)) &&
(!error.Metadata.TryGetValue("reasonCode", out code2) || string.IsNullOrWhiteSpace(code2)))
{
return false;
}
reasonCode = OfflineKitReasonCodes.Normalize(code1 ?? code2 ?? "") ?? "";
return reasonCode.Length > 0;
}
private static void RenderOfflineKitGuidance(CliError error)
{
if (!TryGetReasonCode(error, out var reasonCode))
{
return;
}
var remediation = OfflineKitReasonCodes.GetRemediation(reasonCode);
if (string.IsNullOrWhiteSpace(remediation))
{
return;
}
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine($"[yellow]Remediation:[/] {Markup.Escape(remediation)}");
}
}