using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using StellaOps.Cli.Extensions; using StellaOps.Doctor.Engine; using StellaOps.Doctor.Export; using StellaOps.Doctor.Models; using StellaOps.Doctor.Output; using StellaOps.Doctor.Packs; using StellaOps.Doctor.Plugins; using System; using System.Collections.Generic; using System.CommandLine; using System.CommandLine.Parsing; using System.Globalization; using System.IO; using System.Linq; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; namespace StellaOps.Cli.Commands; /// /// Builds the stella doctor command for diagnostic checks. /// internal static class DoctorCommandGroup { private const int MaxFixOutputChars = 2000; private static readonly Regex PlaceholderPattern = new(@"\{[A-Z][A-Z0-9_]*\}", RegexOptions.Compiled | RegexOptions.CultureInvariant); internal static Command BuildDoctorCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var doctor = new Command("doctor", "Run diagnostic checks on Stella Ops installation and environment."); var rootRunOptions = CreateRunOptions(); AddRunOptions(doctor, rootRunOptions, verboseOption); doctor.SetAction(async (parseResult, ct) => { await RunDoctorFromParseResultAsync( parseResult, rootRunOptions, verboseOption, services, cancellationToken); }); // Sub-commands doctor.Add(BuildRunCommand(services, verboseOption, cancellationToken)); doctor.Add(BuildListCommand(services, verboseOption, cancellationToken)); doctor.Add(BuildExportCommand(services, verboseOption, cancellationToken)); doctor.Add(BuildFixCommand(services, verboseOption, cancellationToken)); doctor.Add(KnowledgeSearchCommandGroup.BuildDoctorSuggestCommand(services, verboseOption, cancellationToken)); return doctor; } private static Command BuildRunCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var runOptions = CreateRunOptions(); var run = new Command("run", "Execute diagnostic checks."); AddRunOptions(run, runOptions, verboseOption); run.SetAction(async (parseResult, ct) => { await RunDoctorFromParseResultAsync( parseResult, runOptions, verboseOption, services, cancellationToken); }); return run; } private static DoctorRunCommandOptions CreateRunOptions() { var formatOption = new Option("--format", new[] { "-f" }) { Description = "Output format: text (default), json, markdown" }; formatOption.SetDefaultValue("text"); var modeOption = new Option("--mode", new[] { "-m" }) { Description = "Run mode: quick (fast checks only), normal (default), full (all checks including slow ones)" }; var categoryOption = new Option("--category", new[] { "-c" }) { Description = "Filter checks by category (e.g., Core, Database, Security)" }; var tagOption = new Option("--tag", new[] { "-t" }) { Description = "Filter checks by tag (e.g., quick, connectivity). Can be specified multiple times.", Arity = ArgumentArity.ZeroOrMore }; var checkOption = new Option("--check") { Description = "Run a specific check by ID (e.g., check.core.disk)" }; var parallelOption = new Option("--parallel", new[] { "-p" }) { Description = "Maximum parallel check executions (default: 4)" }; var timeoutOption = new Option("--timeout") { Description = "Per-check timeout in seconds (default: 30)" }; var outputOption = new Option("--output", new[] { "-o" }) { Description = "Write output to file instead of stdout" }; var failOnWarnOption = new Option("--fail-on-warn") { Description = "Exit with non-zero code on warnings (default: only fail on errors)" }; var watchOption = new Option("--watch", new[] { "-w" }) { Description = "Run in continuous monitoring mode" }; var intervalOption = new Option("--interval") { Description = "Interval in seconds between checks in watch mode (default: 60)" }; var envOption = new Option("--env", new[] { "-e" }) { Description = "Target environment for checks (e.g., dev, staging, prod)" }; return new DoctorRunCommandOptions( formatOption, modeOption, categoryOption, tagOption, checkOption, parallelOption, timeoutOption, outputOption, failOnWarnOption, watchOption, intervalOption, envOption); } private static void AddRunOptions( Command command, DoctorRunCommandOptions options, Option verboseOption) { command.Add(options.FormatOption); command.Add(options.ModeOption); command.Add(options.CategoryOption); command.Add(options.TagOption); command.Add(options.CheckOption); command.Add(options.ParallelOption); command.Add(options.TimeoutOption); command.Add(options.OutputOption); command.Add(options.FailOnWarnOption); command.Add(options.WatchOption); command.Add(options.IntervalOption); command.Add(options.EnvOption); command.Add(verboseOption); } private static async Task RunDoctorFromParseResultAsync( ParseResult parseResult, DoctorRunCommandOptions options, Option verboseOption, IServiceProvider services, CancellationToken cancellationToken) { var format = parseResult.GetValue(options.FormatOption) ?? "text"; var mode = parseResult.GetValue(options.ModeOption); var category = parseResult.GetValue(options.CategoryOption); var tags = parseResult.GetValue(options.TagOption) ?? []; var checkId = parseResult.GetValue(options.CheckOption); var parallel = parseResult.GetValue(options.ParallelOption) ?? 4; var timeout = parseResult.GetValue(options.TimeoutOption) ?? 30; var output = parseResult.GetValue(options.OutputOption); var failOnWarn = parseResult.GetValue(options.FailOnWarnOption); var verbose = parseResult.GetValue(verboseOption); await RunDoctorAsync( services, format, mode, category, tags, checkId, parallel, timeout, output, failOnWarn, verbose, cancellationToken); } private static Command BuildListCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var categoryOption = new Option("--category", new[] { "-c" }) { Description = "Filter by category" }; var tagOption = new Option("--tag", new[] { "-t" }) { Description = "Filter by tag", Arity = ArgumentArity.ZeroOrMore }; var list = new Command("list", "List available diagnostic checks.") { categoryOption, tagOption, verboseOption }; list.SetAction((parseResult, ct) => { var category = parseResult.GetValue(categoryOption); var tags = parseResult.GetValue(tagOption) ?? []; var verbose = parseResult.GetValue(verboseOption); return ListChecksAsync(services, category, tags, verbose, cancellationToken); }); return list; } private static Command BuildExportCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var outputOption = new Option("--output", new[] { "-o" }) { Description = "Output ZIP file path", Arity = ArgumentArity.ExactlyOne }; var includeLogsOption = new Option("--include-logs") { Description = "Include recent log files in the bundle (default: true)" }; includeLogsOption.SetDefaultValue(true); var logDurationOption = new Option("--log-duration") { Description = "Duration of logs to include (e.g., 1h, 4h, 24h). Default: 1h" }; var noConfigOption = new Option("--no-config") { Description = "Exclude configuration from the bundle" }; var export = new Command("export", "Generate diagnostic bundle for support") { outputOption, includeLogsOption, logDurationOption, noConfigOption, verboseOption }; export.SetAction(async (parseResult, ct) => { var output = parseResult.GetValue(outputOption)!; var includeLogs = parseResult.GetValue(includeLogsOption); var logDuration = parseResult.GetValue(logDurationOption); var noConfig = parseResult.GetValue(noConfigOption); var verbose = parseResult.GetValue(verboseOption); await ExportDiagnosticBundleAsync( services, output, includeLogs, logDuration, noConfig, verbose, cancellationToken); }); return export; } private static Command BuildFixCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var fromOption = new Option("--from") { Description = "Path to JSON report file with fix commands", Arity = ArgumentArity.ExactlyOne }; var applyOption = new Option("--apply") { Description = "Execute non-destructive fixes (default: dry-run preview)" }; var fix = new Command("fix", "Apply non-destructive fixes from a doctor report.") { fromOption, applyOption, verboseOption }; fix.SetAction(async (parseResult, ct) => { var reportPath = parseResult.GetValue(fromOption); var apply = parseResult.GetValue(applyOption); var verbose = parseResult.GetValue(verboseOption); await ApplyFixesAsync(services, reportPath, apply, verbose, cancellationToken); }); return fix; } private static async Task ExportDiagnosticBundleAsync( IServiceProvider services, string outputPath, bool includeLogs, string? logDuration, bool noConfig, bool verbose, CancellationToken ct) { var generator = services.GetRequiredService(); var duration = ParseDuration(logDuration) ?? TimeSpan.FromHours(1); var options = new DiagnosticBundleOptions { IncludeConfig = !noConfig, IncludeLogs = includeLogs, LogDuration = duration }; Console.WriteLine("Generating diagnostic bundle..."); var bundle = await generator.GenerateAsync(options, ct); // Ensure output path has .zip extension if (!outputPath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) { outputPath += ".zip"; } await generator.ExportToZipAsync(bundle, outputPath, ct); Console.WriteLine(); Console.WriteLine($"Diagnostic bundle created: {outputPath}"); Console.WriteLine(); Console.WriteLine($"Summary:"); Console.WriteLine($" Passed: {bundle.DoctorReport.Summary.Passed}"); Console.WriteLine($" Warnings: {bundle.DoctorReport.Summary.Warnings}"); Console.WriteLine($" Failed: {bundle.DoctorReport.Summary.Failed}"); Console.WriteLine(); Console.WriteLine("Share this bundle with Stella Ops support for assistance."); } private static TimeSpan? ParseDuration(string? duration) { if (string.IsNullOrEmpty(duration)) { return null; } // Parse duration strings like "1h", "4h", "30m", "24h" if (duration.EndsWith("h", StringComparison.OrdinalIgnoreCase)) { if (double.TryParse(duration.AsSpan(0, duration.Length - 1), NumberStyles.Float, CultureInfo.InvariantCulture, out var hours)) { return TimeSpan.FromHours(hours); } } if (duration.EndsWith("m", StringComparison.OrdinalIgnoreCase)) { if (double.TryParse(duration.AsSpan(0, duration.Length - 1), NumberStyles.Float, CultureInfo.InvariantCulture, out var minutes)) { return TimeSpan.FromMinutes(minutes); } } if (duration.EndsWith("d", StringComparison.OrdinalIgnoreCase)) { if (double.TryParse(duration.AsSpan(0, duration.Length - 1), NumberStyles.Float, CultureInfo.InvariantCulture, out var days)) { return TimeSpan.FromDays(days); } } return null; } private static async Task RunDoctorAsync( IServiceProvider services, string format, string? mode, string? category, string[] tags, string? checkId, int parallel, int timeout, string? outputPath, bool failOnWarn, bool verbose, CancellationToken ct) { var engine = services.GetRequiredService(); var runMode = ParseRunMode(mode); var options = new DoctorRunOptions { Mode = runMode, Categories = category != null ? [category] : null, Tags = tags.Length > 0 ? tags : null, CheckIds = checkId != null ? [checkId] : null, Parallelism = parallel, Timeout = TimeSpan.FromSeconds(timeout), DoctorCommand = Environment.CommandLine }; // Progress reporting for verbose mode IProgress? progress = null; if (verbose) { progress = new Progress(p => { Console.WriteLine($"[{p.Completed}/{p.Total}] {p.CheckId} - {p.Severity}"); }); } var report = await engine.RunAsync(options, progress, ct); // Format output var formatter = GetFormatter(format); var output = formatter.Format(report); // Write output if (!string.IsNullOrEmpty(outputPath)) { await File.WriteAllTextAsync(outputPath, output, ct); Console.WriteLine($"Report written to: {outputPath}"); } else { Console.WriteLine(output); } // Set exit code SetExitCode(report, failOnWarn); } private static async Task ApplyFixesAsync( IServiceProvider services, string? reportPath, bool apply, bool verbose, CancellationToken ct) { if (string.IsNullOrWhiteSpace(reportPath)) { Console.Error.WriteLine("Error: --from is required."); Environment.ExitCode = CliExitCodes.MissingRequiredOption; return; } if (!File.Exists(reportPath)) { Console.Error.WriteLine($"Error: Report file not found: {reportPath}"); Environment.ExitCode = CliExitCodes.InputFileNotFound; return; } IReadOnlyList steps; try { steps = await LoadFixStepsAsync(reportPath, ct); } catch (Exception ex) when (ex is JsonException or InvalidOperationException) { Console.Error.WriteLine($"Error: Unable to parse doctor report: {ex.Message}"); Environment.ExitCode = CliExitCodes.GeneralError; return; } catch (Exception ex) { Console.Error.WriteLine($"Error: Unable to read doctor report: {ex.Message}"); Environment.ExitCode = CliExitCodes.GeneralError; return; } var orderedSteps = steps .Where(step => !string.IsNullOrWhiteSpace(step.Command)) .OrderBy(step => step.CheckId, StringComparer.Ordinal) .ThenBy(step => step.Order) .ThenBy(step => step.Command, StringComparer.Ordinal) .ToList(); if (orderedSteps.Count == 0) { Console.WriteLine("No fix commands found."); return; } var plan = BuildFixPlan(orderedSteps); var distinctChecks = plan .Select(entry => entry.Step.CheckId) .Distinct(StringComparer.Ordinal) .Count(); var safeCount = plan.Count(entry => entry.IsSafe); var manualCount = plan.Count - safeCount; Console.WriteLine($"Found {plan.Count} fix command(s) across {distinctChecks} check(s)."); Console.WriteLine($"Safe commands: {safeCount}. Manual commands: {manualCount}."); if (!apply) { Console.WriteLine("Dry-run preview. Use --apply to execute safe commands."); PrintFixPlan(plan, verbose); return; } if (safeCount == 0) { Console.WriteLine("No safe commands to apply."); return; } Console.WriteLine("Applying safe fix commands..."); var runner = services.GetRequiredService(); var context = BuildFixContext(services); var applied = 0; var skipped = 0; var failed = 0; foreach (var entry in plan) { if (!entry.IsSafe) { skipped++; WritePlanEntry("SKIP", entry, verbose); continue; } WritePlanEntry("RUN", entry, verbose); var result = await runner.RunAsync( new DoctorPackCommand(entry.Step.Command), context, ct); if (result.ExitCode == 0 && string.IsNullOrWhiteSpace(result.Error)) { applied++; if (verbose && !string.IsNullOrWhiteSpace(result.StdOut)) { Console.WriteLine(TrimOutput(result.StdOut)); } } else { failed++; Console.WriteLine($"[FAIL] {FormatStep(entry.Step)}"); if (!string.IsNullOrWhiteSpace(result.Error)) { Console.WriteLine($" Error: {result.Error}"); } if (!string.IsNullOrWhiteSpace(result.StdErr)) { Console.WriteLine($" Stderr: {TrimOutput(result.StdErr)}"); } } } Console.WriteLine($"Applied: {applied}. Skipped: {skipped}. Failed: {failed}."); if (failed > 0) { Environment.ExitCode = CliExitCodes.GeneralError; } } private static List BuildFixPlan(IReadOnlyList steps) { var plan = new List(steps.Count); foreach (var step in steps) { var isSafe = IsSafeCommand(step, out var reason); plan.Add(new DoctorFixPlanEntry(step, isSafe, isSafe ? string.Empty : reason)); } return plan; } private static void PrintFixPlan(IReadOnlyList plan, bool verbose) { foreach (var entry in plan) { var label = entry.IsSafe ? "SAFE" : "MANUAL"; WritePlanEntry(label, entry, verbose); } } private static void WritePlanEntry(string label, DoctorFixPlanEntry entry, bool verbose) { var reasonSuffix = entry.IsSafe || string.IsNullOrWhiteSpace(entry.Reason) ? string.Empty : $" ({entry.Reason})"; Console.WriteLine($"[{label}] {FormatStep(entry.Step)}: {entry.Step.Command}{reasonSuffix}"); if (!verbose) { return; } if (!string.IsNullOrWhiteSpace(entry.Step.Description)) { Console.WriteLine($" {entry.Step.Description}"); } if (!string.IsNullOrWhiteSpace(entry.Step.SafetyNote)) { Console.WriteLine($" Safety: {entry.Step.SafetyNote}"); } } private static string FormatStep(DoctorFixStep step) { return $"{step.CheckId} step {step.Order.ToString(CultureInfo.InvariantCulture)}"; } private static async Task> LoadFixStepsAsync( string reportPath, CancellationToken ct) { try { await using var stream = File.OpenRead(reportPath); using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct); return ExtractFixSteps(doc.RootElement); } catch (JsonException) { return await LoadFixStepsFromJsonLinesAsync(reportPath, ct); } } private static async Task> LoadFixStepsFromJsonLinesAsync( string reportPath, CancellationToken ct) { var steps = new List(); using var stream = new FileStream(reportPath, FileMode.Open, FileAccess.Read, FileShare.Read); using var reader = new StreamReader(stream); string? line; while ((line = await reader.ReadLineAsync()) is not null) { ct.ThrowIfCancellationRequested(); if (string.IsNullOrWhiteSpace(line)) { continue; } using var doc = JsonDocument.Parse(line); steps.AddRange(ExtractFixSteps(doc.RootElement)); } return steps; } private static IReadOnlyList ExtractFixSteps(JsonElement root) { var steps = new List(); if (root.ValueKind == JsonValueKind.Object) { if (TryGetProperty(root, "results", out var results) || TryGetProperty(root, "checks", out results)) { AppendFixStepsFromResults(results, steps); } else { AppendFixStepsFromResult(root, steps); } } else if (root.ValueKind == JsonValueKind.Array) { AppendFixStepsFromResults(root, steps); } return steps; } private static void AppendFixStepsFromResults(JsonElement results, List steps) { if (results.ValueKind != JsonValueKind.Array) { return; } foreach (var result in results.EnumerateArray()) { AppendFixStepsFromResult(result, steps); } } private static void AppendFixStepsFromResult(JsonElement result, List steps) { var checkId = GetString(result, "checkId") ?? "unknown"; if (TryGetProperty(result, "remediation", out var remediation)) { AppendFixStepsFromRemediation(remediation, checkId, steps); return; } if (TryGetProperty(result, "how_to_fix", out var howToFix) || TryGetProperty(result, "howToFix", out howToFix)) { AppendFixStepsFromHowToFix(howToFix, checkId, steps); } } private static void AppendFixStepsFromRemediation( JsonElement remediation, string checkId, List steps) { var requiresBackup = GetBool(remediation, "requiresBackup"); var safetyNote = GetString(remediation, "safetyNote"); if (TryGetProperty(remediation, "steps", out var stepArray) && stepArray.ValueKind == JsonValueKind.Array) { var fallbackOrder = 0; foreach (var stepElement in stepArray.EnumerateArray()) { fallbackOrder++; var command = GetString(stepElement, "command"); if (string.IsNullOrWhiteSpace(command)) { continue; } var order = GetInt(stepElement, "order") ?? fallbackOrder; if (order <= 0) { order = fallbackOrder; } var description = GetString(stepElement, "description"); var commandType = GetString(stepElement, "commandType"); if (string.IsNullOrWhiteSpace(commandType)) { commandType = "shell"; } steps.Add(new DoctorFixStep( checkId, order, command.Trim(), commandType, description, requiresBackup, safetyNote)); } return; } if (TryGetProperty(remediation, "commands", out var commands)) { AppendFixStepsFromCommands( checkId, commands, "shell", null, requiresBackup, safetyNote, steps); } } private static void AppendFixStepsFromHowToFix( JsonElement howToFix, string checkId, List steps) { if (TryGetProperty(howToFix, "commands", out var commands)) { AppendFixStepsFromCommands( checkId, commands, "shell", null, requiresBackup: false, safetyNote: null, steps); } } private static void AppendFixStepsFromCommands( string checkId, JsonElement commands, string commandType, string? description, bool requiresBackup, string? safetyNote, List steps) { if (commands.ValueKind != JsonValueKind.Array) { return; } var order = 0; foreach (var commandValue in commands.EnumerateArray()) { if (commandValue.ValueKind != JsonValueKind.String) { continue; } var command = commandValue.GetString(); if (string.IsNullOrWhiteSpace(command)) { continue; } order++; steps.Add(new DoctorFixStep( checkId, order, command.Trim(), commandType, description, requiresBackup, safetyNote)); } } private static DoctorPluginContext BuildFixContext(IServiceProvider services) { var configuration = services.GetRequiredService(); var loggerFactory = services.GetRequiredService(); var logger = loggerFactory.CreateLogger("DoctorFix"); var timeProvider = services.GetRequiredService(); var environment = services.GetService(); return new DoctorPluginContext { Services = services, Configuration = configuration, TimeProvider = timeProvider, Logger = logger, EnvironmentName = environment?.EnvironmentName ?? Environments.Production, PluginConfig = configuration.GetSection("Doctor:Packs") }; } private static bool IsSafeCommand(DoctorFixStep step, out string reason) { if (!IsShellCommand(step.CommandType)) { reason = "unsupported command type"; return false; } if (step.RequiresBackup) { reason = "requires backup"; return false; } if (!IsStellaCommand(step.Command)) { reason = "non-stella command"; return false; } if (ContainsPlaceholders(step.Command)) { reason = "has placeholders"; return false; } reason = string.Empty; return true; } private static bool IsShellCommand(string? commandType) { return string.IsNullOrWhiteSpace(commandType) || string.Equals(commandType.Trim(), "shell", StringComparison.OrdinalIgnoreCase); } private static bool IsStellaCommand(string command) { if (string.IsNullOrWhiteSpace(command)) { return false; } var trimmed = command.TrimStart(); var token = trimmed .Split(new[] { ' ', '\t', '\r', '\n' }, 2, StringSplitOptions.RemoveEmptyEntries) .FirstOrDefault(); if (string.IsNullOrWhiteSpace(token)) { return false; } token = token.Trim('"', '\''); return string.Equals(token, "stella", StringComparison.OrdinalIgnoreCase) || string.Equals(token, "stella.exe", StringComparison.OrdinalIgnoreCase) || string.Equals(token, "stella.cmd", StringComparison.OrdinalIgnoreCase) || string.Equals(token, "stella.ps1", StringComparison.OrdinalIgnoreCase) || string.Equals(token, "./stella", StringComparison.OrdinalIgnoreCase) || string.Equals(token, "./stella.exe", StringComparison.OrdinalIgnoreCase) || string.Equals(token, ".\\stella", StringComparison.OrdinalIgnoreCase) || string.Equals(token, ".\\stella.exe", StringComparison.OrdinalIgnoreCase); } private static bool ContainsPlaceholders(string command) { return !string.IsNullOrWhiteSpace(command) && PlaceholderPattern.IsMatch(command); } private static bool TryGetProperty(JsonElement element, string propertyName, out JsonElement value) { value = default; if (element.ValueKind != JsonValueKind.Object) { return false; } foreach (var property in element.EnumerateObject()) { if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase)) { value = property.Value; return true; } } return false; } private static string? GetString(JsonElement element, string propertyName) { if (!TryGetProperty(element, propertyName, out var value)) { return null; } return value.ValueKind switch { JsonValueKind.String => value.GetString(), JsonValueKind.Number => value.GetRawText(), JsonValueKind.True => "true", JsonValueKind.False => "false", JsonValueKind.Null => null, _ => value.GetRawText() }; } private static bool GetBool(JsonElement element, string propertyName) { if (!TryGetProperty(element, propertyName, out var value)) { return false; } return value.ValueKind switch { JsonValueKind.True => true, JsonValueKind.False => false, JsonValueKind.String => bool.TryParse(value.GetString(), out var parsed) && parsed, _ => false }; } private static int? GetInt(JsonElement element, string propertyName) { if (!TryGetProperty(element, propertyName, out var value)) { return null; } if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) { return number; } if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) { return parsed; } return null; } private static string TrimOutput(string value) { if (string.IsNullOrWhiteSpace(value)) { return string.Empty; } var trimmed = value.Trim(); if (trimmed.Length <= MaxFixOutputChars) { return trimmed; } return trimmed[..MaxFixOutputChars] + "...(truncated)"; } private static async Task ListChecksAsync( IServiceProvider services, string? category, string[] tags, bool verbose, CancellationToken ct) { var engine = services.GetRequiredService(); var options = new DoctorRunOptions { Categories = category != null ? [category] : null, Tags = tags.Length > 0 ? tags : null }; var checks = engine.ListChecks(options); Console.WriteLine($"Available diagnostic checks ({checks.Count}):"); Console.WriteLine(); string? currentCategory = null; foreach (var check in checks.OrderBy(c => c.Category).ThenBy(c => c.CheckId)) { if (check.Category != currentCategory) { currentCategory = check.Category; Console.WriteLine($"## {currentCategory ?? "Uncategorized"}"); Console.WriteLine(); } Console.WriteLine($" {check.CheckId}"); Console.WriteLine($" Name: {check.Name}"); if (verbose) { Console.WriteLine($" Description: {check.Description}"); Console.WriteLine($" Plugin: {check.PluginId}"); Console.WriteLine($" Tags: {string.Join(", ", check.Tags)}"); Console.WriteLine($" Estimated: {check.EstimatedDuration.TotalSeconds:F1}s"); } Console.WriteLine(); } await Task.CompletedTask; } private static DoctorRunMode ParseRunMode(string? mode) { return mode?.ToLowerInvariant() switch { "quick" => DoctorRunMode.Quick, "full" => DoctorRunMode.Full, _ => DoctorRunMode.Normal }; } private static IDoctorReportFormatter GetFormatter(string format) { return format.ToLowerInvariant() switch { "json" => new JsonReportFormatter(), "markdown" or "md" => new MarkdownReportFormatter(), _ => new TextReportFormatter() }; } private static void SetExitCode(DoctorReport report, bool failOnWarn) { var exitCode = report.OverallSeverity switch { DoctorSeverity.Fail => CliExitCodes.DoctorFailed, DoctorSeverity.Warn when failOnWarn => CliExitCodes.DoctorWarning, _ => 0 }; if (exitCode != 0) { Environment.ExitCode = exitCode; } } private sealed record DoctorRunCommandOptions( Option FormatOption, Option ModeOption, Option CategoryOption, Option TagOption, Option CheckOption, Option ParallelOption, Option TimeoutOption, Option OutputOption, Option FailOnWarnOption, Option WatchOption, Option IntervalOption, Option EnvOption); private sealed record DoctorFixStep( string CheckId, int Order, string Command, string CommandType, string? Description, bool RequiresBackup, string? SafetyNote); private sealed record DoctorFixPlanEntry( DoctorFixStep Step, bool IsSafe, string Reason); }