using System.Text.Json; using w4c_workflows.Models; namespace w4c_workflows.Services; /// /// Generates Mermaid diagram source for a compiled workflow. The frontend /// renders the returned source with the existing `mermaid` library; the backend /// stays the single source of truth for graph layout. /// /// Task labels use the YAML (never the hashed /// ), and every class diagram reflects /// (not a runtime/host — that is /// ). /// public class MermaidGeneratorService { public static readonly string[] Types = { "flowchart", "sequence", "state", "class" }; private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); public string Generate(string type, Workflow workflow, IReadOnlyList tasks) => type switch { "flowchart" => Flowchart(tasks), "sequence" => Sequence(tasks), "state" => State(tasks), "class" => Class(tasks), _ => throw new ArgumentException($"Unknown diagram type '{type}'.", nameof(type)), }; private static WorkflowTask Root(IReadOnlyList tasks) => tasks.First(t => t.Key == "root"); private static string NodeLabel(WorkflowTask t) => $"{t.Key}
({t.Language})"; // ------------------------------------------------------------------ flowchart private static string Flowchart(IReadOnlyList tasks) { var lines = new List { "flowchart TD" }; foreach (var task in tasks.OrderBy(t => t.Order)) lines.Add($" {task.Key}[\"{NodeLabel(task)}\"]"); lines.Add(string.Empty); foreach (var task in tasks) { if (task.NextId != null) lines.Add($" {task.Key} --> {Key(tasks, task.NextId.Value)}"); } foreach (var task in tasks) { if (task.OnErrorId != null) lines.Add($" {task.Key} -.->|error| {Key(tasks, task.OnErrorId.Value)}"); } lines.Add(string.Empty); lines.Add(" classDef root fill:#fde68a,stroke:#d97706,stroke-width:2px"); lines.Add(" class root root"); return string.Join('\n', lines); } // ------------------------------------------------------------------ sequence private static string Sequence(IReadOnlyList tasks) { var lines = new List { "sequenceDiagram", " autonumber" }; foreach (var task in tasks.OrderBy(t => t.Order)) lines.Add($" participant {task.Key} as {task.Key} [{task.Language}]"); lines.Add(string.Empty); var happyPath = Chain(tasks); for (var i = 0; i < happyPath.Count - 1; i++) lines.Add($" {happyPath[i].Key}->>{happyPath[i + 1].Key}: {happyPath[i + 1].Key}"); var errorEdges = tasks.Where(t => t.OnErrorId != null).ToList(); if (errorEdges.Count > 0) { lines.Add(string.Empty); lines.Add(" alt on error"); foreach (var task in errorEdges) lines.Add($" {task.Key}->>{Key(tasks, task.OnErrorId!.Value)}: compensate"); lines.Add(" end"); } return string.Join('\n', lines); } // ------------------------------------------------------------------ state private static string State(IReadOnlyList tasks) { var lines = new List { "stateDiagram-v2" }; // Task keys are arbitrary YAML identifiers and may collide with mermaid // state-diagram reserved words ("root", "note", ...) that crash the // renderer. Alias every state to a generated, guaranteed-safe id (s0, // s1, ...) while keeping the human-readable task key as the state label. var nodes = tasks.OrderBy(t => t.Order).ToList(); var idOf = new Dictionary(); for (var i = 0; i < nodes.Count; i++) { var id = $"s{i}"; idOf[nodes[i].Id] = id; lines.Add($" state \"{nodes[i].Key}\" as {id}"); } var root = Root(tasks); lines.Add(string.Empty); lines.Add($" [*] --> {idOf[root.Id]}"); foreach (var task in tasks) { var from = idOf[task.Id]; if (task.NextId != null && idOf.TryGetValue(task.NextId.Value, out var next)) lines.Add($" {from} --> {next}"); else if (task.OnErrorId == null) lines.Add($" {from} --> [*]"); } foreach (var task in tasks) { if (task.OnErrorId != null && idOf.TryGetValue(task.OnErrorId.Value, out var onError)) lines.Add($" {idOf[task.Id]} --> {onError}: error"); } return string.Join('\n', lines); } // ------------------------------------------------------------------ class private static string Class(IReadOnlyList tasks) { var lines = new List { "classDiagram" }; foreach (var task in tasks.OrderBy(t => t.Order)) { lines.Add($" class {task.Key} {{"); lines.Add($" <<{task.Language}>>"); lines.Add($" {FunctionOf(task)}"); lines.Add($" mode: {task.Mode}"); lines.Add($" entry: {FileOf(task)}"); lines.Add(" }"); } lines.Add(string.Empty); foreach (var task in tasks) { if (task.NextId != null) lines.Add($" {task.Key} --> {Key(tasks, task.NextId.Value)} : next"); } foreach (var task in tasks) { if (task.OnErrorId != null) lines.Add($" {task.Key} ..> {Key(tasks, task.OnErrorId.Value)} : error"); } return string.Join('\n', lines); } // ------------------------------------------------------------------ helpers /// The linear success chain starting at root. private static IReadOnlyList Chain(IReadOnlyList tasks) { var byId = tasks.ToDictionary(t => t.Id); var chain = new List(); var visited = new HashSet(); var cursor = Root(tasks); while (cursor != null && visited.Add(cursor.Id)) { chain.Add(cursor); cursor = cursor.NextId != null && byId.TryGetValue(cursor.NextId.Value, out var next) ? next : null; } return chain; } private static string Key(IReadOnlyList tasks, Guid id) => tasks.FirstOrDefault(t => t.Id == id)?.Key ?? id.ToString(); private static string FileOf(WorkflowTask task) => JsonSerializer.Deserialize(task.EntryJson ?? "{}", Json)?.File ?? "?"; private static string FunctionOf(WorkflowTask task) { var entry = JsonSerializer.Deserialize(task.EntryJson ?? "{}", Json); var fn = string.IsNullOrWhiteSpace(entry?.Function) ? "run" : entry.Function; return $"+{fn}()"; } }