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