198 lines
7 KiB
C#
198 lines
7 KiB
C#
|
|
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}()";
|
||
|
|
}
|
||
|
|
}
|