use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use serde::Serialize;
use syn::parse::{Parse, ParseStream};
use syn::punctuated::Punctuated;
use syn::{Ident, Token};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Text,
Json,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagramFormat {
Dot,
Mermaid,
}
struct Transition {
from: Ident,
to: Ident,
}
impl Parse for Transition {
fn parse(input: ParseStream) -> syn::Result<Self> {
let from: Ident = input.parse()?;
input.parse::<Token![->]>()?;
let to: Ident = input.parse()?;
Ok(Self { from, to })
}
}
struct LifecycleArgs {
initial: Ident,
terminals: Vec<Ident>,
transitions: Vec<Transition>,
}
impl Parse for LifecycleArgs {
fn parse(input: ParseStream) -> syn::Result<Self> {
let mut initial: Option<Ident> = None;
let mut terminals: Option<Vec<Ident>> = None;
let mut transitions: Option<Vec<Transition>> = None;
while !input.is_empty() {
let key: Ident = input.parse()?;
match key.to_string().as_str() {
"initial" => {
if initial.is_some() {
return Err(syn::Error::new_spanned(&key, "duplicate `initial`"));
}
input.parse::<Token![=]>()?;
initial = Some(input.parse()?);
}
"terminal" => {
if terminals.is_some() {
return Err(syn::Error::new_spanned(&key, "duplicate `terminal`"));
}
let content;
syn::parenthesized!(content in input);
let idents: Punctuated<Ident, Token![,]> =
content.parse_terminated(Ident::parse, Token![,])?;
if idents.is_empty() {
return Err(syn::Error::new_spanned(
&key,
"`terminal(...)` requires at least one terminal state",
));
}
terminals = Some(idents.into_iter().collect());
}
"transitions" => {
if transitions.is_some() {
return Err(syn::Error::new_spanned(&key, "duplicate `transitions`"));
}
let content;
syn::parenthesized!(content in input);
let edges: Punctuated<Transition, Token![,]> =
content.parse_terminated(Transition::parse, Token![,])?;
if edges.is_empty() {
return Err(syn::Error::new_spanned(
&key,
"`transitions(...)` requires at least one `From -> To` edge",
));
}
transitions = Some(edges.into_iter().collect());
}
other => {
return Err(syn::Error::new_spanned(
&key,
format!(
"unknown `#[lifecycle]` argument `{other}` — expected `initial`, `terminal`, or `transitions`"
),
));
}
}
if input.peek(Token![,]) {
input.parse::<Token![,]>()?;
}
}
let initial = initial.ok_or_else(|| {
syn::Error::new(
proc_macro2::Span::call_site(),
"missing required `initial = <Variant>` in `#[lifecycle(...)]`",
)
})?;
let terminals = terminals.ok_or_else(|| {
syn::Error::new(
proc_macro2::Span::call_site(),
"missing required `terminal(<Variant>, ...)` in `#[lifecycle(...)]`",
)
})?;
let transitions = transitions.ok_or_else(|| {
syn::Error::new(
proc_macro2::Span::call_site(),
"missing required `transitions(<From> -> <To>, ...)` in `#[lifecycle(...)]`",
)
})?;
Ok(Self {
initial,
terminals,
transitions,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LifecycleDef {
pub id: String,
pub name: String,
pub states: Vec<String>,
pub initial: String,
pub terminals: Vec<String>,
pub transitions: Vec<(String, String)>,
}
#[derive(Debug)]
struct EffectEdge {
from: String,
to: String,
on: Option<String>,
on_commit: Option<String>,
}
struct RawBinding {
module_path: Vec<String>,
lifecycle_path: syn::Path,
edges: Vec<EffectEdge>,
}
impl std::fmt::Debug for RawBinding {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let path = self
.lifecycle_path
.segments
.iter()
.map(|s| s.ident.to_string())
.collect::<Vec<_>>()
.join("::");
f.debug_struct("RawBinding")
.field("module_path", &self.module_path)
.field("lifecycle_path", &path)
.field("edges", &self.edges)
.finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ViolationKind {
Existence,
Reachability,
DeadEnd,
TerminalSource,
}
impl ViolationKind {
const fn tag(self) -> &'static str {
match self {
Self::Existence => "existence",
Self::Reachability => "reachability",
Self::DeadEnd => "dead-end",
Self::TerminalSource => "terminal-source",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Violation {
pub kind: ViolationKind,
pub states: Vec<String>,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct LifecycleReport {
pub name: String,
pub states: Vec<String>,
pub initial: String,
pub terminals: Vec<String>,
pub transitions: Vec<(String, String)>,
pub violations: Vec<Violation>,
}
impl LifecycleReport {
const fn is_sound(&self) -> bool {
self.violations.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Report {
pub files_scanned: usize,
pub lifecycles: Vec<LifecycleReport>,
pub errors: Vec<String>,
}
impl Report {
#[must_use]
pub fn violation_count(&self) -> usize {
self.lifecycles.iter().map(|w| w.violations.len()).sum()
}
#[must_use]
pub fn exit_code(&self) -> i32 {
i32::from(!self.errors.is_empty() || self.lifecycles.iter().any(|w| !w.is_sound()))
}
}
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn analyze(wf: &LifecycleDef) -> LifecycleReport {
let states: BTreeSet<&str> = wf.states.iter().map(String::as_str).collect();
let mut violations = Vec::new();
if !states.contains(wf.initial.as_str()) {
violations.push(Violation {
kind: ViolationKind::Existence,
states: vec![wf.initial.clone()],
message: format!(
"unknown state '{}' referenced as initial state of lifecycle '{}'",
wf.initial, wf.name
),
});
}
for t in &wf.terminals {
if !states.contains(t.as_str()) {
violations.push(Violation {
kind: ViolationKind::Existence,
states: vec![t.clone()],
message: format!(
"unknown state '{t}' referenced in terminals of lifecycle '{}'",
wf.name
),
});
}
}
let mut reported_unknown: BTreeSet<&str> = BTreeSet::new();
for (from, to) in &wf.transitions {
for endpoint in [from, to] {
if !states.contains(endpoint.as_str()) && reported_unknown.insert(endpoint.as_str()) {
violations.push(Violation {
kind: ViolationKind::Existence,
states: vec![endpoint.clone()],
message: format!(
"unknown state '{endpoint}' referenced in transitions of lifecycle '{}'",
wf.name
),
});
}
}
}
let terminal_set: BTreeSet<&str> = wf.terminals.iter().map(String::as_str).collect();
for (from, to) in &wf.transitions {
if terminal_set.contains(from.as_str()) {
violations.push(Violation {
kind: ViolationKind::TerminalSource,
states: vec![from.clone()],
message: format!("terminal state '{from}' has an outgoing transition to '{to}'"),
});
}
}
let mut forward: HashMap<&str, Vec<&str>> = HashMap::new();
for (from, to) in &wf.transitions {
forward.entry(from.as_str()).or_default().push(to.as_str());
}
let reachable = if states.contains(wf.initial.as_str()) {
let reachable = bfs(wf.initial.as_str(), &forward);
for state in &wf.states {
if !reachable.contains(state.as_str()) {
violations.push(Violation {
kind: ViolationKind::Reachability,
states: vec![state.clone()],
message: format!(
"state '{state}' is unreachable from initial state '{}'",
wf.initial
),
});
}
}
reachable
} else {
BTreeSet::new()
};
let mut reverse: HashMap<&str, Vec<&str>> = HashMap::new();
for (from, to) in &wf.transitions {
reverse.entry(to.as_str()).or_default().push(from.as_str());
}
let mut can_reach_terminal: BTreeSet<&str> = BTreeSet::new();
let mut queue: VecDeque<&str> = VecDeque::new();
for t in &terminal_set {
if states.contains(t) && can_reach_terminal.insert(t) {
queue.push_back(t);
}
}
while let Some(node) = queue.pop_front() {
if let Some(preds) = reverse.get(node) {
for &p in preds {
if can_reach_terminal.insert(p) {
queue.push_back(p);
}
}
}
}
for state in &wf.states {
let s = state.as_str();
if reachable.contains(s) && !terminal_set.contains(s) && !can_reach_terminal.contains(s) {
violations.push(Violation {
kind: ViolationKind::DeadEnd,
states: vec![state.clone()],
message: format!(
"state '{state}' is a non-terminal dead-end (cannot reach any terminal state)"
),
});
}
}
LifecycleReport {
name: wf.name.clone(),
states: wf.states.clone(),
initial: wf.initial.clone(),
terminals: wf.terminals.clone(),
transitions: wf.transitions.clone(),
violations,
}
}
fn bfs<'a>(start: &'a str, adj: &HashMap<&'a str, Vec<&'a str>>) -> BTreeSet<&'a str> {
let mut visited: BTreeSet<&str> = BTreeSet::new();
let mut queue: VecDeque<&str> = VecDeque::new();
visited.insert(start);
queue.push_back(start);
while let Some(node) = queue.pop_front() {
if let Some(nexts) = adj.get(node) {
for &n in nexts {
if visited.insert(n) {
queue.push_back(n);
}
}
}
}
visited
}
#[derive(Debug, Default)]
struct Scan {
lifecycles: Vec<LifecycleDef>,
errors: Vec<String>,
files_scanned: usize,
raw_bindings: Vec<RawBinding>,
effect_edges: HashMap<String, Vec<EffectEdge>>,
}
fn scan_project(root: &Path) -> std::io::Result<Scan> {
std::fs::read_dir(root)?;
let mut scan = Scan::default();
let mut files = Vec::new();
collect_rs_files(root, &mut files);
files.sort();
for path in files {
let Ok(src) = std::fs::read_to_string(&path) else {
continue;
};
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.into_owned();
scan_source(&src, &rel, &mut scan);
scan.files_scanned += 1;
}
scan.effect_edges =
resolve_effect_bindings(std::mem::take(&mut scan.raw_bindings), &scan.lifecycles);
Ok(scan)
}
fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_symlink() {
continue;
}
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if file_type.is_dir() {
if name == "target" || name.starts_with('.') {
continue;
}
collect_rs_files(&path, out);
} else if file_type.is_file() && path.extension().and_then(|s| s.to_str()) == Some("rs") {
out.push(path);
}
}
}
fn scan_source(src: &str, file: &str, scan: &mut Scan) {
let Ok(ast) = syn::parse_file(src) else {
return;
};
let mut module_path = Vec::new();
scan_items(&ast.items, file, scan, &BTreeSet::new(), &mut module_path);
}
fn collect_use_tree_aliases(tree: &syn::UseTree, out: &mut BTreeSet<String>) {
match tree {
syn::UseTree::Path(path) => collect_use_tree_aliases(&path.tree, out),
syn::UseTree::Rename(rename) => {
if rename.ident == "lifecycle" {
out.insert(rename.rename.to_string());
}
}
syn::UseTree::Group(group) => {
for item in &group.items {
collect_use_tree_aliases(item, out);
}
}
syn::UseTree::Name(_) | syn::UseTree::Glob(_) => {}
}
}
fn scan_items(
items: &[syn::Item],
file: &str,
scan: &mut Scan,
inherited_aliases: &BTreeSet<String>,
module_path: &mut Vec<String>,
) {
let mut local_aliases = inherited_aliases.clone();
for item in items {
if let syn::Item::Use(item_use) = item {
collect_use_tree_aliases(&item_use.tree, &mut local_aliases);
}
}
for item in items {
match item {
syn::Item::Enum(item_enum) => {
collect_lifecycle_enum(
item_enum,
file,
scan,
&local_aliases,
module_path.as_slice(),
);
}
syn::Item::Struct(item_struct) => {
collect_state_machine_effects(item_struct, scan, module_path.as_slice());
}
syn::Item::Mod(item_mod) => {
if let Some((_, inner)) = &item_mod.content {
module_path.push(item_mod.ident.to_string());
scan_items(inner, file, scan, &local_aliases, module_path);
module_path.pop();
}
}
_ => {}
}
}
}
fn collect_lifecycle_enum(
item_enum: &syn::ItemEnum,
file: &str,
scan: &mut Scan,
aliases: &BTreeSet<String>,
module_path: &[String],
) {
let Some(attr) = item_enum.attrs.iter().find(|a| {
let path = a.path();
path.get_ident().map_or_else(
|| path.segments.last().is_some_and(|s| s.ident == "lifecycle"),
|ident| ident == "lifecycle" || aliases.contains(&ident.to_string()),
)
}) else {
return;
};
let name = item_enum.ident.to_string();
match attr.parse_args::<LifecycleArgs>() {
Ok(args) => {
let id = if module_path.is_empty() {
name.clone()
} else {
format!("{}::{}", module_path.join("::"), name)
};
let states = item_enum
.variants
.iter()
.map(|v| v.ident.to_string())
.collect();
scan.lifecycles.push(LifecycleDef {
id,
name,
states,
initial: args.initial.to_string(),
terminals: args.terminals.iter().map(ToString::to_string).collect(),
transitions: args
.transitions
.iter()
.map(|t| (t.from.to_string(), t.to.to_string()))
.collect(),
});
}
Err(err) => {
scan.errors.push(format!(
"{file}: malformed #[lifecycle] on enum '{name}': {err}"
));
}
}
}
fn collect_state_machine_effects(
item_struct: &syn::ItemStruct,
scan: &mut Scan,
module_path: &[String],
) {
for field in &item_struct.fields {
for attr in &field.attrs {
if !is_state_machine_attr(attr) {
continue;
}
if let Ok(Some((lifecycle_path, edges))) =
attr.parse_args_with(parse_state_machine_effects)
&& !edges.is_empty()
{
scan.raw_bindings.push(RawBinding {
module_path: module_path.to_vec(),
lifecycle_path,
edges,
});
}
}
}
}
fn resolve_effect_bindings(
bindings: Vec<RawBinding>,
lifecycles: &[LifecycleDef],
) -> HashMap<String, Vec<EffectEdge>> {
let id_set: BTreeSet<&str> = lifecycles.iter().map(|l| l.id.as_str()).collect();
let mut by_bare_name: HashMap<&str, Vec<&str>> = HashMap::new();
for l in lifecycles {
by_bare_name
.entry(l.name.as_str())
.or_default()
.push(l.id.as_str());
}
let mut effect_edges: HashMap<String, Vec<EffectEdge>> = HashMap::new();
for binding in bindings {
let qualified = resolve_lifecycle_id(&binding.module_path, &binding.lifecycle_path)
.filter(|id| id_set.contains(id.as_str()));
let key = qualified.or_else(|| {
let bare = binding.lifecycle_path.segments.last()?.ident.to_string();
match by_bare_name.get(bare.as_str()) {
Some(ids) if ids.len() == 1 => Some(ids[0].to_string()),
_ => None,
}
});
if let Some(key) = key {
effect_edges.entry(key).or_default().extend(binding.edges);
}
}
effect_edges
}
fn resolve_lifecycle_id(module_path: &[String], path: &syn::Path) -> Option<String> {
let segs: Vec<String> = path.segments.iter().map(|s| s.ident.to_string()).collect();
if segs.is_empty() {
return None;
}
let (mut prefix, rest): (Vec<String>, &[String]) = match segs[0].as_str() {
"crate" => (Vec::new(), &segs[1..]),
"self" => (module_path.to_vec(), &segs[1..]),
"super" => {
let mut prefix = module_path.to_vec();
let mut i = 0;
while i < segs.len() && segs[i] == "super" {
prefix.pop()?;
i += 1;
}
(prefix, &segs[i..])
}
_ => (module_path.to_vec(), &segs[..]),
};
if rest.is_empty() {
return None;
}
prefix.extend(rest.iter().cloned());
Some(prefix.join("::"))
}
fn is_state_machine_attr(attr: &syn::Attribute) -> bool {
attr.path()
.segments
.last()
.is_some_and(|s| s.ident == "state_machine")
}
fn parse_state_machine_effects(
input: ParseStream,
) -> syn::Result<Option<(syn::Path, Vec<EffectEdge>)>> {
let key: Ident = input.parse()?;
if key == "transitions" {
return Ok(None);
}
if key != "lifecycle" {
return Err(syn::Error::new_spanned(
&key,
"expected `transitions(...)` or `lifecycle = <Enum>`",
));
}
input.parse::<Token![=]>()?;
let path: syn::Path = input.parse()?;
if path.segments.is_empty() {
return Err(syn::Error::new_spanned(
&path,
"`lifecycle = <Enum>` requires a non-empty path",
));
}
let edges = if input.peek(Token![,]) {
input.parse::<Token![,]>()?;
let effects_kw: Ident = input.parse()?;
if effects_kw != "effects" {
return Err(syn::Error::new_spanned(
&effects_kw,
"expected `effects(...)` after `lifecycle = <Enum>`",
));
}
let content;
syn::parenthesized!(content in input);
parse_effect_edges(&content)?
} else {
Vec::new()
};
Ok(Some((path, edges)))
}
fn parse_effect_edges(content: ParseStream) -> syn::Result<Vec<EffectEdge>> {
let mut edges = Vec::new();
while !content.is_empty() {
let from: Ident = content.parse()?;
content.parse::<Token![->]>()?;
let to: Ident = content.parse()?;
let mut on: Option<String> = None;
let mut on_commit: Option<String> = None;
if content.peek(Token![:]) {
content.parse::<Token![:]>()?;
if content.peek(syn::LitStr) {
let _: syn::LitStr = content.parse()?;
} else {
loop {
let mkey: Ident = content.parse()?;
content.parse::<Token![=]>()?;
if mkey == "guard" {
let _: syn::LitStr = content.parse()?;
} else if mkey == "on_commit" {
let job: syn::Path = content.parse()?;
on_commit = job.segments.last().map(|seg| seg.ident.to_string());
} else if mkey == "on" {
let lit: syn::LitStr = content.parse()?;
on = Some(lit.value());
} else {
return Err(syn::Error::new_spanned(
&mkey,
"expected `guard = \"...\"`, `on = \"...\"`, or `on_commit = <Job>`",
));
}
if !content.peek(Token![,]) {
break;
}
let ahead = content.fork();
ahead.parse::<Token![,]>()?;
if ahead.peek(Ident) && ahead.peek2(Token![=]) {
content.parse::<Token![,]>()?;
} else {
break;
}
}
}
}
edges.push(EffectEdge {
from: from.to_string(),
to: to.to_string(),
on,
on_commit,
});
if content.peek(Token![,]) {
content.parse::<Token![,]>()?;
}
}
Ok(edges)
}
fn build_edge_labels(effects: Option<&Vec<EffectEdge>>) -> BTreeMap<(String, String), String> {
let mut acc: BTreeMap<(String, String), (BTreeSet<String>, BTreeSet<String>)> = BTreeMap::new();
if let Some(effects) = effects {
for edge in effects {
let entry = acc.entry((edge.from.clone(), edge.to.clone())).or_default();
if let Some(on) = &edge.on {
entry.0.insert(on.clone());
}
if let Some(on_commit) = &edge.on_commit {
entry.1.insert(on_commit.clone());
}
}
}
acc.into_iter()
.filter_map(|(edge, (ons, commits))| {
let mut parts = Vec::new();
parts.extend(ons.iter().map(|on| format!("on: {on}")));
parts.extend(
commits
.iter()
.map(|on_commit| format!("on_commit: {on_commit}")),
);
if parts.is_empty() {
None
} else {
Some((edge, parts.join(", ")))
}
})
.collect()
}
fn dot_label_escape(label: &str) -> String {
label.replace('\\', "\\\\").replace('"', "\\\"")
}
fn report_from_scan(scan: Scan) -> Report {
let lifecycles = scan.lifecycles.iter().map(analyze).collect();
Report {
files_scanned: scan.files_scanned,
lifecycles,
errors: scan.errors,
}
}
#[derive(Debug, Clone, Copy)]
pub struct CheckOptions {
pub format: OutputFormat,
}
#[must_use]
pub fn run_check(root: &Path, opts: CheckOptions) -> i32 {
let scan = match scan_project(root) {
Ok(scan) => scan,
Err(err) => {
eprintln!("error: cannot read scan path '{}': {err}", root.display());
return 1;
}
};
let report = report_from_scan(scan);
match opts.format {
OutputFormat::Json => print_check_json(&report),
OutputFormat::Text => print_check_text(&report),
}
report.exit_code()
}
fn print_check_json(report: &Report) {
match serde_json::to_string_pretty(report) {
Ok(json) => println!("{json}"),
Err(err) => eprintln!("autumn lifecycle check: failed to serialize report: {err}"),
}
}
fn print_check_text(report: &Report) {
println!("autumn lifecycle check");
println!(
" scanned {} lifecycle(s) across {} .rs file(s)",
report.lifecycles.len(),
report.files_scanned
);
for err in &report.errors {
println!(" error: {err}");
}
for wf in &report.lifecycles {
println!("\n lifecycle '{}' ({} state(s))", wf.name, wf.states.len());
println!(" initial: {}", wf.initial);
println!(" terminals: {}", wf.terminals.join(", "));
if wf.violations.is_empty() {
println!(" sound: no violations");
} else {
println!(" violations:");
for v in &wf.violations {
println!(" [{}] {}", v.kind.tag(), v.message);
}
}
}
let violations = report.violation_count();
println!(
"\n summary: {} lifecycle(s), {} violation(s), {} parse error(s)",
report.lifecycles.len(),
violations,
report.errors.len()
);
if report.exit_code() == 0 {
println!("Result: PASS — all lifecycles are sound.");
} else {
println!("Result: FAIL — fix the lifecycle violations above.");
}
}
#[derive(Debug, Clone)]
pub struct DiagramOptions {
pub format: DiagramFormat,
pub out: Option<PathBuf>,
}
#[must_use]
pub fn run_diagram(root: &Path, opts: &DiagramOptions) -> i32 {
let scan = match scan_project(root) {
Ok(scan) => scan,
Err(err) => {
eprintln!("error: cannot read scan path '{}': {err}", root.display());
return 1;
}
};
if !scan.errors.is_empty() {
for err in &scan.errors {
eprintln!("error: {err}");
}
return 1;
}
if scan.lifecycles.is_empty() {
eprintln!(
"autumn lifecycle diagram: no #[lifecycle] enums found under '{}'",
root.display()
);
return 1;
}
let output = match opts.format {
DiagramFormat::Dot => render_dot_document(&scan.lifecycles, &scan.effect_edges),
DiagramFormat::Mermaid => render_mermaid_document(&scan.lifecycles, &scan.effect_edges),
};
match &opts.out {
Some(path) => {
if let Err(err) = std::fs::write(path, &output) {
eprintln!(
"autumn lifecycle diagram: failed to write '{}': {err}",
path.display()
);
return 1;
}
println!(
"wrote {} diagram(s) to {}",
scan.lifecycles.len(),
path.display()
);
}
None => print!("{output}"),
}
0
}
#[must_use]
fn render_mermaid_document(
lifecycles: &[LifecycleDef],
effects: &HashMap<String, Vec<EffectEdge>>,
) -> String {
if let [single] = lifecycles {
return render_mermaid(single, &build_edge_labels(effects.get(&single.id)));
}
let mut out = String::new();
out.push_str("stateDiagram-v2\n");
for wf in lifecycles {
let labels = build_edge_labels(effects.get(&wf.id));
let ns = sanitize_ident(&wf.id);
let sid = |state: &str| format!("{ns}_{}", sanitize_ident(state));
let _ = writeln!(out, " state \"{}\" as {ns} {{", wf.name);
for state in &wf.states {
let _ = writeln!(out, " state \"{state}\" as {}", sid(state));
}
let _ = writeln!(out, " [*] --> {}", sid(&wf.initial));
for (from, to) in &wf.transitions {
if let Some(label) = labels.get(&(from.clone(), to.clone())) {
let _ = writeln!(out, " {} --> {} : {label}", sid(from), sid(to));
} else {
let _ = writeln!(out, " {} --> {}", sid(from), sid(to));
}
}
for t in &wf.terminals {
let _ = writeln!(out, " {} --> [*]", sid(t));
}
out.push_str(" }\n");
}
out
}
#[must_use]
pub fn render_mermaid(wf: &LifecycleDef, labels: &BTreeMap<(String, String), String>) -> String {
let mut out = String::new();
let _ = writeln!(out, "%% lifecycle: {}", wf.name);
out.push_str("stateDiagram-v2\n");
let _ = writeln!(out, " [*] --> {}", wf.initial);
for (from, to) in &wf.transitions {
if let Some(label) = labels.get(&(from.clone(), to.clone())) {
let _ = writeln!(out, " {from} --> {to} : {label}");
} else {
let _ = writeln!(out, " {from} --> {to}");
}
}
for t in &wf.terminals {
let _ = writeln!(out, " {t} --> [*]");
}
out
}
#[must_use]
fn render_dot_document(
lifecycles: &[LifecycleDef],
effects: &HashMap<String, Vec<EffectEdge>>,
) -> String {
if let [single] = lifecycles {
return render_dot(single, &build_edge_labels(effects.get(&single.id)));
}
let mut out = String::new();
out.push_str("digraph {\n");
out.push_str(" rankdir=LR;\n");
out.push_str(" node [shape=box, style=rounded];\n");
for wf in lifecycles {
write_dot_cluster(&mut out, wf, &build_edge_labels(effects.get(&wf.id)));
}
out.push_str("}\n");
out
}
fn write_dot_cluster(
out: &mut String,
wf: &LifecycleDef,
labels: &BTreeMap<(String, String), String>,
) {
let ns = sanitize_ident(&wf.id);
let nid = |state: &str| dot_id(&format!("{ns}.{state}"));
let start = dot_id(&format!("{ns}.__start"));
let _ = writeln!(out, " subgraph cluster_{ns} {{");
let _ = writeln!(out, " label={};", dot_id(&wf.name));
let _ = writeln!(out, " {start} [shape=point, width=0.15];");
for state in &wf.states {
let mut attrs = format!("label={}", dot_id(state));
if state == &wf.initial {
attrs.push_str(", style=\"rounded,filled\", fillcolor=lightblue");
}
if wf.terminals.contains(state) {
attrs.push_str(", shape=doublecircle");
}
let _ = writeln!(out, " {} [{attrs}];", nid(state));
}
let _ = writeln!(out, " {start} -> {};", nid(&wf.initial));
for (from, to) in &wf.transitions {
if let Some(label) = labels.get(&(from.clone(), to.clone())) {
let _ = writeln!(
out,
" {} -> {} [label=\"{}\"];",
nid(from),
nid(to),
dot_label_escape(label)
);
} else {
let _ = writeln!(out, " {} -> {};", nid(from), nid(to));
}
}
out.push_str(" }\n");
}
fn sanitize_ident(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' {
c
} else {
'_'
}
})
.collect()
}
#[must_use]
pub fn render_dot(wf: &LifecycleDef, labels: &BTreeMap<(String, String), String>) -> String {
let mut out = String::new();
let _ = writeln!(out, "digraph {} {{", wf.name);
out.push_str(" rankdir=LR;\n");
out.push_str(" node [shape=box, style=rounded];\n");
out.push_str(" __start [shape=point, width=0.15];\n");
let _ = writeln!(
out,
" {} [style=\"rounded,filled\", fillcolor=lightblue];",
dot_id(&wf.initial)
);
for t in &wf.terminals {
let _ = writeln!(out, " {} [shape=doublecircle];", dot_id(t));
}
let _ = writeln!(out, " __start -> {};", dot_id(&wf.initial));
for (from, to) in &wf.transitions {
if let Some(label) = labels.get(&(from.clone(), to.clone())) {
let _ = writeln!(
out,
" {} -> {} [label=\"{}\"];",
dot_id(from),
dot_id(to),
dot_label_escape(label)
);
} else {
let _ = writeln!(out, " {} -> {};", dot_id(from), dot_id(to));
}
}
out.push_str("}\n");
out
}
fn dot_id(name: &str) -> String {
format!("\"{name}\"")
}