From 53f15ade7a2c9766181463a6a8a4599b4e66beaf Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 10 Feb 2026 16:05:17 +0100 Subject: [PATCH 1/6] refactor: update visualization command to support 'flat' output format --- .../Commands/VisualizeCommand.cs | 34 +++-- src/ProjGraph.Cli/Program.cs | 4 +- src/ProjGraph.Cli/Rendering/TreeRenderer.cs | 121 +++++++++++++++++- .../Rendering/MermaidGraphRenderer.cs | 30 +++-- .../VisualizeCommandTests.cs | 36 ++++-- .../MermaidGraphRendererTests.cs | 57 +++++++++ 6 files changed, 249 insertions(+), 33 deletions(-) diff --git a/src/ProjGraph.Cli/Commands/VisualizeCommand.cs b/src/ProjGraph.Cli/Commands/VisualizeCommand.cs index fe6aa9a..c0c3541 100644 --- a/src/ProjGraph.Cli/Commands/VisualizeCommand.cs +++ b/src/ProjGraph.Cli/Commands/VisualizeCommand.cs @@ -42,16 +42,16 @@ public sealed class Settings : CommandSettings /// /// Gets or sets the output format for the visualization. - /// Supported formats are "tree" and "mermaid". + /// Supported formats are "flat", "tree", and "mermaid". /// [CommandOption("-f|--format")] - [Description("The output format (tree, mermaid)")] - [DefaultValue("tree")] - public string Format { get; init; } = "tree"; + [Description("The output format (flat, tree, mermaid)")] + [DefaultValue("mermaid")] + public string Format { get; init; } = "mermaid"; /// /// Validates the settings provided for the command. - /// Ensures that the specified path exists, is valid, and that the format is either "tree" or "mermaid". + /// Ensures that the specified path exists, is valid, and that the format is "flat", "tree" or "mermaid". /// /// /// A indicating whether the settings are valid. @@ -68,9 +68,9 @@ public override ValidationResult Validate() return ValidationResult.Error($"File not found: {Path}"); } - if (Format != "tree" && Format != "mermaid") + if (Format != "flat" && Format != "tree" && Format != "mermaid") { - return ValidationResult.Error("Format must be 'tree' or 'mermaid'"); + return ValidationResult.Error("Format must be 'flat', 'tree' or 'mermaid'"); } return ValidationResult.Success(); @@ -79,7 +79,7 @@ public override ValidationResult Validate() /// /// Executes the command asynchronously, analyzing the specified solution or project file and rendering its structure - /// in the specified format (tree or mermaid). + /// in the specified format (flat, tree or mermaid). /// /// /// The command context containing information about the execution environment. @@ -112,13 +112,27 @@ public override async Task ExecuteAsync( { // We'll keep AnsiConsole.Status for now as it's a CLI UI feature, // but we use the service for the final render if we refactor it. + SolutionGraph? graph = null; await AnsiConsole.Status() .Spinner(Spinner.Known.Dots) .StartAsync($"Analyzing [blue]{settings.Path}[/]...", async _ => { - var graph = await Task.Run(() => graphService.BuildGraph(settings.Path), cancellationToken); - TreeRenderer.Render(graph); + graph = await Task.Run(() => graphService.BuildGraph(settings.Path), cancellationToken); }); + + if (graph is null) + { + return 0; + } + + if (settings.Format.Equals("flat", StringComparison.OrdinalIgnoreCase)) + { + TreeRenderer.RenderFlat(graph); + } + else + { + TreeRenderer.RenderTree(graph); + } } return 0; diff --git a/src/ProjGraph.Cli/Program.cs b/src/ProjGraph.Cli/Program.cs index d38224b..a4e0519 100644 --- a/src/ProjGraph.Cli/Program.cs +++ b/src/ProjGraph.Cli/Program.cs @@ -25,7 +25,7 @@ public static int Main(string[] args) config.AddCommand("visualize") .WithDescription("Visualize the dependency graph of a solution or project") .WithExample("visualize", "MySolution.sln") - .WithExample("visualize", "MySolution.sln", "--format", "mermaid"); + .WithExample("visualize", "MySolution.sln", "--format", "tree"); config.AddCommand("erd") .WithDescription("Generate a Mermaid ERD for an Entity Framework Core DbContext") @@ -35,7 +35,7 @@ public static int Main(string[] args) config.AddCommand("classdiagram") .WithDescription("Generate a Mermaid Class Diagram for a C# file") .WithExample("classdiagram", "Services/UserService.cs") - .WithExample("classdiagram", "Models/User.cs", "--inheritance", "--dependencies"); + .WithExample("classdiagram", "Models/User.cs", "--inheritance", "--dependencies", "--depth", "10"); }); return app.Run(args); diff --git a/src/ProjGraph.Cli/Rendering/TreeRenderer.cs b/src/ProjGraph.Cli/Rendering/TreeRenderer.cs index 5bd9b5e..2598ccb 100644 --- a/src/ProjGraph.Cli/Rendering/TreeRenderer.cs +++ b/src/ProjGraph.Cli/Rendering/TreeRenderer.cs @@ -14,12 +14,12 @@ namespace ProjGraph.Cli.Rendering; public static class TreeRenderer { /// - /// Renders the solution graph by displaying its header, projects, dependencies, and any detected cyclic dependencies. + /// Renders the solution graph as a flat list of projects and their direct dependencies. /// /// /// The solution graph containing the projects and dependencies to be rendered. /// - public static void Render(SolutionGraph graph) + public static void RenderFlat(SolutionGraph graph) { RenderHeader(graph); @@ -46,6 +46,123 @@ public static void Render(SolutionGraph graph) RenderCycleWarning(cyclicProjectIds); } + /// + /// Renders the solution graph as a real tree structure, starting from root projects. + /// + /// + /// The solution graph containing the projects and dependencies to be rendered. + /// + public static void RenderTree(SolutionGraph graph) + { + RenderHeader(graph); + + // Identify incoming dependency counts to find roots + var incomingCounts = graph.Projects.ToDictionary(p => p.Id, _ => 0); + foreach (var dep in graph.Dependencies) + { + if (incomingCounts.TryGetValue(dep.TargetId, out var value)) + { + incomingCounts[dep.TargetId] = ++value; + } + } + + var cycles = TarjanSccAlgorithm.FindStronglyConnectedComponents(graph); + var cyclicProjectIds = cycles + .Where(c => c.Count > 1) + .SelectMany(c => c) + .ToHashSet(); + + var globalVisited = new HashSet(); + + // 1. Print the Solution Name as the main header + AnsiConsole.MarkupLine($"[bold blue]{Markup.Escape(graph.Name.Trim())}[/]"); + + // 2. Identify "Root" projects: projects with 0 incoming dependencies + var rootProjects = graph.Projects + .Where(p => incomingCounts[p.Id] == 0) + .OrderBy(p => p.Type) + .ThenBy(p => p.Name) + .ToList(); + + // 3. Render each root branch as a separate tree to allow for true blank lines between them + foreach (var project in rootProjects) + { + AnsiConsole.WriteLine(); // Spacing line between branches + var rootLabel = GetProjectMarkup(project, cyclicProjectIds); + var tree = new Tree(rootLabel); + + AddChildrenRecursive(tree, project, graph, [], globalVisited, cyclicProjectIds); + AnsiConsole.Write(tree); + } + + // 4. Add remaining projects (those not reachable from roots, e.g. purely cyclic clusters) + var remainingProjects = graph.Projects + .Where(p => !globalVisited.Contains(p.Id)) + .OrderBy(p => p.Name) + .ToList(); + + foreach (var project in remainingProjects.Where(project => !globalVisited.Contains(project.Id))) + { + AnsiConsole.WriteLine(); + var rootLabel = GetProjectMarkup(project, cyclicProjectIds); + var tree = new Tree(rootLabel); + + AddChildrenRecursive(tree, project, graph, [], globalVisited, cyclicProjectIds); + AnsiConsole.Write(tree); + } + + RenderCycleWarning(cyclicProjectIds); + } + + private static void AddChildrenRecursive( + IHasTreeNodes parent, + Project project, + SolutionGraph graph, + HashSet currentPath, + HashSet globalVisited, + HashSet cyclicProjectIds) + { + globalVisited.Add(project.Id); + currentPath.Add(project.Id); + + var dependencies = graph.Dependencies + .Where(d => d.SourceId == project.Id) + .Select(d => graph.Projects.FirstOrDefault(p => p.Id == d.TargetId)) + .Where(p => p != null) + .OrderBy(p => p!.Name) + .ToList(); + + foreach (var dep in dependencies) + { + var isCycle = currentPath.Contains(dep!.Id); + var typeIcon = GetProjectTypeIcon(dep.Type); + var isCyclicProject = cyclicProjectIds.Contains(dep.Id); + var projectName = Markup.Escape(dep.Name.Trim()); + + if (isCycle) + { + parent.AddNode($"{typeIcon} [red]{projectName}[/] [italic red](cycle detected)[/]"); + continue; + } + + var color = isCyclicProject ? "red" : "green"; + var label = $"{typeIcon} [{color}]{projectName}[/]"; + var node = parent.AddNode(label); + + AddChildrenRecursive(node, dep, graph, currentPath, globalVisited, cyclicProjectIds); + } + + currentPath.Remove(project.Id); + } + + private static string GetProjectMarkup(Project project, HashSet cyclicProjectIds) + { + var typeIcon = GetProjectTypeIcon(project.Type); + var color = cyclicProjectIds.Contains(project.Id) ? "red" : "green"; + var projectName = Markup.Escape(project.Name.Trim()); + return $"{typeIcon} [{color}]{projectName}[/]"; + } + /// /// Renders the header of the solution graph with proper formatting and styling. /// diff --git a/src/ProjGraph.Lib.ProjectGraph/Rendering/MermaidGraphRenderer.cs b/src/ProjGraph.Lib.ProjectGraph/Rendering/MermaidGraphRenderer.cs index 1a0c1dc..80e977a 100644 --- a/src/ProjGraph.Lib.ProjectGraph/Rendering/MermaidGraphRenderer.cs +++ b/src/ProjGraph.Lib.ProjectGraph/Rendering/MermaidGraphRenderer.cs @@ -18,9 +18,17 @@ public string Render(SolutionGraph graph) { var sb = new StringBuilder(); sb.AppendLine("```mermaid"); + + if (!string.IsNullOrEmpty(graph.Name)) + { + sb.AppendLine("---"); + sb.AppendLine($"title: {graph.Name}"); + sb.AppendLine("---"); + } + sb.AppendLine("graph TD"); - foreach (var project in graph.Projects) + foreach (var project in graph.Projects.OrderBy(p => p.Name)) { var typeLabel = project.Type switch { @@ -32,15 +40,19 @@ public string Render(SolutionGraph graph) sb.AppendLine($" {safeId}[\"{project.Name}{typeLabel}\"]"); } - foreach (var dep in graph.Dependencies) - { - var source = graph.Projects.FirstOrDefault(p => p.Id == dep.SourceId); - var target = graph.Projects.FirstOrDefault(p => p.Id == dep.TargetId); - - if (source != null && target != null) + var sortedDependencies = graph.Dependencies + .Select(d => new { - sb.AppendLine($" {SanitizeId(source.Name)} --> {SanitizeId(target.Name)}"); - } + Source = graph.Projects.FirstOrDefault(p => p.Id == d.SourceId), + Target = graph.Projects.FirstOrDefault(p => p.Id == d.TargetId) + }) + .Where(d => d.Source != null && d.Target != null) + .OrderBy(d => d.Source!.Name) + .ThenBy(d => d.Target!.Name); + + foreach (var dep in sortedDependencies) + { + sb.AppendLine($" {SanitizeId(dep.Source!.Name)} --> {SanitizeId(dep.Target!.Name)}"); } sb.AppendLine("```"); diff --git a/tests/ProjGraph.Tests.Integration.Cli/VisualizeCommandTests.cs b/tests/ProjGraph.Tests.Integration.Cli/VisualizeCommandTests.cs index 809c8b6..3845138 100644 --- a/tests/ProjGraph.Tests.Integration.Cli/VisualizeCommandTests.cs +++ b/tests/ProjGraph.Tests.Integration.Cli/VisualizeCommandTests.cs @@ -77,7 +77,7 @@ public void VisualizeCommand_ProjGraphSolution_Slnx_Mermaid_ShouldShowAllProject } [Fact] - public void VisualizeCommand_SimpleDependencies_TreeFormat_ShouldShowHierarchy() + public void VisualizeCommand_SimpleDependencies_FlatFormat_ShouldShowList() { // Arrange var app = CliTestHelpers.CreateApp(); @@ -86,7 +86,7 @@ public void VisualizeCommand_SimpleDependencies_TreeFormat_ShouldShowHierarchy() // Act var capturedOutput = CliTestHelpers.CaptureConsoleOutput(() => { - var result = app.Run(["visualize", slnxPath, "--format", "tree"]); + var result = app.Run(["visualize", slnxPath, "--format", "flat"]); result.Should().Be(0); }); @@ -95,14 +95,31 @@ public void VisualizeCommand_SimpleDependencies_TreeFormat_ShouldShowHierarchy() capturedOutput.Should().Contain("๐Ÿ“ฆ A"); capturedOutput.Should().Contain("โ†’ B"); capturedOutput.Should().Contain("๐Ÿ“ฆ B"); - capturedOutput.Should().Contain("โ†’ C"); - capturedOutput.Should().Contain("โ†’ D"); - capturedOutput.Should().Contain("๐Ÿ“ฆ C"); - capturedOutput.Should().Contain("๐Ÿ“ฆ D"); } [Fact] - public void VisualizeCommand_DefaultFormat_ShouldUseTree() + public void VisualizeCommand_SimpleDependencies_TreeFormat_ShouldShowHierarchy() + { + // Arrange + var app = CliTestHelpers.CreateApp(); + var slnxPath = CliTestHelpers.GetSamplePath(@"visualize\simple-dependencies\simple-dependencies.slnx"); + + // Act + var capturedOutput = CliTestHelpers.CaptureConsoleOutput(() => + { + var result = app.Run(["visualize", slnxPath, "--format", "tree"]); + result.Should().Be(0); + }); + + // Assert + capturedOutput.Should().Contain("A"); + capturedOutput.Should().Contain("B"); + capturedOutput.Should().Contain("C"); + capturedOutput.Should().Contain("D"); + } + + [Fact] + public void VisualizeCommand_DefaultFormat_ShouldUseMermaid() { // Arrange var app = CliTestHelpers.CreateApp(); @@ -115,9 +132,8 @@ public void VisualizeCommand_DefaultFormat_ShouldUseTree() result.Should().Be(0); }); - // Assert - tree format is default - capturedOutput.Should().Contain("Projects"); - capturedOutput.Should().Contain("๐Ÿ“ฆ"); + // Assert - mermaid format is default + capturedOutput.Should().Contain("graph TD"); } [Fact] diff --git a/tests/ProjGraph.Tests.Unit.ProjectGraph/MermaidGraphRendererTests.cs b/tests/ProjGraph.Tests.Unit.ProjectGraph/MermaidGraphRendererTests.cs index 4f8da61..77d2b24 100644 --- a/tests/ProjGraph.Tests.Unit.ProjectGraph/MermaidGraphRendererTests.cs +++ b/tests/ProjGraph.Tests.Unit.ProjectGraph/MermaidGraphRendererTests.cs @@ -98,6 +98,20 @@ public void Render_ShouldNotLabelLibraryProjects() result.Should().NotContain("(Test)"); } + [Fact] + public void Render_ShouldIncludeTitleFromSolutionName() + { + // Arrange + var graph = new SolutionGraph("MySolution", "MySolution.sln", [], []); + + // Act + var result = _renderer.Render(graph); + + // Assert + result.Should().Contain("---"); + result.Should().Contain("title: MySolution"); + } + [Fact] public void Render_ShouldHandleEmptyGraph() { @@ -259,4 +273,47 @@ public void Render_ShouldProduceValidMermaidClosingTag() // Assert result.TrimEnd().Should().EndWith("```"); } + + [Fact] + public void Render_ShouldOrderProjectsAndDependenciesByName() + { + // Arrange + var guidA = Guid.NewGuid(); + var guidB = Guid.NewGuid(); + var guidC = Guid.NewGuid(); + + var projects = new List + { + new(guidC, "ProjectC", "C.csproj", "C.csproj", "net10.0", ProjectType.Library), + new(guidA, "ProjectA", "A.csproj", "A.csproj", "net10.0", ProjectType.Library), + new(guidB, "ProjectB", "B.csproj", "B.csproj", "net10.0", ProjectType.Library) + }; + + var dependencies = new List + { + new(guidB, guidC, DependencyType.ProjectReference), // B -> C + new(guidA, guidB, DependencyType.ProjectReference), // A -> B + new(guidA, guidC, DependencyType.ProjectReference) // A -> C + }; + + var graph = new SolutionGraph("TestSolution", "TestSolution.sln", projects, dependencies); + + // Act + var result = _renderer.Render(graph); + + // Assert + var lines = result.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + // Projects should be ordered A, B, C + var projectLines = lines.Where(l => l.Contains("[")).ToList(); + projectLines[0].Should().Contain("ProjectA"); + projectLines[1].Should().Contain("ProjectB"); + projectLines[2].Should().Contain("ProjectC"); + + // Dependencies should be ordered A->B, A->C, B->C + var dependencyLines = lines.Where(l => l.Contains("-->")).ToList(); + dependencyLines[0].Should().Contain("ProjectA --> ProjectB"); + dependencyLines[1].Should().Contain("ProjectA --> ProjectC"); + dependencyLines[2].Should().Contain("ProjectB --> ProjectC"); + } } \ No newline at end of file From 2a33faf953336abcd80e9d291e7e21faa89f8b56 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 10 Feb 2026 16:19:45 +0100 Subject: [PATCH 2/6] feat: add support for multiple graph rendering formats including flat and tree --- .../Commands/VisualizeCommand.cs | 26 +- src/ProjGraph.Cli/Rendering/TreeRenderer.cs | 302 ------------------ .../DependencyInjection.cs | 11 +- .../Rendering/FlatGraphRenderer.cs | 125 ++++++++ .../Rendering/SolutionGraphRendererBase.cs | 87 +++++ .../Rendering/TreeGraphRenderer.cs | 155 +++++++++ 6 files changed, 392 insertions(+), 314 deletions(-) delete mode 100644 src/ProjGraph.Cli/Rendering/TreeRenderer.cs create mode 100644 src/ProjGraph.Lib.ProjectGraph/Rendering/FlatGraphRenderer.cs create mode 100644 src/ProjGraph.Lib.ProjectGraph/Rendering/SolutionGraphRendererBase.cs create mode 100644 src/ProjGraph.Lib.ProjectGraph/Rendering/TreeGraphRenderer.cs diff --git a/src/ProjGraph.Cli/Commands/VisualizeCommand.cs b/src/ProjGraph.Cli/Commands/VisualizeCommand.cs index c0c3541..232d115 100644 --- a/src/ProjGraph.Cli/Commands/VisualizeCommand.cs +++ b/src/ProjGraph.Cli/Commands/VisualizeCommand.cs @@ -1,7 +1,7 @@ -using ProjGraph.Cli.Rendering; using ProjGraph.Core.Models; using ProjGraph.Lib.Core.Abstractions; using ProjGraph.Lib.ProjectGraph.Application; +using ProjGraph.Lib.ProjectGraph.Rendering; using Spectre.Console; using Spectre.Console.Cli; using System.ComponentModel; @@ -20,10 +20,21 @@ namespace ProjGraph.Cli.Commands; /// public sealed class VisualizeCommand( IGraphService graphService, - IDiagramRenderer mermaidRenderer, + IEnumerable> renderers, IOutputConsole console) : AsyncCommand { + private IDiagramRenderer GetRenderer(string format) + { + return format.ToLowerInvariant() switch + { + "mermaid" => renderers.OfType().First(), + "tree" => renderers.OfType().First(), + "flat" => renderers.OfType().First(), + _ => throw new ArgumentException($"Unsupported format: {format}") + }; + } + /// /// Represents the settings for the `VisualizeCommand`. /// @@ -106,7 +117,7 @@ public override async Task ExecuteAsync( console.WriteInfo($"Analyzing {settings.Path}..."); var graph = await Task.Run(() => graphService.BuildGraph(settings.Path), cancellationToken); - console.WriteLine(mermaidRenderer.Render(graph)); + console.WriteLine(GetRenderer(settings.Format).Render(graph)); } else { @@ -125,14 +136,7 @@ await AnsiConsole.Status() return 0; } - if (settings.Format.Equals("flat", StringComparison.OrdinalIgnoreCase)) - { - TreeRenderer.RenderFlat(graph); - } - else - { - TreeRenderer.RenderTree(graph); - } + console.WriteLine(GetRenderer(settings.Format).Render(graph)); } return 0; diff --git a/src/ProjGraph.Cli/Rendering/TreeRenderer.cs b/src/ProjGraph.Cli/Rendering/TreeRenderer.cs deleted file mode 100644 index 2598ccb..0000000 --- a/src/ProjGraph.Cli/Rendering/TreeRenderer.cs +++ /dev/null @@ -1,302 +0,0 @@ -using ProjGraph.Core.Models; -using ProjGraph.Lib.Core.Domain.Algorithms; -using Spectre.Console; - -namespace ProjGraph.Cli.Rendering; - -/// -/// Provides methods for rendering a solution graph as a tree structure in the console. -/// -/// -/// The class includes methods to render the solution graph's header, -/// projects, dependencies, and any detected cyclic dependencies with proper formatting and color coding. -/// -public static class TreeRenderer -{ - /// - /// Renders the solution graph as a flat list of projects and their direct dependencies. - /// - /// - /// The solution graph containing the projects and dependencies to be rendered. - /// - public static void RenderFlat(SolutionGraph graph) - { - RenderHeader(graph); - - var cycles = TarjanSccAlgorithm.FindStronglyConnectedComponents(graph); - var cyclicProjectIds = cycles - .Where(c => c.Count > 1) - .SelectMany(c => c) - .ToHashSet(); - - var sortedProjects = graph.Projects - .OrderBy(p => p.Type) - .ThenBy(p => p.Name) - .ToList(); - - for (var i = 0; i < sortedProjects.Count; i++) - { - var project = sortedProjects[i]; - var isLastProject = i == sortedProjects.Count - 1; - - RenderProject(project, isLastProject, cyclicProjectIds); - RenderDependencies(graph, project, isLastProject, cyclicProjectIds); - } - - RenderCycleWarning(cyclicProjectIds); - } - - /// - /// Renders the solution graph as a real tree structure, starting from root projects. - /// - /// - /// The solution graph containing the projects and dependencies to be rendered. - /// - public static void RenderTree(SolutionGraph graph) - { - RenderHeader(graph); - - // Identify incoming dependency counts to find roots - var incomingCounts = graph.Projects.ToDictionary(p => p.Id, _ => 0); - foreach (var dep in graph.Dependencies) - { - if (incomingCounts.TryGetValue(dep.TargetId, out var value)) - { - incomingCounts[dep.TargetId] = ++value; - } - } - - var cycles = TarjanSccAlgorithm.FindStronglyConnectedComponents(graph); - var cyclicProjectIds = cycles - .Where(c => c.Count > 1) - .SelectMany(c => c) - .ToHashSet(); - - var globalVisited = new HashSet(); - - // 1. Print the Solution Name as the main header - AnsiConsole.MarkupLine($"[bold blue]{Markup.Escape(graph.Name.Trim())}[/]"); - - // 2. Identify "Root" projects: projects with 0 incoming dependencies - var rootProjects = graph.Projects - .Where(p => incomingCounts[p.Id] == 0) - .OrderBy(p => p.Type) - .ThenBy(p => p.Name) - .ToList(); - - // 3. Render each root branch as a separate tree to allow for true blank lines between them - foreach (var project in rootProjects) - { - AnsiConsole.WriteLine(); // Spacing line between branches - var rootLabel = GetProjectMarkup(project, cyclicProjectIds); - var tree = new Tree(rootLabel); - - AddChildrenRecursive(tree, project, graph, [], globalVisited, cyclicProjectIds); - AnsiConsole.Write(tree); - } - - // 4. Add remaining projects (those not reachable from roots, e.g. purely cyclic clusters) - var remainingProjects = graph.Projects - .Where(p => !globalVisited.Contains(p.Id)) - .OrderBy(p => p.Name) - .ToList(); - - foreach (var project in remainingProjects.Where(project => !globalVisited.Contains(project.Id))) - { - AnsiConsole.WriteLine(); - var rootLabel = GetProjectMarkup(project, cyclicProjectIds); - var tree = new Tree(rootLabel); - - AddChildrenRecursive(tree, project, graph, [], globalVisited, cyclicProjectIds); - AnsiConsole.Write(tree); - } - - RenderCycleWarning(cyclicProjectIds); - } - - private static void AddChildrenRecursive( - IHasTreeNodes parent, - Project project, - SolutionGraph graph, - HashSet currentPath, - HashSet globalVisited, - HashSet cyclicProjectIds) - { - globalVisited.Add(project.Id); - currentPath.Add(project.Id); - - var dependencies = graph.Dependencies - .Where(d => d.SourceId == project.Id) - .Select(d => graph.Projects.FirstOrDefault(p => p.Id == d.TargetId)) - .Where(p => p != null) - .OrderBy(p => p!.Name) - .ToList(); - - foreach (var dep in dependencies) - { - var isCycle = currentPath.Contains(dep!.Id); - var typeIcon = GetProjectTypeIcon(dep.Type); - var isCyclicProject = cyclicProjectIds.Contains(dep.Id); - var projectName = Markup.Escape(dep.Name.Trim()); - - if (isCycle) - { - parent.AddNode($"{typeIcon} [red]{projectName}[/] [italic red](cycle detected)[/]"); - continue; - } - - var color = isCyclicProject ? "red" : "green"; - var label = $"{typeIcon} [{color}]{projectName}[/]"; - var node = parent.AddNode(label); - - AddChildrenRecursive(node, dep, graph, currentPath, globalVisited, cyclicProjectIds); - } - - currentPath.Remove(project.Id); - } - - private static string GetProjectMarkup(Project project, HashSet cyclicProjectIds) - { - var typeIcon = GetProjectTypeIcon(project.Type); - var color = cyclicProjectIds.Contains(project.Id) ? "red" : "green"; - var projectName = Markup.Escape(project.Name.Trim()); - return $"{typeIcon} [{color}]{projectName}[/]"; - } - - /// - /// Renders the header of the solution graph with proper formatting and styling. - /// - /// - /// The solution graph containing the projects and dependencies to be displayed. - /// The graph's name will be used as the title of the header. - /// - private static void RenderHeader(SolutionGraph graph) - { - var graphName = Markup.Escape(graph.Name.Trim()); - AnsiConsole.Write(new Rule($"[yellow]Dependency Graph: {graphName}[/]") { Justification = Justify.Left }); - AnsiConsole.MarkupLine("[bold blue]Projects[/]"); - } - - /// - /// Renders a project in the solution graph with appropriate formatting and color coding. - /// - /// The project to be rendered, represented as a object. - /// - /// A boolean indicating whether the current project is the last in the list of projects. - /// Used to determine the connector style. - /// - /// - /// A set of project IDs that are part of a circular dependency. - /// If the project is part of this set, it will be highlighted in red. - /// - private static void RenderProject(Project project, bool isLastProject, HashSet cyclicProjectIds) - { - var pPrefix = isLastProject ? "โ””โ”€โ”€ " : "โ”œโ”€โ”€ "; - var color = cyclicProjectIds.Contains(project.Id) ? "red" : "green"; - var typeIcon = GetProjectTypeIcon(project.Type); - var projectName = Markup.Escape(project.Name.Trim()); - - AnsiConsole.MarkupLine($"{pPrefix}{typeIcon} [{color}]{projectName}[/]"); - } - - /// - /// Renders the dependencies of a given project in the solution graph with proper formatting and color coding. - /// - /// The solution graph containing all projects and their dependencies. - /// The project whose dependencies are to be rendered. - /// - /// A boolean indicating whether the current project is the last in the list of projects. - /// Used to determine the indentation style. - /// - /// - /// A set of project IDs that are part of a circular dependency. - /// Dependencies in this set will be highlighted in red. - /// - private static void RenderDependencies( - SolutionGraph graph, - Project project, - bool isLastProject, - HashSet cyclicProjectIds) - { - var dependencies = graph.Dependencies - .Where(d => d.SourceId == project.Id) - .Select(d => graph.Projects.FirstOrDefault(p => p.Id == d.TargetId)) - .Where(p => p != null) - .OrderBy(p => p!.Name) - .ToList(); - - for (var j = 0; j < dependencies.Count; j++) - { - var dep = dependencies[j]!; - var isLastDep = j == dependencies.Count - 1; - - RenderDependency(dep, isLastProject, isLastDep, cyclicProjectIds); - } - } - - /// - /// Renders a dependency in the project graph with appropriate formatting and color coding. - /// - /// The project that represents the dependency to be rendered. - /// - /// A boolean indicating whether the current project is the last in the list of projects. - /// Used to determine the indentation style. - /// - /// - /// A boolean indicating whether the current dependency is the last in the list of dependencies. - /// Used to determine the connector style. - /// - /// - /// A set of project IDs that are part of a circular dependency. - /// If the dependency is part of this set, it will be highlighted in red. - /// - private static void RenderDependency( - Project dependency, - bool isLastProject, - bool isLastDep, - HashSet cyclicProjectIds) - { - var dPrefix = isLastProject ? " " : "โ”‚ "; - var dConnector = isLastDep ? "โ””โ”€โ”€ " : "โ”œโ”€โ”€ "; - var depColor = cyclicProjectIds.Contains(dependency.Id) ? "red" : "grey"; - var depName = Markup.Escape(dependency.Name.Trim()); - - AnsiConsole.MarkupLine($"{dPrefix}{dConnector}[italic {depColor}]โ†’ {depName}[/]"); - } - - /// - /// Retrieves the appropriate icon representation for a given project type. - /// - /// The type of the project, represented as a enum. - /// - /// A string containing an emoji that represents the project type: - /// - "๐Ÿš€" for executable projects. - /// - "๐Ÿงช" for test projects. - /// - "๐Ÿ“ฆ" for other types of projects. - /// - private static string GetProjectTypeIcon(ProjectType type) - { - return type switch - { - ProjectType.Executable => "๐Ÿš€", - ProjectType.Test => "๐Ÿงช", - _ => "๐Ÿ“ฆ" - }; - } - - /// - /// Renders a warning message if there are any cyclic dependencies detected in the project graph. - /// - /// - /// A set of project IDs that are part of a circular dependency. - /// If the set is not empty, a warning message will be displayed. - /// - private static void RenderCycleWarning(HashSet cyclicProjectIds) - { - if (cyclicProjectIds.Count is not 0) - { - AnsiConsole.MarkupLine( - "\n[red]โš  Cycles detected![/] The projects in [red]red[/] are part of a circular dependency."); - } - } -} \ No newline at end of file diff --git a/src/ProjGraph.Lib.ProjectGraph/DependencyInjection.cs b/src/ProjGraph.Lib.ProjectGraph/DependencyInjection.cs index 53403a6..5368db9 100644 --- a/src/ProjGraph.Lib.ProjectGraph/DependencyInjection.cs +++ b/src/ProjGraph.Lib.ProjectGraph/DependencyInjection.cs @@ -19,7 +19,16 @@ public static IServiceCollection AddProjGraphProjectGraph(this IServiceCollectio { services.AddSingleton(); services.AddSingleton(); - services.AddSingleton, MermaidGraphRenderer>(); + + // Renderers + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Register as interface for collection injection + services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); return services; } diff --git a/src/ProjGraph.Lib.ProjectGraph/Rendering/FlatGraphRenderer.cs b/src/ProjGraph.Lib.ProjectGraph/Rendering/FlatGraphRenderer.cs new file mode 100644 index 0000000..08b6bcb --- /dev/null +++ b/src/ProjGraph.Lib.ProjectGraph/Rendering/FlatGraphRenderer.cs @@ -0,0 +1,125 @@ +using ProjGraph.Core.Models; +using Spectre.Console; + +namespace ProjGraph.Lib.ProjectGraph.Rendering; + +/// +/// Renders a solution graph as a flat list of projects and their direct dependencies using Spectre.Console. +/// +public sealed class FlatGraphRenderer : SolutionGraphRendererBase +{ + /// + /// Renders a as a flat list of projects and their direct dependencies. + /// + /// The to render. + /// A string representation of the solution graph rendered as a flat list. + /// + /// This method renders each project in sorted order (by type and name) followed by its direct dependencies. + /// Projects involved in cyclic dependencies are highlighted in red. Cycle detection is performed using the + /// method. + /// + public override string Render(SolutionGraph graph) + { + _writer.GetStringBuilder().Clear(); + + RenderHeader(graph); + + var cyclicProjectIds = GetCyclicProjectIds(graph); + + var sortedProjects = graph.Projects + .OrderBy(p => p.Type) + .ThenBy(p => p.Name) + .ToList(); + + for (var i = 0; i < sortedProjects.Count; i++) + { + var project = sortedProjects[i]; + var isLastProject = i == sortedProjects.Count - 1; + + RenderProject(project, isLastProject, cyclicProjectIds); + RenderDependencies(graph, project, isLastProject, cyclicProjectIds); + } + + RenderCycleWarning(cyclicProjectIds); + + return _writer.ToString(); + } + + /// + /// Renders a single with appropriate visual formatting and color coding. + /// + /// The to render. + /// A value indicating whether this is the last project in the list. + /// A of s representing projects involved in cycles. + /// + /// The project is displayed with a tree connector prefix (โ””โ”€โ”€ for last, โ”œโ”€โ”€ for others), a type icon obtained from + /// , and color-coded based on whether it's part of a cycle. + /// + private void RenderProject(Project project, bool isLastProject, HashSet cyclicProjectIds) + { + var pPrefix = isLastProject ? "โ””โ”€โ”€ " : "โ”œโ”€โ”€ "; + var color = cyclicProjectIds.Contains(project.Id) ? "red" : "green"; + var typeIcon = GetProjectTypeIcon(project.Type); + var projectName = Markup.Escape(project.Name.Trim()); + + _console.MarkupLine($"{pPrefix}{typeIcon} [{color}]{projectName}[/]"); + } + + /// + /// Renders all direct dependencies of the specified . + /// + /// The containing project relationships. + /// The whose dependencies should be rendered. + /// A value indicating whether the parent project is the last in the list. + /// A of s representing projects involved in cycles. + /// + /// Dependencies are rendered in sorted order by project name. The visual formatting is adjusted based on whether + /// the parent project is the last in its list using the method. + /// + private void RenderDependencies( + SolutionGraph graph, + Project project, + bool isLastProject, + HashSet cyclicProjectIds) + { + var dependencies = graph.Dependencies + .Where(d => d.SourceId == project.Id) + .Select(d => graph.Projects.FirstOrDefault(p => p.Id == d.TargetId)) + .Where(p => p != null) + .OrderBy(p => p!.Name) + .ToList(); + + for (var j = 0; j < dependencies.Count; j++) + { + var dep = dependencies[j]!; + var isLastDep = j == dependencies.Count - 1; + + RenderDependency(dep, isLastProject, isLastDep, cyclicProjectIds); + } + } + + /// + /// Renders a single dependency with appropriate tree formatting and color coding. + /// + /// The representing the dependency to render. + /// A value indicating whether the parent project is the last in the list. + /// A value indicating whether this is the last dependency in the parent's dependency list. + /// A of s representing projects involved in cycles. + /// + /// Dependencies are rendered with appropriate indentation and tree connectors (โ””โ”€โ”€ for last, โ”œโ”€โ”€ for others). + /// Colors are determined by whether the dependency is involved in a cycle (red) or not (grey). + /// + private void RenderDependency( + Project dependency, + bool isLastProject, + bool isLastDep, + HashSet cyclicProjectIds) + { + var dPrefix = isLastProject ? " " : "โ”‚ "; + var dConnector = isLastDep ? "โ””โ”€โ”€ " : "โ”œโ”€โ”€ "; + var depColor = cyclicProjectIds.Contains(dependency.Id) ? "red" : "grey"; + var depName = Markup.Escape(dependency.Name.Trim()); + + _console.MarkupLine($"{dPrefix}{dConnector}[italic {depColor}]โ†’ {depName}[/]"); + } +} \ No newline at end of file diff --git a/src/ProjGraph.Lib.ProjectGraph/Rendering/SolutionGraphRendererBase.cs b/src/ProjGraph.Lib.ProjectGraph/Rendering/SolutionGraphRendererBase.cs new file mode 100644 index 0000000..2679edf --- /dev/null +++ b/src/ProjGraph.Lib.ProjectGraph/Rendering/SolutionGraphRendererBase.cs @@ -0,0 +1,87 @@ +using ProjGraph.Core.Models; +using ProjGraph.Lib.Core.Abstractions; +using ProjGraph.Lib.Core.Domain.Algorithms; +using Spectre.Console; + +namespace ProjGraph.Lib.ProjectGraph.Rendering; + +/// +/// Base class for rendering solution graphs with ANSI console support. +/// +public abstract class SolutionGraphRendererBase : IDiagramRenderer +{ + protected readonly IAnsiConsole _console; + protected readonly StringWriter _writer; + + /// + /// Initializes a new instance of the class. + /// + protected SolutionGraphRendererBase() + { + _writer = new StringWriter(); + _console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.Detect, ColorSystem = ColorSystemSupport.Detect, Out = new AnsiConsoleOutput(_writer) + }); + } + + /// + /// Renders the specified to a string representation. + /// + /// The solution graph to render. + /// A string representation of the rendered graph. + public abstract string Render(SolutionGraph graph); + + /// + /// Renders the header section of the solution graph visualization. + /// + /// The solution graph to render the header for. + protected void RenderHeader(SolutionGraph graph) + { + var graphName = Markup.Escape(graph.Name.Trim()); + _console.Write(new Rule($"[yellow]Dependency Graph: {graphName}[/]") { Justification = Justify.Left }); + _console.MarkupLine("[bold blue]Projects[/]"); + } + + /// + /// Gets the icon representation for the specified project type. + /// + /// The to get the icon for. + /// A string containing the icon emoji for the project type. + protected static string GetProjectTypeIcon(ProjectType type) + { + return type switch + { + ProjectType.Executable => "๐Ÿš€", + ProjectType.Test => "๐Ÿงช", + _ => "๐Ÿ“ฆ" + }; + } + + /// + /// Renders a warning message if cyclic dependencies are detected in the solution graph. + /// + /// A set of project IDs that are part of cyclic dependencies. + protected void RenderCycleWarning(HashSet cyclicProjectIds) + { + if (cyclicProjectIds.Count is not 0) + { + _console.MarkupLine( + "\n[red]โš  Cycles detected![/] The projects in [red]red[/] are part of a circular dependency."); + } + } + + /// + /// Identifies all projects that are part of cyclic dependencies in the solution graph. + /// + /// The to analyze for cycles. + /// A of project s that are part of cycles. + protected static HashSet GetCyclicProjectIds(SolutionGraph graph) + { + var cycles = TarjanSccAlgorithm.FindStronglyConnectedComponents(graph); + return cycles + .Where(c => c.Count > 1) + .SelectMany(c => c) + .ToHashSet(); + } +} \ No newline at end of file diff --git a/src/ProjGraph.Lib.ProjectGraph/Rendering/TreeGraphRenderer.cs b/src/ProjGraph.Lib.ProjectGraph/Rendering/TreeGraphRenderer.cs new file mode 100644 index 0000000..b788589 --- /dev/null +++ b/src/ProjGraph.Lib.ProjectGraph/Rendering/TreeGraphRenderer.cs @@ -0,0 +1,155 @@ +using ProjGraph.Core.Models; +using Spectre.Console; + +namespace ProjGraph.Lib.ProjectGraph.Rendering; + +/// +/// Renders a solution graph as a tree structure using Spectre.Console. +/// +public sealed class TreeGraphRenderer : SolutionGraphRendererBase +{ + /// + /// Renders a as a tree structure, displaying project dependencies hierarchically. + /// + /// The to render. + /// A string representation of the solution graph rendered as a tree. + /// + /// This method identifies root projects (those with no incoming dependencies) and renders each as a separate tree branch. + /// Projects involved in cyclic dependencies are highlighted in red, and cycle detection is performed using the + /// method. + /// + public override string Render(SolutionGraph graph) + { + _writer.GetStringBuilder().Clear(); + + RenderHeader(graph); + + // Identify incoming dependency counts to find roots + var incomingCounts = graph.Projects.ToDictionary(p => p.Id, _ => 0); + foreach (var dep in graph.Dependencies) + { + if (incomingCounts.TryGetValue(dep.TargetId, out var value)) + { + incomingCounts[dep.TargetId] = ++value; + } + } + + var cyclicProjectIds = GetCyclicProjectIds(graph); + + var globalVisited = new HashSet(); + + // 1. Print the Solution Name as the main header + _console.MarkupLine($"[bold blue]{Markup.Escape(graph.Name.Trim())}[/]"); + + // 2. Identify "Root" projects: projects with 0 incoming dependencies + var rootProjects = graph.Projects + .Where(p => incomingCounts[p.Id] == 0) + .OrderBy(p => p.Type) + .ThenBy(p => p.Name) + .ToList(); + + // 3. Render each root branch as a separate tree + foreach (var project in rootProjects) + { + _console.WriteLine(); // Spacing line between branches + var rootLabel = GetProjectMarkup(project, cyclicProjectIds); + var tree = new Tree(rootLabel); + + AddChildrenRecursive(tree, project, graph, [], globalVisited, cyclicProjectIds); + _console.Write(tree); + } + + // 4. Add remaining projects (those not reachable from roots) + var remainingProjects = graph.Projects + .Where(p => !globalVisited.Contains(p.Id)) + .OrderBy(p => p.Name) + .ToList(); + + foreach (var project in remainingProjects.Where(project => !globalVisited.Contains(project.Id))) + { + _console.WriteLine(); + var rootLabel = GetProjectMarkup(project, cyclicProjectIds); + var tree = new Tree(rootLabel); + + AddChildrenRecursive(tree, project, graph, [], globalVisited, cyclicProjectIds); + _console.Write(tree); + } + + RenderCycleWarning(cyclicProjectIds); + + return _writer.ToString(); + } + + /// + /// Recursively adds child nodes to the tree for all dependencies of the specified project. + /// + /// The parent node to add children to. + /// The whose dependencies should be rendered. + /// The containing the project relationships. + /// A tracking the current path to detect cycles. + /// A tracking all visited IDs to avoid duplicate rendering. + /// A of s representing projects involved in cycles. + /// + /// This method uses depth-first traversal to build the tree structure. It detects cycles by checking if a dependency + /// is already in the and marks cycle edges with red color and "(cycle detected)" label. + /// Cyclic projects are colored differently from normal dependencies using the method. + /// + private static void AddChildrenRecursive( + IHasTreeNodes parent, + Project project, + SolutionGraph graph, + HashSet currentPath, + HashSet globalVisited, + HashSet cyclicProjectIds) + { + globalVisited.Add(project.Id); + currentPath.Add(project.Id); + + var dependencies = graph.Dependencies + .Where(d => d.SourceId == project.Id) + .Select(d => graph.Projects.FirstOrDefault(p => p.Id == d.TargetId)) + .Where(p => p != null) + .OrderBy(p => p!.Name) + .ToList(); + + foreach (var dep in dependencies) + { + var isCycle = currentPath.Contains(dep!.Id); + var typeIcon = GetProjectTypeIcon(dep.Type); + var isCyclicProject = cyclicProjectIds.Contains(dep.Id); + var projectName = Markup.Escape(dep.Name.Trim()); + + if (isCycle) + { + parent.AddNode($"{typeIcon} [red]{projectName}[/] [italic red](cycle detected)[/]"); + continue; + } + + var color = isCyclicProject ? "red" : "green"; + var label = $"{typeIcon} [{color}]{projectName}[/]"; + var node = parent.AddNode(label); + + AddChildrenRecursive(node, dep, graph, currentPath, globalVisited, cyclicProjectIds); + } + + currentPath.Remove(project.Id); + } + + /// + /// Generates markup text for a with appropriate color and icon based on its cyclic status. + /// + /// The to generate markup for. + /// A of s representing projects involved in cycles. + /// A markup string containing the project type icon, name, and appropriate color coding. + /// + /// Projects that are part of cyclic dependencies are colored red, while normal projects are colored green. + /// The project type icon is obtained using . + /// + private static string GetProjectMarkup(Project project, HashSet cyclicProjectIds) + { + var typeIcon = GetProjectTypeIcon(project.Type); + var color = cyclicProjectIds.Contains(project.Id) ? "red" : "green"; + var projectName = Markup.Escape(project.Name.Trim()); + return $"{typeIcon} [{color}]{projectName}[/]"; + } +} \ No newline at end of file From 148ffe43217583cc792e081a9d27bf4f1aba6724 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 10 Feb 2026 16:39:32 +0100 Subject: [PATCH 3/6] refactor: reorganize GetRenderer method for improved clarity and documentation --- .../Commands/VisualizeCommand.cs | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/src/ProjGraph.Cli/Commands/VisualizeCommand.cs b/src/ProjGraph.Cli/Commands/VisualizeCommand.cs index 232d115..3b746bb 100644 --- a/src/ProjGraph.Cli/Commands/VisualizeCommand.cs +++ b/src/ProjGraph.Cli/Commands/VisualizeCommand.cs @@ -24,17 +24,6 @@ public sealed class VisualizeCommand( IOutputConsole console) : AsyncCommand { - private IDiagramRenderer GetRenderer(string format) - { - return format.ToLowerInvariant() switch - { - "mermaid" => renderers.OfType().First(), - "tree" => renderers.OfType().First(), - "flat" => renderers.OfType().First(), - _ => throw new ArgumentException($"Unsupported format: {format}") - }; - } - /// /// Represents the settings for the `VisualizeCommand`. /// @@ -147,4 +136,25 @@ await AnsiConsole.Status() return 1; } } + + /// + /// Retrieves the appropriate diagram renderer based on the specified format. + /// + /// The desired output format (e.g., "flat", "tree", "mermaid"). + /// + /// An instance of that matches the specified format. + /// + /// + /// Thrown when an unsupported format is specified. + /// + private IDiagramRenderer GetRenderer(string format) + { + return format.ToLowerInvariant() switch + { + "mermaid" => renderers.OfType().First(), + "tree" => renderers.OfType().First(), + "flat" => renderers.OfType().First(), + _ => throw new ArgumentException($"Unsupported format: {format}") + }; + } } \ No newline at end of file From 8de6f876af74ff5cd82dde871012eb1ab5fef421 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 10 Feb 2026 16:47:17 +0100 Subject: [PATCH 4/6] refactor: update AnsiConsole settings for consistent ANSI support --- src/ProjGraph.Cli/Commands/VisualizeCommand.cs | 5 +++-- .../Infrastructure/SpectreOutputConsole.cs | 1 + .../DependencyInjection.cs | 12 ++++++------ .../Rendering/SolutionGraphRendererBase.cs | 4 +++- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/ProjGraph.Cli/Commands/VisualizeCommand.cs b/src/ProjGraph.Cli/Commands/VisualizeCommand.cs index 3b746bb..1e33342 100644 --- a/src/ProjGraph.Cli/Commands/VisualizeCommand.cs +++ b/src/ProjGraph.Cli/Commands/VisualizeCommand.cs @@ -16,7 +16,7 @@ namespace ProjGraph.Cli.Commands; /// /// The class is an asynchronous command that uses the class /// to configure the path to the solution or project file and the desired output format. It processes the input -/// and renders the structure in the specified format (tree or mermaid). +/// and renders the structure in the specified format (flat, tree or mermaid). /// public sealed class VisualizeCommand( IGraphService graphService, @@ -47,7 +47,7 @@ public sealed class Settings : CommandSettings [CommandOption("-f|--format")] [Description("The output format (flat, tree, mermaid)")] [DefaultValue("mermaid")] - public string Format { get; init; } = "mermaid"; + public string Format { get; private set; } = "mermaid"; /// /// Validates the settings provided for the command. @@ -68,6 +68,7 @@ public override ValidationResult Validate() return ValidationResult.Error($"File not found: {Path}"); } + Format = Format.ToLowerInvariant(); if (Format != "flat" && Format != "tree" && Format != "mermaid") { return ValidationResult.Error("Format must be 'flat', 'tree' or 'mermaid'"); diff --git a/src/ProjGraph.Lib.Core/Infrastructure/SpectreOutputConsole.cs b/src/ProjGraph.Lib.Core/Infrastructure/SpectreOutputConsole.cs index 6315ca6..ee8bb91 100644 --- a/src/ProjGraph.Lib.Core/Infrastructure/SpectreOutputConsole.cs +++ b/src/ProjGraph.Lib.Core/Infrastructure/SpectreOutputConsole.cs @@ -13,6 +13,7 @@ public class SpectreOutputConsole : IOutputConsole /// private static IAnsiConsole Stderr => AnsiConsole.Create(new AnsiConsoleSettings { + Ansi = AnsiConsole.Console.Profile.Capabilities.Ansi ? AnsiSupport.Yes : AnsiSupport.No, Out = new AnsiConsoleOutput(Console.Error) }); diff --git a/src/ProjGraph.Lib.ProjectGraph/DependencyInjection.cs b/src/ProjGraph.Lib.ProjectGraph/DependencyInjection.cs index 5368db9..efeea96 100644 --- a/src/ProjGraph.Lib.ProjectGraph/DependencyInjection.cs +++ b/src/ProjGraph.Lib.ProjectGraph/DependencyInjection.cs @@ -21,14 +21,14 @@ public static IServiceCollection AddProjGraphProjectGraph(this IServiceCollectio services.AddSingleton(); // Renderers - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); // Register as interface for collection injection - services.AddSingleton>(sp => sp.GetRequiredService()); - services.AddSingleton>(sp => sp.GetRequiredService()); - services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddTransient>(sp => sp.GetRequiredService()); + services.AddTransient>(sp => sp.GetRequiredService()); + services.AddTransient>(sp => sp.GetRequiredService()); return services; } diff --git a/src/ProjGraph.Lib.ProjectGraph/Rendering/SolutionGraphRendererBase.cs b/src/ProjGraph.Lib.ProjectGraph/Rendering/SolutionGraphRendererBase.cs index 2679edf..4823211 100644 --- a/src/ProjGraph.Lib.ProjectGraph/Rendering/SolutionGraphRendererBase.cs +++ b/src/ProjGraph.Lib.ProjectGraph/Rendering/SolutionGraphRendererBase.cs @@ -21,7 +21,9 @@ protected SolutionGraphRendererBase() _writer = new StringWriter(); _console = AnsiConsole.Create(new AnsiConsoleSettings { - Ansi = AnsiSupport.Detect, ColorSystem = ColorSystemSupport.Detect, Out = new AnsiConsoleOutput(_writer) + Ansi = AnsiConsole.Console.Profile.Capabilities.Ansi ? AnsiSupport.Yes : AnsiSupport.No, + ColorSystem = ColorSystemSupport.Detect, + Out = new AnsiConsoleOutput(_writer) }); } From e7413edfd3692714557f59addd05ab9b5f759314 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 10 Feb 2026 16:53:36 +0100 Subject: [PATCH 5/6] refactor: improve title validation by using string.IsNullOrWhiteSpace in renderers --- .../Rendering/MermaidClassDiagramRenderer.cs | 2 +- .../Rendering/MermaidErdRenderer.cs | 2 +- .../Rendering/MermaidGraphRenderer.cs | 2 +- .../Rendering/TreeGraphRenderer.cs | 7 ++----- 4 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/ProjGraph.Lib.ClassDiagram/Rendering/MermaidClassDiagramRenderer.cs b/src/ProjGraph.Lib.ClassDiagram/Rendering/MermaidClassDiagramRenderer.cs index ba42549..7dbbed4 100644 --- a/src/ProjGraph.Lib.ClassDiagram/Rendering/MermaidClassDiagramRenderer.cs +++ b/src/ProjGraph.Lib.ClassDiagram/Rendering/MermaidClassDiagramRenderer.cs @@ -19,7 +19,7 @@ public string Render(ClassModel model) var sb = new StringBuilder(); sb.AppendLine("```mermaid"); - if (!string.IsNullOrEmpty(model.Title)) + if (!string.IsNullOrWhiteSpace(model.Title)) { sb.AppendLine("---"); sb.AppendLine($"title: {model.Title}"); diff --git a/src/ProjGraph.Lib.EntityFramework/Rendering/MermaidErdRenderer.cs b/src/ProjGraph.Lib.EntityFramework/Rendering/MermaidErdRenderer.cs index ea0016b..b4de1f8 100644 --- a/src/ProjGraph.Lib.EntityFramework/Rendering/MermaidErdRenderer.cs +++ b/src/ProjGraph.Lib.EntityFramework/Rendering/MermaidErdRenderer.cs @@ -22,7 +22,7 @@ public string Render(EfModel model) var sb = new StringBuilder(); sb.AppendLine("```mermaid"); - if (!string.IsNullOrEmpty(model.ContextName)) + if (!string.IsNullOrWhiteSpace(model.ContextName)) { sb.AppendLine("---"); sb.AppendLine($"title: {model.ContextName}"); diff --git a/src/ProjGraph.Lib.ProjectGraph/Rendering/MermaidGraphRenderer.cs b/src/ProjGraph.Lib.ProjectGraph/Rendering/MermaidGraphRenderer.cs index 80e977a..e7f449e 100644 --- a/src/ProjGraph.Lib.ProjectGraph/Rendering/MermaidGraphRenderer.cs +++ b/src/ProjGraph.Lib.ProjectGraph/Rendering/MermaidGraphRenderer.cs @@ -19,7 +19,7 @@ public string Render(SolutionGraph graph) var sb = new StringBuilder(); sb.AppendLine("```mermaid"); - if (!string.IsNullOrEmpty(graph.Name)) + if (!string.IsNullOrWhiteSpace(graph.Name)) { sb.AppendLine("---"); sb.AppendLine($"title: {graph.Name}"); diff --git a/src/ProjGraph.Lib.ProjectGraph/Rendering/TreeGraphRenderer.cs b/src/ProjGraph.Lib.ProjectGraph/Rendering/TreeGraphRenderer.cs index b788589..666b9f8 100644 --- a/src/ProjGraph.Lib.ProjectGraph/Rendering/TreeGraphRenderer.cs +++ b/src/ProjGraph.Lib.ProjectGraph/Rendering/TreeGraphRenderer.cs @@ -26,12 +26,9 @@ public override string Render(SolutionGraph graph) // Identify incoming dependency counts to find roots var incomingCounts = graph.Projects.ToDictionary(p => p.Id, _ => 0); - foreach (var dep in graph.Dependencies) + foreach (var dep in graph.Dependencies.Where(d => incomingCounts.ContainsKey(d.TargetId))) { - if (incomingCounts.TryGetValue(dep.TargetId, out var value)) - { - incomingCounts[dep.TargetId] = ++value; - } + incomingCounts[dep.TargetId]++; } var cyclicProjectIds = GetCyclicProjectIds(graph); From 85a63297a30c1e9702342d364cf3bbcff6bd7123 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 10 Feb 2026 16:58:30 +0100 Subject: [PATCH 6/6] refactor: enhance console settings to inherit capabilities from global console --- .../Infrastructure/SpectreOutputConsole.cs | 19 +++++++++++++++---- .../Rendering/SolutionGraphRendererBase.cs | 7 ++++++- .../Helpers/CliTestHelpers.cs | 7 +++++-- .../VisualizeCommandTests.cs | 4 ++-- 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/ProjGraph.Lib.Core/Infrastructure/SpectreOutputConsole.cs b/src/ProjGraph.Lib.Core/Infrastructure/SpectreOutputConsole.cs index ee8bb91..458b932 100644 --- a/src/ProjGraph.Lib.Core/Infrastructure/SpectreOutputConsole.cs +++ b/src/ProjGraph.Lib.Core/Infrastructure/SpectreOutputConsole.cs @@ -11,11 +11,22 @@ public class SpectreOutputConsole : IOutputConsole /// /// Gets an that writes to the current standard error stream. /// - private static IAnsiConsole Stderr => AnsiConsole.Create(new AnsiConsoleSettings + private static IAnsiConsole Stderr { - Ansi = AnsiConsole.Console.Profile.Capabilities.Ansi ? AnsiSupport.Yes : AnsiSupport.No, - Out = new AnsiConsoleOutput(Console.Error) - }); + get + { + var globalConsole = AnsiConsole.Console; + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = globalConsole.Profile.Capabilities.Ansi ? AnsiSupport.Yes : AnsiSupport.No, + ColorSystem = ColorSystemSupport.Detect, + Out = new AnsiConsoleOutput(Console.Error) + }); + console.Profile.Capabilities.Unicode = globalConsole.Profile.Capabilities.Unicode; + console.Profile.Width = globalConsole.Profile.Width; + return console; + } + } /// /// Writes a message to the console without a newline. diff --git a/src/ProjGraph.Lib.ProjectGraph/Rendering/SolutionGraphRendererBase.cs b/src/ProjGraph.Lib.ProjectGraph/Rendering/SolutionGraphRendererBase.cs index 4823211..179c687 100644 --- a/src/ProjGraph.Lib.ProjectGraph/Rendering/SolutionGraphRendererBase.cs +++ b/src/ProjGraph.Lib.ProjectGraph/Rendering/SolutionGraphRendererBase.cs @@ -19,12 +19,17 @@ public abstract class SolutionGraphRendererBase : IDiagramRenderer diff --git a/tests/ProjGraph.Tests.Integration.Cli/Helpers/CliTestHelpers.cs b/tests/ProjGraph.Tests.Integration.Cli/Helpers/CliTestHelpers.cs index 6dc63e4..b64caa0 100644 --- a/tests/ProjGraph.Tests.Integration.Cli/Helpers/CliTestHelpers.cs +++ b/tests/ProjGraph.Tests.Integration.Cli/Helpers/CliTestHelpers.cs @@ -61,10 +61,13 @@ public static string CaptureConsoleOutput(Action action) { Ansi = AnsiSupport.No, // Disable ANSI codes for cleaner test output ColorSystem = ColorSystemSupport.NoColors, - Out = new AnsiConsoleOutput(writer) + Out = new AnsiConsoleOutput(writer), + Interactive = InteractionSupport.No }; var console = AnsiConsole.Create(settings); + console.Profile.Capabilities.Unicode = true; + console.Profile.Width = 200; AnsiConsole.Console = console; action(); @@ -84,4 +87,4 @@ public static string CaptureConsoleOutput(Action action) AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings()); } } -} +} \ No newline at end of file diff --git a/tests/ProjGraph.Tests.Integration.Cli/VisualizeCommandTests.cs b/tests/ProjGraph.Tests.Integration.Cli/VisualizeCommandTests.cs index 3845138..db7f872 100644 --- a/tests/ProjGraph.Tests.Integration.Cli/VisualizeCommandTests.cs +++ b/tests/ProjGraph.Tests.Integration.Cli/VisualizeCommandTests.cs @@ -92,9 +92,9 @@ public void VisualizeCommand_SimpleDependencies_FlatFormat_ShouldShowList() // Assert capturedOutput.Should().Contain("Projects"); - capturedOutput.Should().Contain("๐Ÿ“ฆ A"); + capturedOutput.Should().Contain("A"); + capturedOutput.Should().Contain("B"); capturedOutput.Should().Contain("โ†’ B"); - capturedOutput.Should().Contain("๐Ÿ“ฆ B"); } [Fact]