Skip to content
19 changes: 19 additions & 0 deletions RackPeek.Domain/UseCases/Mermaid/MermaidDiagramExportUseCase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using RackPeek.Domain.Resources;
using RackPeek.Domain.Persistence;

namespace RackPeek.Domain.UseCases.Mermaid {
public class MermaidDiagramExportUseCase : IUseCase {
private readonly IResourceCollection _repository;

public MermaidDiagramExportUseCase(IResourceCollection repository) {
_repository = repository;
}

public async Task<MermaidExportResult?> ExecuteAsync(MermaidExportOptions options) {
IReadOnlyList<Resource> resources = await _repository.GetAllOfTypeAsync<Resource>();
return resources.ToMermaidDiagram(options);
}
}
}
84 changes: 84 additions & 0 deletions RackPeek.Domain/UseCases/Mermaid/MermaidDiagramGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using RackPeek.Domain.Resources;

namespace RackPeek.Domain.UseCases.Mermaid {
public static class MermaidDiagramGenerator {
public static MermaidExportResult ToMermaidDiagram(
this IReadOnlyList<Resource> resources,
MermaidExportOptions? options = null) {
MermaidExportOptions resolvedOptions = options ?? new MermaidExportOptions();
var sb = new StringBuilder();
var warnings = new List<string>();

sb.AppendLine(resolvedOptions.DiagramType);

// Group resources by Kind
IOrderedEnumerable<IGrouping<string, Resource>> grouped = resources
.Where(r => resolvedOptions.IncludeTags.Count == 0
|| (r.Tags != null && r.Tags.Any(t => resolvedOptions.IncludeTags.Contains(t, StringComparer.OrdinalIgnoreCase))))
.GroupBy(r => Resource.KindToPlural(r.Kind))
.OrderBy(g => g.Key);

foreach (IGrouping<string, Resource> group in grouped) {
sb.AppendLine($" subgraph {SanitizeId(group.Key)}");
foreach (Resource r in group.OrderBy(x => x.Name)) {
var nodeId = SanitizeId(r.Name);
var label = BuildNodeLabel(r, resolvedOptions);
sb.AppendLine($" {nodeId}[\"{label}\"]");
}
sb.AppendLine(" end");
}

// Map RunsOn relationships if requested
if (resolvedOptions.IncludeEdges) {

var resourceLookup = resources.ToDictionary(r => r.Name, r => SanitizeId(r.Name), StringComparer.OrdinalIgnoreCase);

foreach (Resource r in resources) {
var nodeId = SanitizeId(r.Name);
foreach (var depName in r.RunsOn) {
if (resourceLookup.TryGetValue(depName, out var depId))
{
sb.AppendLine($" {nodeId} --> {depId}");
}
else
{
warnings.Add($"RunsOn reference '{depName}' for '{r.Name}' not found in resources");
}
}
}
}

if (sb.Length == 0)
warnings.Add("No Mermaid diagram entries generated.");

return new MermaidExportResult(sb.ToString().TrimEnd(), warnings);
}

private static string BuildNodeLabel(Resource r, MermaidExportOptions options) {
if (!options.IncludeLabels)
return r.Name;

IEnumerable<KeyValuePair<string, string>> filtered = options.LabelWhitelist is null
? r.Labels
: r.Labels.Where(kvp => options.LabelWhitelist.Contains(kvp.Key, StringComparer.OrdinalIgnoreCase));

var labelParts = filtered.Select(kvp => $"{kvp.Key}: {kvp.Value}").ToList();
return labelParts.Count == 0 ? r.Name : $"{r.Name}\\n{string.Join("\\n", labelParts)}";
}

private static string SanitizeId(string name) {
var sb = new StringBuilder();
foreach (var ch in name.Trim().ToLowerInvariant()) {
if (char.IsLetterOrDigit(ch) || ch == '_')
sb.Append(ch);
else if (ch == '-' || ch == '.' || ch == ' ')
sb.Append('_');
}
return sb.Length == 0 ? "node" : sb.ToString();
}
}
}
35 changes: 35 additions & 0 deletions RackPeek.Domain/UseCases/Mermaid/MermaidExportOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System.Collections.Generic;

namespace RackPeek.Domain.UseCases.Mermaid {
public sealed record MermaidExportOptions {
/// <summary>
/// Only include resources with these tags (optional)
/// </summary>
public IReadOnlyList<string> IncludeTags { get; init; } = new List<string>();

/// <summary>
/// Diagram type: "flowchart", "sequence", "class", "er", etc.
/// Default: flowchart TD
/// </summary>
public string DiagramType { get; init; } = "flowchart TD";

/// <summary>
/// Whether to include resource labels as annotations
/// </summary>
public bool IncludeLabels { get; init; } = true;

/// <summary>
/// Whether to include relationships (edges)
/// </summary>
public bool IncludeEdges { get; init; } = true;

/// <summary>
/// Optional label keys to include (null = include all)
/// </summary>
public IReadOnlyList<string>? LabelWhitelist { get; init; }
}

public sealed record MermaidExportResult(
string DiagramText,
IReadOnlyList<string> Warnings);
}
11 changes: 9 additions & 2 deletions Shared.Rcl/CliBootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
using RackPeek.Domain.Persistence;
using RackPeek.Domain.Persistence.Yaml;
using Shared.Rcl.Commands;
using Shared.Rcl.Commands.Connections;
using Shared.Rcl.Commands.AccessPoints;
using Shared.Rcl.Commands.AccessPoints.Labels;
using Shared.Rcl.Commands.Connections;
using Shared.Rcl.Commands.Desktops;
using Shared.Rcl.Commands.Desktops.Cpus;
using Shared.Rcl.Commands.Desktops.Drive;
Expand Down Expand Up @@ -578,7 +578,7 @@ public static void BuildApp(CommandApp app) {
hosts.AddCommand<GenerateHostsFileCommand>("export")
.WithDescription("Generate a /etc/hosts compatible file.");
});

config.AddBranch("connections", connections => {
connections.SetDescription("Manage physical or logical port connections.");

Expand All @@ -588,6 +588,13 @@ public static void BuildApp(CommandApp app) {
connections.AddCommand<ConnectionRemoveCommand>("remove")
.WithDescription("Remove the connection from a specific port.");
});

config.AddBranch("mermaid", mermaid => {
mermaid.SetDescription("Generate Mermaid diagrams from infrastructure.");

mermaid.AddCommand<GenerateMermaidDiagramCommand>("export")
.WithDescription("Generate a Mermaid infrastructure diagram.");
});
});
}

Expand Down
68 changes: 68 additions & 0 deletions Shared.Rcl/Commands/Exporters/GenerateMermaidDiagramCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using Microsoft.Extensions.DependencyInjection;
using RackPeek.Domain.UseCases.Mermaid;
using Spectre.Console;
using Spectre.Console.Cli;

namespace Shared.Rcl.Commands.Exporters;

public sealed class GenerateMermaidDiagramCommand(IServiceProvider provider)
: AsyncCommand<GenerateMermaidDiagramSettings> {
public override async Task<int> ExecuteAsync(
CommandContext context,
GenerateMermaidDiagramSettings settings,
CancellationToken cancellationToken) {
using IServiceScope scope = provider.CreateScope();

MermaidDiagramExportUseCase useCase = scope.ServiceProvider
.GetRequiredService<MermaidDiagramExportUseCase>();

var options = new MermaidExportOptions {
IncludeTags = ParseCsv(settings.IncludeTags),
DiagramType = settings.DiagramType ?? "flowchart TD",
IncludeLabels = !settings.NoLabels,
IncludeEdges = !settings.NoEdges,
LabelWhitelist = ParseCsv(settings.LabelWhitelist)
};

MermaidExportResult? result = await useCase.ExecuteAsync(options);

if (result is null) {
AnsiConsole.MarkupLine("[red]Mermaid export returned null.[/]");
return -1;
}

if (result.Warnings.Any()) {
AnsiConsole.MarkupLine("[yellow]Warnings:[/]");
foreach (var warning in result.Warnings)
AnsiConsole.MarkupLine($"[yellow]- {Markup.Escape(warning)}[/]");
AnsiConsole.WriteLine();
}

if (!string.IsNullOrWhiteSpace(settings.OutputPath)) {
await File.WriteAllTextAsync(
settings.OutputPath,
result.DiagramText,
cancellationToken);

AnsiConsole.MarkupLine(
$"[green]Mermaid diagram written to:[/] {Markup.Escape(settings.OutputPath)}");
}
else {
AnsiConsole.MarkupLine("[green]Generated Mermaid Diagram:[/]");
AnsiConsole.WriteLine();
AnsiConsole.Write(result.DiagramText);
}

return 0;
}

private static IReadOnlyList<string> ParseCsv(string? raw) {
if (string.IsNullOrWhiteSpace(raw))
return Array.Empty<string>();

return raw.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Trim())
.Where(x => !string.IsNullOrWhiteSpace(x))
.ToArray();
}
}
30 changes: 30 additions & 0 deletions Shared.Rcl/Commands/Exporters/GenerateMermaidDiagramSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System.ComponentModel;
using Spectre.Console.Cli;

namespace Shared.Rcl.Commands.Exporters;

public sealed class GenerateMermaidDiagramSettings : CommandSettings {
[CommandOption("--include-tags")]
[Description("Comma-separated list of tags to include (e.g. prod,linux)")]
public string? IncludeTags { get; init; }

[CommandOption("--diagram-type")]
[Description("Mermaid diagram type (default: \"flowchart TD\")")]
public string? DiagramType { get; init; }

[CommandOption("--no-labels")]
[Description("Disable resource label annotations")]
public bool NoLabels { get; init; }

[CommandOption("--no-edges")]
[Description("Disable relationship edges")]
public bool NoEdges { get; init; }

[CommandOption("--label-whitelist")]
[Description("Comma-separated list of label keys to include")]
public string? LabelWhitelist { get; init; }

[CommandOption("-o|--output")]
[Description("Write Mermaid diagram to file instead of stdout")]
public string? OutputPath { get; init; }
}
Loading
Loading