Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 79 additions & 27 deletions cli/SimpleModule.Cli/Commands/Doctor/DoctorCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ public override int Execute(CommandContext context, DoctorSettings settings)
return 1;
}

AnsiConsole.MarkupLine("[blue]Running project health checks...[/]");
AnsiConsole.MarkupLine("");
AnsiConsole.Write(new Rule("[blue]Project health check[/]").LeftJustified());

IDoctorCheck[] checks =
[
Expand All @@ -43,25 +42,49 @@ public override int Execute(CommandContext context, DoctorSettings settings)
results.AddRange(check.Run(solution));
}

// Auto-fix if requested
var fixedCount = 0;
if (settings.Fix)
{
var beforeFail = results.Count(r => r.Status == CheckStatus.Fail);
AutoFix(solution, results);
// Re-run checks after fix
results.Clear();
foreach (var check in checks)
{
results.AddRange(check.Run(solution));
}
fixedCount = beforeFail - results.Count(r => r.Status == CheckStatus.Fail);
}

// Display results table
var table = new Table();
RenderResults(results);

var failCount = results.Count(r => r.Status == CheckStatus.Fail);
var warnCount = results.Count(r => r.Status == CheckStatus.Warning);
var passCount = results.Count(r => r.Status == CheckStatus.Pass);

AnsiConsole.WriteLine();
AnsiConsole.Write(
BuildSummaryPanel(passCount, warnCount, failCount, fixedCount, settings.Fix)
);

return failCount > 0 ? 1 : 0;
}

private static void RenderResults(IReadOnlyList<CheckResult> results)
{
// Failures first so they're the first thing the user sees, then warnings,
// then passes. Preserve discovery order within each status bucket.
var ordered = results
.Select((r, i) => (Result: r, Index: i))
.OrderBy(x => StatusPriority(x.Result.Status))
.ThenBy(x => x.Index)
.Select(x => x.Result);

var table = new Table().RoundedBorder();
table.AddColumn("Status");
table.AddColumn("Check");
table.AddColumn("Details");

foreach (var result in results)
foreach (var result in ordered)
{
var statusMarkup = result.Status switch
{
Expand All @@ -74,38 +97,67 @@ public override int Execute(CommandContext context, DoctorSettings settings)
}

AnsiConsole.Write(table);
}

var failCount = results.Count(r => r.Status == CheckStatus.Fail);
var warnCount = results.Count(r => r.Status == CheckStatus.Warning);

AnsiConsole.MarkupLine("");
if (failCount > 0)
private static int StatusPriority(CheckStatus status) =>
status switch
{
AnsiConsole.MarkupLine(
$"[red]{failCount} failure(s)[/], [yellow]{warnCount} warning(s)[/]"
);
if (!settings.Fix)
{
AnsiConsole.MarkupLine(
"[dim]Run with --fix to auto-fix missing slnx entries, project references, Pages registry entries, and npm workspace globs.[/]"
);
}
CheckStatus.Fail => 0,
CheckStatus.Warning => 1,
CheckStatus.Pass => 2,
_ => 3,
};

private static Panel BuildSummaryPanel(
int passCount,
int warnCount,
int failCount,
int fixedCount,
bool fixRequested
)
{
var total = passCount + warnCount + failCount;
var lines = new List<string>
{
$"[green]{passCount} pass[/] · [yellow]{warnCount} warn[/] · [red]{failCount} fail[/] [dim](of {total})[/]",
};

return 1;
if (fixRequested && fixedCount > 0)
{
lines.Add($"[green]✓[/] Auto-fixed [green]{fixedCount}[/] issue(s)");
}

if (warnCount > 0)
if (failCount > 0)
{
AnsiConsole.MarkupLine(
$"[green]All checks passed[/] with [yellow]{warnCount} warning(s)[/]"
lines.Add(
fixRequested
? "[red]Some failures could not be auto-fixed.[/] See table above for details."
: "[dim]Run with --fix to auto-fix slnx entries, project references, Pages registry, and npm workspace globs.[/]"
);
}
else if (warnCount > 0)
{
lines.Add("[green]All failures resolved.[/] Warnings are non-blocking.");
}
else
{
AnsiConsole.MarkupLine("[green]All checks passed![/]");
lines.Add("[green]All checks passed.[/]");
}

return 0;
var color =
failCount > 0 ? Color.Red
: warnCount > 0 ? Color.Yellow
: Color.Green;
var header =
failCount > 0 ? "Failing"
: warnCount > 0 ? "Passing with warnings"
: "Healthy";

return new Panel(string.Join("\n", lines))
.Header($"[bold]{header}[/]")
.Border(BoxBorder.Rounded)
.BorderColor(color)
.Expand();
}

private static void AutoFix(SolutionContext solution, List<CheckResult> results)
Expand Down
10 changes: 5 additions & 5 deletions cli/SimpleModule.Cli/Commands/Install/InstallCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@

namespace SimpleModule.Cli.Commands.Install;

public sealed class InstallCommand : Command<InstallSettings>
public sealed class InstallCommand : AsyncCommand<InstallSettings>
{
public override int Execute(CommandContext context, InstallSettings settings)
public override async Task<int> ExecuteAsync(CommandContext context, InstallSettings settings)
{
var solution = SolutionContext.Discover();
if (solution is null)
Expand Down Expand Up @@ -53,9 +53,9 @@ public override int Execute(CommandContext context, InstallSettings settings)
process.Start();
var outputTask = process.StandardOutput.ReadToEndAsync();
var errorTask = process.StandardError.ReadToEndAsync();
process.WaitForExit();
var output = outputTask.GetAwaiter().GetResult();
var error = errorTask.GetAwaiter().GetResult();
await process.WaitForExitAsync().ConfigureAwait(false);
var output = await outputTask.ConfigureAwait(false);
var error = await errorTask.ConfigureAwait(false);

if (process.ExitCode != 0)
{
Expand Down
119 changes: 119 additions & 0 deletions cli/SimpleModule.Cli/Commands/List/ListCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
using System.Text.RegularExpressions;
using SimpleModule.Cli.Infrastructure;
using Spectre.Console;
using Spectre.Console.Cli;

namespace SimpleModule.Cli.Commands.List;

public sealed partial class ListCommand : Command<ListSettings>
{
[GeneratedRegex(
"""RoutePrefix\s*=\s*(?:"(?<literal>[^"]*)"|(?<constref>[A-Za-z_][\w\.]*))""",
RegexOptions.Singleline,
matchTimeoutMilliseconds: 1000
)]
private static partial Regex RoutePrefixRegex();

[GeneratedRegex(
"""public\s+const\s+string\s+RoutePrefix\s*=\s*"(?<value>[^"]*)"\s*;""",
RegexOptions.Singleline,
matchTimeoutMilliseconds: 1000
)]
private static partial Regex ConstantsRoutePrefixRegex();

public override int Execute(CommandContext context, ListSettings settings)
{
var solution = SolutionContext.Discover();
if (solution is null)
{
AnsiConsole.MarkupLine(
"[red]No .slnx file found. Run this command from inside a SimpleModule project.[/]"
);
return 1;
}

if (solution.ExistingModules.Count == 0)
{
AnsiConsole.MarkupLine(
"[yellow]No modules found.[/] Create one with [green]sm new module <Name>[/]."
);
return 0;
}

var table = new Table().RoundedBorder();
table.AddColumn("Module");
table.AddColumn("Route prefix");
table.AddColumn(new TableColumn("Endpoints").RightAligned());

foreach (var module in solution.ExistingModules)
{
var routePrefix = ReadRoutePrefix(solution, module);
var endpointCount = CountEndpoints(solution, module);

table.AddRow(
$"[green]{Markup.Escape(module)}[/]",
Markup.Escape(routePrefix ?? "—"),
endpointCount.ToString(System.Globalization.CultureInfo.InvariantCulture)
);
}

AnsiConsole.Write(table);
AnsiConsole.MarkupLine(
$"\n[dim]{solution.ExistingModules.Count} module(s) in {Markup.Escape(solution.RootPath)}[/]"
);
return 0;
}

private static string? ReadRoutePrefix(SolutionContext solution, string module)
{
var moduleClassPath = Path.Combine(
solution.GetModuleProjectPath(module),
$"{module}Module.cs"
);
if (!File.Exists(moduleClassPath))
{
return null;
}

var content = File.ReadAllText(moduleClassPath);
var match = RoutePrefixRegex().Match(content);
if (!match.Success)
{
return null;
}

if (match.Groups["literal"].Success)
{
return match.Groups["literal"].Value;
}

// RoutePrefix = ModuleConstants.RoutePrefix — try to resolve from Constants.cs
var constantsPath = Path.Combine(
solution.GetModuleProjectPath(module),
$"{module}Constants.cs"
);
if (File.Exists(constantsPath))
{
var constantsMatch = ConstantsRoutePrefixRegex().Match(File.ReadAllText(constantsPath));
if (constantsMatch.Success)
{
return constantsMatch.Groups["value"].Value;
}
}

return match.Groups["constref"].Value;
}

private static int CountEndpoints(SolutionContext solution, string module)
{
var endpointsDir = Path.Combine(solution.GetModuleProjectPath(module), "Endpoints");
if (!Directory.Exists(endpointsDir))
{
return 0;
}

return Directory
.EnumerateFiles(endpointsDir, "*Endpoint.cs", SearchOption.AllDirectories)
.Count();
}
}
5 changes: 5 additions & 0 deletions cli/SimpleModule.Cli/Commands/List/ListSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
using Spectre.Console.Cli;

namespace SimpleModule.Cli.Commands.List;

public sealed class ListSettings : CommandSettings;
17 changes: 1 addition & 16 deletions cli/SimpleModule.Cli/Commands/New/NewFeatureCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public override int Execute(CommandContext context, NewFeatureSettings settings)

if (settings.DryRun)
{
RenderDryRunTree(ops);
FileTreeRenderer.Render("Would create/modify", ops, isDryRun: true);
return 0;
}

Expand Down Expand Up @@ -104,19 +104,4 @@ private static void WriteFile(string path, string content)
File.WriteAllText(path, content);
AnsiConsole.MarkupLine($"[green] + {Markup.Escape(Path.GetFileName(path))}[/]");
}

private static void RenderDryRunTree(List<(string Path, FileAction Action)> ops)
{
AnsiConsole.MarkupLine("[dim]Dry run — no files written[/]\n");
var tree = new Tree("[dim]Would create/modify:[/]");
foreach (var (path, action) in ops)
{
var label =
action == FileAction.Modify
? $"[yellow]{Markup.Escape(Path.GetFileName(path))}[/] [dim](modify)[/]"
: $"[green]{Markup.Escape(Path.GetFileName(path))}[/] [dim](create)[/]";
tree.AddNode(label);
}
AnsiConsole.Write(tree);
}
}
40 changes: 2 additions & 38 deletions cli/SimpleModule.Cli/Commands/New/NewModuleCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ public override int Execute(CommandContext context, NewModuleSettings settings)

if (settings.DryRun)
{
RenderDryRunTree(moduleName, ops);
FileTreeRenderer.Render(moduleName, ops, isDryRun: true);
return 0;
}

Expand Down Expand Up @@ -147,7 +147,7 @@ public override int Execute(CommandContext context, NewModuleSettings settings)
}
);

RenderCreatedTree(moduleName, ops);
FileTreeRenderer.Render(moduleName, ops);

AnsiConsole.MarkupLine($"\n[green]Module '{Markup.Escape(moduleName)}' created![/]");
AnsiConsole.MarkupLine("[dim]Next steps:[/]");
Expand All @@ -157,40 +157,4 @@ public override int Execute(CommandContext context, NewModuleSettings settings)
AnsiConsole.MarkupLine("[dim] dotnet build[/]");
return 0;
}

private static void RenderDryRunTree(
string moduleName,
List<(string Path, FileAction Action)> ops
)
{
AnsiConsole.MarkupLine("[dim]Dry run — no files written[/]\n");
var tree = new Tree($"[blue]{Markup.Escape(moduleName)}[/]");
foreach (var (path, action) in ops)
{
var label =
action == FileAction.Modify
? $"[yellow]{Markup.Escape(Path.GetFileName(path))}[/] [dim](modify)[/]"
: $"[green]{Markup.Escape(Path.GetFileName(path))}[/] [dim](create)[/]";
tree.AddNode(label);
}
AnsiConsole.Write(tree);
}

private static void RenderCreatedTree(
string moduleName,
List<(string Path, FileAction Action)> ops
)
{
AnsiConsole.MarkupLine("");
var tree = new Tree($"[blue]{Markup.Escape(moduleName)}[/]");
foreach (var (path, action) in ops)
{
var label =
action == FileAction.Modify
? $"[yellow]{Markup.Escape(Path.GetFileName(path))}[/] [dim](modified)[/]"
: $"[green]{Markup.Escape(Path.GetFileName(path))}[/]";
tree.AddNode(label);
}
AnsiConsole.Write(tree);
}
}
Loading
Loading