diff --git a/src/ProjGraph.Cli/Commands/VisualizeCommand.cs b/src/ProjGraph.Cli/Commands/VisualizeCommand.cs
index fe6aa9a..1e33342 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;
@@ -16,11 +16,11 @@ 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,
- IDiagramRenderer mermaidRenderer,
+ IEnumerable> renderers,
IOutputConsole console)
: AsyncCommand
{
@@ -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; private set; } = "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,10 @@ public override ValidationResult Validate()
return ValidationResult.Error($"File not found: {Path}");
}
- if (Format != "tree" && Format != "mermaid")
+ Format = Format.ToLowerInvariant();
+ 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 +80,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.
@@ -106,19 +107,26 @@ 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
{
// 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;
+ }
+
+ console.WriteLine(GetRenderer(settings.Format).Render(graph));
}
return 0;
@@ -129,4 +137,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
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
deleted file mode 100644
index 5bd9b5e..0000000
--- a/src/ProjGraph.Cli/Rendering/TreeRenderer.cs
+++ /dev/null
@@ -1,185 +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 by displaying its header, projects, dependencies, and any detected cyclic dependencies.
- ///
- ///
- /// The solution graph containing the projects and dependencies to be rendered.
- ///
- public static void Render(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 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.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.Core/Infrastructure/SpectreOutputConsole.cs b/src/ProjGraph.Lib.Core/Infrastructure/SpectreOutputConsole.cs
index 6315ca6..458b932 100644
--- a/src/ProjGraph.Lib.Core/Infrastructure/SpectreOutputConsole.cs
+++ b/src/ProjGraph.Lib.Core/Infrastructure/SpectreOutputConsole.cs
@@ -11,10 +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
{
- 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.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/DependencyInjection.cs b/src/ProjGraph.Lib.ProjectGraph/DependencyInjection.cs
index 53403a6..efeea96 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.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+
+ // Register as interface for collection injection
+ 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/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/MermaidGraphRenderer.cs b/src/ProjGraph.Lib.ProjectGraph/Rendering/MermaidGraphRenderer.cs
index 1a0c1dc..e7f449e 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.IsNullOrWhiteSpace(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/src/ProjGraph.Lib.ProjectGraph/Rendering/SolutionGraphRendererBase.cs b/src/ProjGraph.Lib.ProjectGraph/Rendering/SolutionGraphRendererBase.cs
new file mode 100644
index 0000000..179c687
--- /dev/null
+++ b/src/ProjGraph.Lib.ProjectGraph/Rendering/SolutionGraphRendererBase.cs
@@ -0,0 +1,94 @@
+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();
+ var globalConsole = AnsiConsole.Console;
+ _console = AnsiConsole.Create(new AnsiConsoleSettings
+ {
+ Ansi = globalConsole.Profile.Capabilities.Ansi ? AnsiSupport.Yes : AnsiSupport.No,
+ ColorSystem = ColorSystemSupport.Detect,
+ Out = new AnsiConsoleOutput(_writer)
+ });
+
+ // Inherit capabilities from the global console (like Unicode support)
+ _console.Profile.Capabilities.Unicode = globalConsole.Profile.Capabilities.Unicode;
+ _console.Profile.Width = globalConsole.Profile.Width;
+ }
+
+ ///
+ /// 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..666b9f8
--- /dev/null
+++ b/src/ProjGraph.Lib.ProjectGraph/Rendering/TreeGraphRenderer.cs
@@ -0,0 +1,152 @@
+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.Where(d => incomingCounts.ContainsKey(d.TargetId)))
+ {
+ incomingCounts[dep.TargetId]++;
+ }
+
+ 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
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 809c8b6..db7f872 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,23 +86,40 @@ 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);
});
// 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");
- 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