w4c-workflows-api/Services/MermaidGeneratorService.cs

198 lines
7 KiB
C#
Raw Normal View History

using System.Text.Json;
using w4c_workflows.Models;
namespace w4c_workflows.Services;
/// <summary>
/// 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 <see cref="WorkflowTask.Key"/> (never the hashed
/// <see cref="WorkflowTask.Id"/>), and every class diagram reflects
/// <see cref="WorkflowTask.Language"/> (not a runtime/host — that is
/// <see cref="Workflow.Target"/>).
/// </summary>
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<WorkflowTask> 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<WorkflowTask> tasks)
=> tasks.First(t => t.Key == "root");
private static string NodeLabel(WorkflowTask t) => $"{t.Key}<br/>({t.Language})";
// ------------------------------------------------------------------ flowchart
private static string Flowchart(IReadOnlyList<WorkflowTask> tasks)
{
var lines = new List<string> { "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<WorkflowTask> tasks)
{
var lines = new List<string> { "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<WorkflowTask> tasks)
{
var lines = new List<string> { "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<Guid, string>();
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<WorkflowTask> tasks)
{
var lines = new List<string> { "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
/// <summary>The linear success chain starting at root.</summary>
private static IReadOnlyList<WorkflowTask> Chain(IReadOnlyList<WorkflowTask> tasks)
{
var byId = tasks.ToDictionary(t => t.Id);
var chain = new List<WorkflowTask>();
var visited = new HashSet<Guid>();
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<WorkflowTask> tasks, Guid id)
=> tasks.FirstOrDefault(t => t.Id == id)?.Key ?? id.ToString();
private static string FileOf(WorkflowTask task)
=> JsonSerializer.Deserialize<EntryDefinition>(task.EntryJson ?? "{}", Json)?.File ?? "?";
private static string FunctionOf(WorkflowTask task)
{
var entry = JsonSerializer.Deserialize<EntryDefinition>(task.EntryJson ?? "{}", Json);
var fn = string.IsNullOrWhiteSpace(entry?.Function) ? "run" : entry.Function;
return $"+{fn}()";
}
}