From b04479a04d1455cfae976475fc066a7384bccb41 Mon Sep 17 00:00:00 2001 From: Eran Boodnero Date: Wed, 25 Mar 2026 09:35:32 -0700 Subject: [PATCH 1/9] feat(graph): add statum-graph export crate --- Cargo.toml | 12 +- README.md | 4 + docs/introspection.md | 4 + statum-graph/Cargo.toml | 26 ++ statum-graph/README.md | 76 +++++ statum-graph/src/lib.rs | 160 ++++++++++ statum-graph/src/render.rs | 65 ++++ statum-graph/tests/export.rs | 293 ++++++++++++++++++ .../export__branching_flow_mermaid.snap | 18 ++ statum/README.md | 2 + 10 files changed, 658 insertions(+), 2 deletions(-) create mode 100644 statum-graph/Cargo.toml create mode 100644 statum-graph/README.md create mode 100644 statum-graph/src/lib.rs create mode 100644 statum-graph/src/render.rs create mode 100644 statum-graph/tests/export.rs create mode 100644 statum-graph/tests/snapshots/export__branching_flow_mermaid.snap diff --git a/Cargo.toml b/Cargo.toml index 09c70da..b4c8dbf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,14 @@ [workspace] resolver = "2" -members = ["module_path_extractor", "macro_registry", "statum-core", "statum-macros", "statum", "statum-examples"] +members = [ + "module_path_extractor", + "macro_registry", + "statum-core", + "statum-macros", + "statum", + "statum-examples", + "statum-graph", +] [patch.crates-io] module_path_extractor = { path = "module_path_extractor" } @@ -10,5 +18,5 @@ statum = { path = "statum" } [workspace.metadata.scripts] version-bump = "cargo script scripts/update_version.rs -- 1.0.0" -publish = "cargo publish -p module_path_extractor && cargo publish -p macro_registry && cargo publish -p statum-core && cargo publish -p statum-macros && cargo publish -p statum" +publish = "cargo publish -p module_path_extractor && cargo publish -p macro_registry && cargo publish -p statum-core && cargo publish -p statum-macros && cargo publish -p statum && cargo publish -p statum-graph" publish-dry-run = "bash scripts/check_publish_dry_run.sh" diff --git a/README.md b/README.md index b168e00..7fd2171 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,10 @@ See [docs/introspection.md](docs/introspection.md) for the full guide and [statum-examples/src/toy_demos/16-machine-introspection.rs](statum-examples/src/toy_demos/16-machine-introspection.rs) for a runnable example. +If you want a ready-made renderer for that graph surface, the workspace also +ships [statum-graph](statum-graph/README.md), which exports machine-local +topology and Mermaid output directly from `MachineIntrospection::GRAPH`. + For source-local labels and descriptions, use `#[present(...)]` on the machine, state variants, and transition methods. If you also want typed metadata in the generated `machine::PRESENTATION` constant, declare diff --git a/docs/introspection.md b/docs/introspection.md index 94b1659..4f3855b 100644 --- a/docs/introspection.md +++ b/docs/introspection.md @@ -91,6 +91,10 @@ From there, a consumer can ask for: - a transition by source state and method name - the exact legal targets for a transition site +If you want a ready-made static graph export instead of writing your own +renderer, `statum-graph` builds `MachineDoc` values and Mermaid output directly +from this graph surface. + ## Transition Identity State ids are generated as a machine-scoped enum like `flow::StateId`. diff --git a/statum-graph/Cargo.toml b/statum-graph/Cargo.toml new file mode 100644 index 0000000..33ca481 --- /dev/null +++ b/statum-graph/Cargo.toml @@ -0,0 +1,26 @@ +[dependencies.statum] +path = "../statum" +version = "0.6.9" + +[dev-dependencies] +insta = "1.43" + +[package] +authors = ["Eran Boodnero "] +categories = ["development-tools", "rust-patterns"] +description = "Static graph export for Statum machine introspection" +documentation = "https://docs.rs/statum-graph" +edition = "2021" +keywords = [ + "typestate", + "graph", + "mermaid", + "workflow", + "state-machine", +] +license = "MIT" +name = "statum-graph" +readme = "README.md" +repository = "https://github.com/eboody/statum" +rust-version = "1.93" +version = "0.6.9" diff --git a/statum-graph/README.md b/statum-graph/README.md new file mode 100644 index 0000000..5cc5de8 --- /dev/null +++ b/statum-graph/README.md @@ -0,0 +1,76 @@ +# statum-graph + +`statum-graph` exports static machine topology directly from +`statum::MachineIntrospection::GRAPH`. + +It is authoritative only for machine-local structure: + +- machine identity +- states +- transition sites +- exact legal targets +- roots derivable from the static graph itself + +It does not model runtime-selected branches, orchestration across multiple +machines, or consumer-owned explanation metadata. + +## Install + +```toml +[dependencies] +statum = "0.6.9" +statum-graph = "0.6.9" +``` + +## Example + +```rust +use statum::{machine, state, transition}; +use statum_graph::{render, MachineDoc}; + +#[state] +enum FlowState { + Draft, + Review, + Accepted, + Rejected, +} + +#[machine] +struct Flow {} + +#[transition] +impl Flow { + fn submit(self) -> Flow { + self.transition() + } +} + +#[transition] +impl Flow { + fn decide( + self, + accept: bool, + ) -> ::core::result::Result, Flow> { + if accept { + Ok(self.accept()) + } else { + Err(self.reject()) + } + } + + fn accept(self) -> Flow { + self.transition() + } + + fn reject(self) -> Flow { + self.transition() + } +} + +let doc = MachineDoc::from_machine::>(); +let mermaid = render::mermaid(&doc); + +assert!(mermaid.contains("s1 -->|decide| s2")); +assert!(mermaid.contains("s1 -->|decide| s3")); +``` diff --git a/statum-graph/src/lib.rs b/statum-graph/src/lib.rs new file mode 100644 index 0000000..729c321 --- /dev/null +++ b/statum-graph/src/lib.rs @@ -0,0 +1,160 @@ +//! Static graph export built directly from `statum::MachineIntrospection::GRAPH`. +//! +//! This crate is authoritative only for machine-local topology: +//! machine identity, states, transition sites, exact legal targets, and +//! roots derivable from the static graph itself. +//! +//! It does not model orchestration order across machines, runtime-selected +//! branches for one run, or any consumer-owned presentation metadata. + +use std::collections::{HashMap, HashSet}; + +use statum::{ + MachineDescriptor, MachineGraph, MachineIntrospection, StateDescriptor, TransitionDescriptor, +}; + +pub mod render; + +/// Static machine graph exported directly from `MachineIntrospection::GRAPH`. +/// +/// This type is authoritative only for machine-local topology: +/// states, transition sites, exact legal targets, and roots derivable +/// from the static graph itself. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MachineDoc { + /// Descriptor for the exported machine family. + pub machine: MachineDescriptor, + /// Exported states in the same order as the underlying static graph. + pub states: Vec>, + /// Exported transition sites sorted stably for deterministic renderers. + pub edges: Vec>, +} + +impl MachineDoc +where + S: Copy + Eq + std::hash::Hash + 'static, + T: Copy + Eq + 'static, +{ + /// Exports one machine family from a concrete `MachineIntrospection` type. + pub fn from_machine() -> Self + where + M: MachineIntrospection, + { + Self::from_graph(M::GRAPH) + } + + /// Exports one machine graph from a static `MachineGraph`. + pub fn from_graph(graph: &'static MachineGraph) -> Self { + let incoming = incoming_states(graph); + let state_positions = state_positions(graph.states); + + let states = graph + .states + .iter() + .copied() + .map(|descriptor| StateDoc { + descriptor, + is_root: !incoming.contains(&descriptor.id), + }) + .collect(); + + let mut edges = graph + .transitions + .iter() + .copied() + .map(|descriptor| EdgeDoc { descriptor }) + .collect::>(); + edges.sort_by(|left, right| compare_edges(&state_positions, left, right)); + + Self { + machine: graph.machine, + states, + edges, + } + } + + /// Returns the exported state descriptor for one generated state id. + pub fn state(&self, id: S) -> Option<&StateDoc> { + self.states.iter().find(|state| state.descriptor.id == id) + } + + /// Returns every state with no incoming edge in the exported topology. + pub fn roots(&self) -> impl Iterator> { + self.states.iter().filter(|state| state.is_root) + } +} + +/// Exported state metadata for one graph node. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct StateDoc { + /// Underlying descriptor from `statum`. + pub descriptor: StateDescriptor, + /// True when the exported topology has no incoming edge for this state. + pub is_root: bool, +} + +/// Exported transition metadata for one graph edge site. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EdgeDoc { + /// Underlying descriptor from `statum`. + pub descriptor: TransitionDescriptor, +} + +fn incoming_states(graph: &MachineGraph) -> HashSet +where + S: Copy + Eq + std::hash::Hash + 'static, + T: Copy + Eq + 'static, +{ + let mut incoming = HashSet::new(); + for transition in graph.transitions.iter() { + for target in transition.to.iter().copied() { + incoming.insert(target); + } + } + + incoming +} + +fn state_positions(states: &[StateDescriptor]) -> HashMap +where + S: Copy + Eq + std::hash::Hash + 'static, +{ + states + .iter() + .enumerate() + .map(|(index, state)| (state.id, index)) + .collect() +} + +fn compare_edges( + state_positions: &HashMap, + left: &EdgeDoc, + right: &EdgeDoc, +) -> std::cmp::Ordering +where + S: Copy + Eq + std::hash::Hash + 'static, + T: Copy + Eq + 'static, +{ + state_positions[&left.descriptor.from] + .cmp(&state_positions[&right.descriptor.from]) + .then_with(|| { + left.descriptor + .method_name + .cmp(right.descriptor.method_name) + }) + .then_with(|| compare_targets(state_positions, left.descriptor.to, right.descriptor.to)) +} + +fn compare_targets( + state_positions: &HashMap, + left: &[S], + right: &[S], +) -> std::cmp::Ordering +where + S: Copy + Eq + std::hash::Hash + 'static, +{ + let left = left.iter().map(|state| state_positions[state]); + let right = right.iter().map(|state| state_positions[state]); + + left.cmp(right) +} diff --git a/statum-graph/src/render.rs b/statum-graph/src/render.rs new file mode 100644 index 0000000..19b4059 --- /dev/null +++ b/statum-graph/src/render.rs @@ -0,0 +1,65 @@ +use std::collections::HashMap; + +use crate::MachineDoc; + +/// Renders a machine-local topology as a Mermaid flow graph. +pub fn mermaid(doc: &MachineDoc) -> String +where + S: Copy + Eq + std::hash::Hash + 'static, + T: 'static, +{ + let state_positions: HashMap = doc + .states + .iter() + .enumerate() + .map(|(index, state)| (state.descriptor.id, index)) + .collect(); + + let mut lines = vec!["graph TD".to_string()]; + for (index, state) in doc.states.iter().enumerate() { + lines.push(format!( + " {}[\"{}\"]", + node_id(index), + escape_label(&state_label( + state.descriptor.rust_name, + state.descriptor.has_data + )) + )); + } + + if !doc.edges.is_empty() { + lines.push(String::new()); + } + + for edge in &doc.edges { + let from = node_id(state_positions[&edge.descriptor.from]); + for target in edge.descriptor.to.iter().copied() { + let to = node_id(state_positions[&target]); + lines.push(format!( + " {from} -->|{}| {to}", + escape_label(edge.descriptor.method_name) + )); + } + } + + lines.join("\n") +} + +fn node_id(index: usize) -> String { + format!("s{index}") +} + +fn state_label(rust_name: &str, has_data: bool) -> String { + if has_data { + format!("{rust_name} (data)") + } else { + rust_name.to_string() + } +} + +fn escape_label(label: &str) -> String { + label + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") +} diff --git a/statum-graph/tests/export.rs b/statum-graph/tests/export.rs new file mode 100644 index 0000000..8671486 --- /dev/null +++ b/statum-graph/tests/export.rs @@ -0,0 +1,293 @@ +#![allow(dead_code)] + +use statum_graph::{render, MachineDoc}; + +mod linear { + use statum::{machine, state, transition}; + + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct ReviewPayload { + pub reviewer: &'static str, + } + + #[state] + pub enum State { + Draft, + Review(ReviewPayload), + Published, + } + + #[machine] + pub struct Flow {} + + #[transition] + impl Flow { + fn submit(self) -> Flow { + self.transition_with(ReviewPayload { reviewer: "amy" }) + } + } + + #[transition] + impl Flow { + fn publish(self) -> Flow { + self.transition() + } + } +} + +mod branching { + use statum::{machine, state, transition}; + + #[state] + pub enum State { + Draft, + Review, + Accepted, + Rejected, + Archived, + } + + #[machine] + pub struct Flow {} + + #[transition] + impl Flow { + fn submit(self) -> Flow { + self.transition() + } + } + + #[transition] + impl Flow { + fn maybe_decide( + self, + accept: bool, + ) -> ::core::result::Result, Flow> { + if accept { + Ok(self.accept()) + } else { + Err(self.reject()) + } + } + + fn accept(self) -> Flow { + self.transition() + } + + fn reject(self) -> Flow { + self.transition() + } + } + + #[transition] + impl Flow { + fn archive(self) -> Flow { + self.transition() + } + } + + #[transition] + impl Flow { + fn archive(self) -> Flow { + self.transition() + } + } +} + +mod multi_root { + use statum::{machine, state, transition}; + + #[state] + pub enum State { + First, + Second, + Finished, + } + + #[machine] + pub struct Flow {} + + #[transition] + impl Flow { + fn finish(self) -> Flow { + self.transition() + } + } +} + +mod no_root { + use statum::{machine, state, transition}; + + #[state] + pub enum State { + Draft, + Review, + Rejected, + } + + #[machine] + pub struct Flow {} + + #[transition] + impl Flow { + fn submit(self) -> Flow { + self.transition() + } + } + + #[transition] + impl Flow { + fn reject(self) -> Flow { + self.transition() + } + } + + #[transition] + impl Flow { + fn rework(self) -> Flow { + self.transition() + } + } +} + +mod macro_generated { + use statum::{machine, state, transition}; + + #[state] + pub enum State { + Start, + Enabled, + MacroTarget, + } + + #[machine] + pub struct Flow {} + + #[transition] + impl Flow { + fn enable(self) -> Flow { + self.transition() + } + } + + macro_rules! generated_transitions { + () => { + #[transition] + impl Flow { + fn via_macro(self) -> Flow { + self.transition() + } + } + }; + } + + generated_transitions!(); +} + +#[test] +fn exports_linear_machine_topology_from_graph() { + let doc = MachineDoc::from_machine::>(); + + assert_eq!(doc.machine.rust_type_path, "export::linear::Flow"); + assert_eq!( + doc.states + .iter() + .map(|state| ( + state.descriptor.rust_name, + state.descriptor.has_data, + state.is_root + )) + .collect::>(), + vec![ + ("Draft", false, true), + ("Review", true, false), + ("Published", false, false), + ] + ); + assert_eq!( + doc.edges + .iter() + .map(|edge| edge.descriptor.method_name) + .collect::>(), + vec!["submit", "publish"] + ); +} + +#[test] +fn preserves_exact_branch_targets_and_sorts_edges_stably() { + let doc = MachineDoc::from_machine::>(); + + assert_eq!( + doc.edges + .iter() + .map(|edge| edge.descriptor.method_name) + .collect::>(), + vec![ + "submit", + "accept", + "maybe_decide", + "reject", + "archive", + "archive" + ] + ); + + let maybe_decide = doc + .edges + .iter() + .find(|edge| edge.descriptor.method_name == "maybe_decide") + .expect("branching transition"); + assert_eq!( + maybe_decide + .descriptor + .to + .iter() + .map(|state| doc.state(*state).unwrap().descriptor.rust_name) + .collect::>(), + vec!["Accepted", "Rejected"] + ); +} + +#[test] +fn derives_multiple_roots_and_zero_roots_from_topology() { + let multi_root = MachineDoc::from_machine::>(); + assert_eq!( + multi_root + .roots() + .map(|state| state.descriptor.rust_name) + .collect::>(), + vec!["First", "Second"] + ); + + let no_root = MachineDoc::from_machine::>(); + assert_eq!(no_root.roots().count(), 0); +} + +#[test] +fn mermaid_snapshot_is_stable_for_reconverging_graphs() { + let doc = MachineDoc::from_machine::>(); + insta::assert_snapshot!("branching_flow_mermaid", render::mermaid(&doc)); +} + +#[test] +fn mermaid_renders_one_edge_per_legal_target() { + let doc = MachineDoc::from_machine::>(); + let mermaid = render::mermaid(&doc); + + assert_eq!(mermaid.matches("-->|maybe_decide|").count(), 2); + assert!(mermaid.contains("s1 -->|maybe_decide| s2")); + assert!(mermaid.contains("s1 -->|maybe_decide| s3")); +} + +#[test] +fn exports_macro_generated_transition_sites() { + let doc = MachineDoc::from_machine::>(); + + assert_eq!( + doc.edges + .iter() + .map(|edge| edge.descriptor.method_name) + .collect::>(), + vec!["enable", "via_macro"] + ); +} diff --git a/statum-graph/tests/snapshots/export__branching_flow_mermaid.snap b/statum-graph/tests/snapshots/export__branching_flow_mermaid.snap new file mode 100644 index 0000000..fdf7bb9 --- /dev/null +++ b/statum-graph/tests/snapshots/export__branching_flow_mermaid.snap @@ -0,0 +1,18 @@ +--- +source: statum-graph/tests/export.rs +expression: render::mermaid(&doc) +--- +graph TD + s0["Draft"] + s1["Review"] + s2["Accepted"] + s3["Rejected"] + s4["Archived"] + + s0 -->|submit| s1 + s1 -->|accept| s2 + s1 -->|maybe_decide| s2 + s1 -->|maybe_decide| s3 + s1 -->|reject| s3 + s2 -->|archive| s4 + s3 -->|archive| s4 diff --git a/statum/README.md b/statum/README.md index 7192374..4e7c179 100644 --- a/statum/README.md +++ b/statum/README.md @@ -70,6 +70,8 @@ impl Light { CLI explainers, graph exports, generated docs, branch-strip views, or runtime replay/debug tooling. Statum exposes exact transition sites instead of a coarse machine-wide state list. +- `statum-graph` is the companion crate for exporting that static graph surface + as machine-local topology and Mermaid output. - API docs: - Repository README: - Coding-agent kit: From 3a9207814bc720265733cdc29a01abae918892db Mon Sep 17 00:00:00 2001 From: Eran Boodnero Date: Wed, 25 Mar 2026 09:55:11 -0700 Subject: [PATCH 2/9] fix(graph): satisfy clippy in mermaid renderer --- statum-graph/src/render.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/statum-graph/src/render.rs b/statum-graph/src/render.rs index 19b4059..fcbc6dd 100644 --- a/statum-graph/src/render.rs +++ b/statum-graph/src/render.rs @@ -33,8 +33,8 @@ where for edge in &doc.edges { let from = node_id(state_positions[&edge.descriptor.from]); - for target in edge.descriptor.to.iter().copied() { - let to = node_id(state_positions[&target]); + for target in edge.descriptor.to { + let to = node_id(state_positions[target]); lines.push(format!( " {from} -->|{}| {to}", escape_label(edge.descriptor.method_name) From 0f5d0931aa79ea79488452b4217baa94dea01299 Mon Sep 17 00:00:00 2001 From: Eran Boodnero Date: Wed, 25 Mar 2026 10:51:59 -0700 Subject: [PATCH 3/9] fix(graph): validate machine doc invariants --- statum-graph/src/lib.rs | 138 ++++++++++++++++++++++++++++++----- statum-graph/src/render.rs | 8 +- statum-graph/tests/export.rs | 110 ++++++++++++++++++++++++++-- 3 files changed, 227 insertions(+), 29 deletions(-) diff --git a/statum-graph/src/lib.rs b/statum-graph/src/lib.rs index 729c321..bd1a79e 100644 --- a/statum-graph/src/lib.rs +++ b/statum-graph/src/lib.rs @@ -22,12 +22,93 @@ pub mod render; /// from the static graph itself. #[derive(Clone, Debug, PartialEq, Eq)] pub struct MachineDoc { + machine: MachineDescriptor, + states: Vec>, + edges: Vec>, +} + +/// Error returned when a `MachineGraph` cannot be exported into a `MachineDoc`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MachineDocError { + /// One transition source state is not present in the graph's state list. + MissingSourceState { + machine: &'static str, + transition: &'static str, + }, + /// One transition target state is not present in the graph's state list. + MissingTargetState { + machine: &'static str, + transition: &'static str, + }, +} + +impl core::fmt::Display for MachineDocError { + fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::MissingSourceState { + machine, + transition, + } => write!( + formatter, + "machine graph `{machine}` contains transition `{transition}` whose source state is missing from the state list" + ), + Self::MissingTargetState { + machine, + transition, + } => write!( + formatter, + "machine graph `{machine}` contains transition `{transition}` whose target state is missing from the state list" + ), + } + } +} + +impl std::error::Error for MachineDocError {} + +impl TryFrom<&'static MachineGraph> for MachineDoc +where + S: Copy + Eq + std::hash::Hash + 'static, + T: Copy + Eq + 'static, +{ + type Error = MachineDocError; + + fn try_from(graph: &'static MachineGraph) -> Result { + Self::try_from_graph(graph) + } +} + +impl MachineDoc { /// Descriptor for the exported machine family. - pub machine: MachineDescriptor, + pub fn machine(&self) -> MachineDescriptor { + self.machine + } + /// Exported states in the same order as the underlying static graph. - pub states: Vec>, + pub fn states(&self) -> &[StateDoc] { + &self.states + } + /// Exported transition sites sorted stably for deterministic renderers. - pub edges: Vec>, + pub fn edges(&self) -> &[EdgeDoc] { + &self.edges + } +} + +impl MachineDoc +where + S: Copy + Eq + 'static, +{ + /// Returns the exported state descriptor for one generated state id. + pub fn state(&self, id: S) -> Option<&StateDoc> { + self.states.iter().find(|state| state.descriptor.id == id) + } +} + +impl MachineDoc { + /// Returns every state with no incoming edge in the exported topology. + pub fn roots(&self) -> impl Iterator> { + self.states.iter().filter(|state| state.is_root) + } } impl MachineDoc @@ -40,11 +121,13 @@ where where M: MachineIntrospection, { - Self::from_graph(M::GRAPH) + Self::try_from_graph(M::GRAPH) + .expect("Statum emitted an invalid MachineIntrospection::GRAPH") } - /// Exports one machine graph from a static `MachineGraph`. - pub fn from_graph(graph: &'static MachineGraph) -> Self { + /// Exports one externally supplied machine graph after validating it. + pub fn try_from_graph(graph: &'static MachineGraph) -> Result { + validate_graph(graph)?; let incoming = incoming_states(graph); let state_positions = state_positions(graph.states); @@ -66,21 +149,11 @@ where .collect::>(); edges.sort_by(|left, right| compare_edges(&state_positions, left, right)); - Self { + Ok(Self { machine: graph.machine, states, edges, - } - } - - /// Returns the exported state descriptor for one generated state id. - pub fn state(&self, id: S) -> Option<&StateDoc> { - self.states.iter().find(|state| state.descriptor.id == id) - } - - /// Returns every state with no incoming edge in the exported topology. - pub fn roots(&self) -> impl Iterator> { - self.states.iter().filter(|state| state.is_root) + }) } } @@ -100,6 +173,35 @@ pub struct EdgeDoc { pub descriptor: TransitionDescriptor, } +fn validate_graph(graph: &MachineGraph) -> Result<(), MachineDocError> +where + S: Copy + Eq + 'static, + T: Copy + Eq + 'static, +{ + for transition in graph.transitions.iter() { + if graph.state(transition.from).is_none() { + return Err(MachineDocError::MissingSourceState { + machine: graph.machine.rust_type_path, + transition: transition.method_name, + }); + } + + if transition + .to + .iter() + .copied() + .any(|target| graph.state(target).is_none()) + { + return Err(MachineDocError::MissingTargetState { + machine: graph.machine.rust_type_path, + transition: transition.method_name, + }); + } + } + + Ok(()) +} + fn incoming_states(graph: &MachineGraph) -> HashSet where S: Copy + Eq + std::hash::Hash + 'static, diff --git a/statum-graph/src/render.rs b/statum-graph/src/render.rs index fcbc6dd..e2efed3 100644 --- a/statum-graph/src/render.rs +++ b/statum-graph/src/render.rs @@ -9,14 +9,14 @@ where T: 'static, { let state_positions: HashMap = doc - .states + .states() .iter() .enumerate() .map(|(index, state)| (state.descriptor.id, index)) .collect(); let mut lines = vec!["graph TD".to_string()]; - for (index, state) in doc.states.iter().enumerate() { + for (index, state) in doc.states().iter().enumerate() { lines.push(format!( " {}[\"{}\"]", node_id(index), @@ -27,11 +27,11 @@ where )); } - if !doc.edges.is_empty() { + if !doc.edges().is_empty() { lines.push(String::new()); } - for edge in &doc.edges { + for edge in doc.edges() { let from = node_id(state_positions[&edge.descriptor.from]); for target in edge.descriptor.to { let to = node_id(state_positions[target]); diff --git a/statum-graph/tests/export.rs b/statum-graph/tests/export.rs index 8671486..722f3fe 100644 --- a/statum-graph/tests/export.rs +++ b/statum-graph/tests/export.rs @@ -1,6 +1,9 @@ #![allow(dead_code)] -use statum_graph::{render, MachineDoc}; +use statum::{ + MachineDescriptor, MachineGraph, StateDescriptor, TransitionDescriptor, TransitionInventory, +}; +use statum_graph::{render, MachineDoc, MachineDocError}; mod linear { use statum::{machine, state, transition}; @@ -188,9 +191,9 @@ mod macro_generated { fn exports_linear_machine_topology_from_graph() { let doc = MachineDoc::from_machine::>(); - assert_eq!(doc.machine.rust_type_path, "export::linear::Flow"); + assert_eq!(doc.machine().rust_type_path, "export::linear::Flow"); assert_eq!( - doc.states + doc.states() .iter() .map(|state| ( state.descriptor.rust_name, @@ -205,7 +208,7 @@ fn exports_linear_machine_topology_from_graph() { ] ); assert_eq!( - doc.edges + doc.edges() .iter() .map(|edge| edge.descriptor.method_name) .collect::>(), @@ -218,7 +221,7 @@ fn preserves_exact_branch_targets_and_sorts_edges_stably() { let doc = MachineDoc::from_machine::>(); assert_eq!( - doc.edges + doc.edges() .iter() .map(|edge| edge.descriptor.method_name) .collect::>(), @@ -233,7 +236,7 @@ fn preserves_exact_branch_targets_and_sorts_edges_stably() { ); let maybe_decide = doc - .edges + .edges() .iter() .find(|edge| edge.descriptor.method_name == "maybe_decide") .expect("branching transition"); @@ -284,10 +287,103 @@ fn exports_macro_generated_transition_sites() { let doc = MachineDoc::from_machine::>(); assert_eq!( - doc.edges + doc.edges() .iter() .map(|edge| edge.descriptor.method_name) .collect::>(), vec!["enable", "via_macro"] ); } + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +enum InvalidStateId { + Draft, + Published, + Missing, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +enum InvalidTransitionId { + Submit, +} + +static VALID_STATE_DESCRIPTORS: [StateDescriptor; 2] = [ + StateDescriptor { + id: InvalidStateId::Draft, + rust_name: "Draft", + has_data: false, + }, + StateDescriptor { + id: InvalidStateId::Published, + rust_name: "Published", + has_data: false, + }, +]; + +static INVALID_TARGETS: [InvalidStateId; 1] = [InvalidStateId::Missing]; + +static INVALID_SOURCE_TRANSITIONS: [TransitionDescriptor; 1] = + [TransitionDescriptor { + id: InvalidTransitionId::Submit, + method_name: "submit", + from: InvalidStateId::Missing, + to: &INVALID_TARGETS, + }]; + +static INVALID_TARGET_TRANSITIONS: [TransitionDescriptor; 1] = + [TransitionDescriptor { + id: InvalidTransitionId::Submit, + method_name: "submit", + from: InvalidStateId::Draft, + to: &INVALID_TARGETS, + }]; + +fn invalid_source_transitions( +) -> &'static [TransitionDescriptor] { + &INVALID_SOURCE_TRANSITIONS +} + +fn invalid_target_transitions( +) -> &'static [TransitionDescriptor] { + &INVALID_TARGET_TRANSITIONS +} + +static INVALID_SOURCE_GRAPH: MachineGraph = MachineGraph { + machine: MachineDescriptor { + module_path: "tests::invalid_source", + rust_type_path: "tests::invalid_source::Flow", + }, + states: &VALID_STATE_DESCRIPTORS, + transitions: TransitionInventory::new(invalid_source_transitions), +}; + +static INVALID_TARGET_GRAPH: MachineGraph = MachineGraph { + machine: MachineDescriptor { + module_path: "tests::invalid_target", + rust_type_path: "tests::invalid_target::Flow", + }, + states: &VALID_STATE_DESCRIPTORS, + transitions: TransitionInventory::new(invalid_target_transitions), +}; + +#[test] +fn rejects_external_graph_with_missing_transition_source() { + assert_eq!( + MachineDoc::try_from_graph(&INVALID_SOURCE_GRAPH), + Err(MachineDocError::MissingSourceState { + machine: "tests::invalid_source::Flow", + transition: "submit", + }) + ); +} + +#[test] +fn rejects_external_graph_with_missing_transition_target() { + assert_eq!( + MachineDoc::try_from_graph(&INVALID_TARGET_GRAPH), + Err(MachineDocError::MissingTargetState { + machine: "tests::invalid_target::Flow", + transition: "submit", + }) + ); +} From 82d1732cead44923939de1277a61014ec12d2ecc Mon Sep 17 00:00:00 2001 From: Eran Boodnero Date: Wed, 25 Mar 2026 11:03:50 -0700 Subject: [PATCH 4/9] fix(graph): reject duplicate state ids --- statum-graph/src/lib.rs | 25 ++++++++++++++++++++++--- statum-graph/tests/export.rs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/statum-graph/src/lib.rs b/statum-graph/src/lib.rs index bd1a79e..baed980 100644 --- a/statum-graph/src/lib.rs +++ b/statum-graph/src/lib.rs @@ -30,6 +30,11 @@ pub struct MachineDoc { /// Error returned when a `MachineGraph` cannot be exported into a `MachineDoc`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum MachineDocError { + /// One state id appears more than once in the graph's state list. + DuplicateStateId { + machine: &'static str, + state: &'static str, + }, /// One transition source state is not present in the graph's state list. MissingSourceState { machine: &'static str, @@ -45,6 +50,10 @@ pub enum MachineDocError { impl core::fmt::Display for MachineDocError { fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { + Self::DuplicateStateId { machine, state } => write!( + formatter, + "machine graph `{machine}` contains duplicate state id for state `{state}`" + ), Self::MissingSourceState { machine, transition, @@ -175,11 +184,21 @@ pub struct EdgeDoc { fn validate_graph(graph: &MachineGraph) -> Result<(), MachineDocError> where - S: Copy + Eq + 'static, + S: Copy + Eq + std::hash::Hash + 'static, T: Copy + Eq + 'static, { + let mut state_ids = HashSet::with_capacity(graph.states.len()); + for state in graph.states.iter() { + if !state_ids.insert(state.id) { + return Err(MachineDocError::DuplicateStateId { + machine: graph.machine.rust_type_path, + state: state.rust_name, + }); + } + } + for transition in graph.transitions.iter() { - if graph.state(transition.from).is_none() { + if !state_ids.contains(&transition.from) { return Err(MachineDocError::MissingSourceState { machine: graph.machine.rust_type_path, transition: transition.method_name, @@ -190,7 +209,7 @@ where .to .iter() .copied() - .any(|target| graph.state(target).is_none()) + .any(|target| !state_ids.contains(&target)) { return Err(MachineDocError::MissingTargetState { machine: graph.machine.rust_type_path, diff --git a/statum-graph/tests/export.rs b/statum-graph/tests/export.rs index 722f3fe..34d27ea 100644 --- a/statum-graph/tests/export.rs +++ b/statum-graph/tests/export.rs @@ -320,6 +320,19 @@ static VALID_STATE_DESCRIPTORS: [StateDescriptor; 2] = [ }, ]; +static DUPLICATE_STATE_DESCRIPTORS: [StateDescriptor; 2] = [ + StateDescriptor { + id: InvalidStateId::Draft, + rust_name: "Draft", + has_data: false, + }, + StateDescriptor { + id: InvalidStateId::Draft, + rust_name: "DraftDuplicate", + has_data: false, + }, +]; + static INVALID_TARGETS: [InvalidStateId; 1] = [InvalidStateId::Missing]; static INVALID_SOURCE_TRANSITIONS: [TransitionDescriptor; 1] = @@ -366,6 +379,15 @@ static INVALID_TARGET_GRAPH: MachineGraph = transitions: TransitionInventory::new(invalid_target_transitions), }; +static DUPLICATE_STATE_GRAPH: MachineGraph = MachineGraph { + machine: MachineDescriptor { + module_path: "tests::duplicate_state", + rust_type_path: "tests::duplicate_state::Flow", + }, + states: &DUPLICATE_STATE_DESCRIPTORS, + transitions: TransitionInventory::new(invalid_target_transitions), +}; + #[test] fn rejects_external_graph_with_missing_transition_source() { assert_eq!( @@ -387,3 +409,14 @@ fn rejects_external_graph_with_missing_transition_target() { }) ); } + +#[test] +fn rejects_external_graph_with_duplicate_state_ids() { + assert_eq!( + MachineDoc::try_from_graph(&DUPLICATE_STATE_GRAPH), + Err(MachineDocError::DuplicateStateId { + machine: "tests::duplicate_state::Flow", + state: "DraftDuplicate", + }) + ); +} From 4bcfc2fdeb99a572f8e487a8118b43e1b38395fa Mon Sep 17 00:00:00 2001 From: Eran Boodnero Date: Wed, 25 Mar 2026 11:15:50 -0700 Subject: [PATCH 5/9] fix(graph): escape Mermaid edge labels --- statum-graph/src/render.rs | 13 ++++++++++++- statum-graph/tests/export.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/statum-graph/src/render.rs b/statum-graph/src/render.rs index e2efed3..6f621f3 100644 --- a/statum-graph/src/render.rs +++ b/statum-graph/src/render.rs @@ -37,7 +37,7 @@ where let to = node_id(state_positions[target]); lines.push(format!( " {from} -->|{}| {to}", - escape_label(edge.descriptor.method_name) + escape_edge_label(edge.descriptor.method_name) )); } } @@ -63,3 +63,14 @@ fn escape_label(label: &str) -> String { .replace('"', "\\\"") .replace('\n', "\\n") } + +fn escape_edge_label(label: &str) -> String { + label + .replace('&', "&") + .replace('|', "|") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") + .replace('\n', "
") +} diff --git a/statum-graph/tests/export.rs b/statum-graph/tests/export.rs index 34d27ea..c2562b3 100644 --- a/statum-graph/tests/export.rs +++ b/statum-graph/tests/export.rs @@ -334,6 +334,7 @@ static DUPLICATE_STATE_DESCRIPTORS: [StateDescriptor; 2] = [ ]; static INVALID_TARGETS: [InvalidStateId; 1] = [InvalidStateId::Missing]; +static VALID_PUBLISHED_TARGET: [InvalidStateId; 1] = [InvalidStateId::Published]; static INVALID_SOURCE_TRANSITIONS: [TransitionDescriptor; 1] = [TransitionDescriptor { @@ -351,6 +352,14 @@ static INVALID_TARGET_TRANSITIONS: [TransitionDescriptor; 1] = + [TransitionDescriptor { + id: InvalidTransitionId::Submit, + method_name: "submit|review", + from: InvalidStateId::Draft, + to: &VALID_PUBLISHED_TARGET, + }]; + fn invalid_source_transitions( ) -> &'static [TransitionDescriptor] { &INVALID_SOURCE_TRANSITIONS @@ -361,6 +370,11 @@ fn invalid_target_transitions( &INVALID_TARGET_TRANSITIONS } +fn pipe_label_transitions() -> &'static [TransitionDescriptor] +{ + &PIPE_LABEL_TRANSITIONS +} + static INVALID_SOURCE_GRAPH: MachineGraph = MachineGraph { machine: MachineDescriptor { module_path: "tests::invalid_source", @@ -388,6 +402,15 @@ static DUPLICATE_STATE_GRAPH: MachineGraph transitions: TransitionInventory::new(invalid_target_transitions), }; +static PIPE_LABEL_GRAPH: MachineGraph = MachineGraph { + machine: MachineDescriptor { + module_path: "tests::pipe_label", + rust_type_path: "tests::pipe_label::Flow", + }, + states: &VALID_STATE_DESCRIPTORS, + transitions: TransitionInventory::new(pipe_label_transitions), +}; + #[test] fn rejects_external_graph_with_missing_transition_source() { assert_eq!( @@ -420,3 +443,12 @@ fn rejects_external_graph_with_duplicate_state_ids() { }) ); } + +#[test] +fn mermaid_escapes_external_edge_labels() { + let doc = MachineDoc::try_from_graph(&PIPE_LABEL_GRAPH) + .expect("external graph with valid topology should export"); + let mermaid = render::mermaid(&doc); + + assert!(mermaid.contains("-->|submit|review|")); +} From c94d9228f132dd645707b613d9171dbf1f22a847 Mon Sep 17 00:00:00 2001 From: Eran Boodnero Date: Wed, 25 Mar 2026 11:20:26 -0700 Subject: [PATCH 6/9] fix(graph): reject ambiguous external topology --- statum-graph/src/lib.rs | 63 +++++++++++++++++++++++----- statum-graph/tests/export.rs | 81 ++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 11 deletions(-) diff --git a/statum-graph/src/lib.rs b/statum-graph/src/lib.rs index baed980..9a5ba02 100644 --- a/statum-graph/src/lib.rs +++ b/statum-graph/src/lib.rs @@ -35,6 +35,11 @@ pub enum MachineDocError { machine: &'static str, state: &'static str, }, + /// One transition id appears more than once in the graph's transition list. + DuplicateTransitionId { + machine: &'static str, + transition: &'static str, + }, /// One transition source state is not present in the graph's state list. MissingSourceState { machine: &'static str, @@ -45,6 +50,12 @@ pub enum MachineDocError { machine: &'static str, transition: &'static str, }, + /// One transition lists the same target state more than once. + DuplicateTargetState { + machine: &'static str, + transition: &'static str, + state: &'static str, + }, } impl core::fmt::Display for MachineDocError { @@ -54,6 +65,13 @@ impl core::fmt::Display for MachineDocError { formatter, "machine graph `{machine}` contains duplicate state id for state `{state}`" ), + Self::DuplicateTransitionId { + machine, + transition, + } => write!( + formatter, + "machine graph `{machine}` contains duplicate transition id for transition `{transition}`" + ), Self::MissingSourceState { machine, transition, @@ -68,6 +86,14 @@ impl core::fmt::Display for MachineDocError { formatter, "machine graph `{machine}` contains transition `{transition}` whose target state is missing from the state list" ), + Self::DuplicateTargetState { + machine, + transition, + state, + } => write!( + formatter, + "machine graph `{machine}` contains transition `{transition}` with duplicate target state `{state}`" + ), } } } @@ -187,9 +213,9 @@ where S: Copy + Eq + std::hash::Hash + 'static, T: Copy + Eq + 'static, { - let mut state_ids = HashSet::with_capacity(graph.states.len()); + let mut state_names = HashMap::with_capacity(graph.states.len()); for state in graph.states.iter() { - if !state_ids.insert(state.id) { + if state_names.insert(state.id, state.rust_name).is_some() { return Err(MachineDocError::DuplicateStateId { machine: graph.machine.rust_type_path, state: state.rust_name, @@ -197,25 +223,40 @@ where } } + let mut transition_ids = Vec::with_capacity(graph.transitions.len()); for transition in graph.transitions.iter() { - if !state_ids.contains(&transition.from) { - return Err(MachineDocError::MissingSourceState { + if transition_ids.contains(&transition.id) { + return Err(MachineDocError::DuplicateTransitionId { machine: graph.machine.rust_type_path, transition: transition.method_name, }); } + transition_ids.push(transition.id); - if transition - .to - .iter() - .copied() - .any(|target| !state_ids.contains(&target)) - { - return Err(MachineDocError::MissingTargetState { + if !state_names.contains_key(&transition.from) { + return Err(MachineDocError::MissingSourceState { machine: graph.machine.rust_type_path, transition: transition.method_name, }); } + + let mut seen_targets = HashSet::with_capacity(transition.to.len()); + for target in transition.to.iter().copied() { + let Some(state_name) = state_names.get(&target).copied() else { + return Err(MachineDocError::MissingTargetState { + machine: graph.machine.rust_type_path, + transition: transition.method_name, + }); + }; + + if !seen_targets.insert(target) { + return Err(MachineDocError::DuplicateTargetState { + machine: graph.machine.rust_type_path, + transition: transition.method_name, + state: state_name, + }); + } + } } Ok(()) diff --git a/statum-graph/tests/export.rs b/statum-graph/tests/export.rs index c2562b3..33ee950 100644 --- a/statum-graph/tests/export.rs +++ b/statum-graph/tests/export.rs @@ -305,6 +305,7 @@ enum InvalidStateId { #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] enum InvalidTransitionId { Submit, + Publish, } static VALID_STATE_DESCRIPTORS: [StateDescriptor; 2] = [ @@ -335,6 +336,8 @@ static DUPLICATE_STATE_DESCRIPTORS: [StateDescriptor; 2] = [ static INVALID_TARGETS: [InvalidStateId; 1] = [InvalidStateId::Missing]; static VALID_PUBLISHED_TARGET: [InvalidStateId; 1] = [InvalidStateId::Published]; +static DUPLICATE_PUBLISHED_TARGETS: [InvalidStateId; 2] = + [InvalidStateId::Published, InvalidStateId::Published]; static INVALID_SOURCE_TRANSITIONS: [TransitionDescriptor; 1] = [TransitionDescriptor { @@ -360,6 +363,32 @@ static PIPE_LABEL_TRANSITIONS: [TransitionDescriptor; 2] = [ + TransitionDescriptor { + id: InvalidTransitionId::Submit, + method_name: "submit", + from: InvalidStateId::Draft, + to: &VALID_PUBLISHED_TARGET, + }, + TransitionDescriptor { + id: InvalidTransitionId::Submit, + method_name: "publish", + from: InvalidStateId::Published, + to: &VALID_PUBLISHED_TARGET, + }, +]; + +static DUPLICATE_TARGET_TRANSITIONS: [TransitionDescriptor; + 1] = [TransitionDescriptor { + id: InvalidTransitionId::Publish, + method_name: "branch", + from: InvalidStateId::Draft, + to: &DUPLICATE_PUBLISHED_TARGETS, +}]; + fn invalid_source_transitions( ) -> &'static [TransitionDescriptor] { &INVALID_SOURCE_TRANSITIONS @@ -375,6 +404,16 @@ fn pipe_label_transitions() -> &'static [TransitionDescriptor &'static [TransitionDescriptor] { + &DUPLICATE_TRANSITION_ID_TRANSITIONS +} + +fn duplicate_target_transitions( +) -> &'static [TransitionDescriptor] { + &DUPLICATE_TARGET_TRANSITIONS +} + static INVALID_SOURCE_GRAPH: MachineGraph = MachineGraph { machine: MachineDescriptor { module_path: "tests::invalid_source", @@ -411,6 +450,25 @@ static PIPE_LABEL_GRAPH: MachineGraph = Mac transitions: TransitionInventory::new(pipe_label_transitions), }; +static DUPLICATE_TRANSITION_ID_GRAPH: MachineGraph = + MachineGraph { + machine: MachineDescriptor { + module_path: "tests::duplicate_transition_id", + rust_type_path: "tests::duplicate_transition_id::Flow", + }, + states: &VALID_STATE_DESCRIPTORS, + transitions: TransitionInventory::new(duplicate_transition_id_transitions), + }; + +static DUPLICATE_TARGET_GRAPH: MachineGraph = MachineGraph { + machine: MachineDescriptor { + module_path: "tests::duplicate_target", + rust_type_path: "tests::duplicate_target::Flow", + }, + states: &VALID_STATE_DESCRIPTORS, + transitions: TransitionInventory::new(duplicate_target_transitions), +}; + #[test] fn rejects_external_graph_with_missing_transition_source() { assert_eq!( @@ -452,3 +510,26 @@ fn mermaid_escapes_external_edge_labels() { assert!(mermaid.contains("-->|submit|review|")); } + +#[test] +fn rejects_external_graph_with_duplicate_transition_ids() { + assert_eq!( + MachineDoc::try_from_graph(&DUPLICATE_TRANSITION_ID_GRAPH), + Err(MachineDocError::DuplicateTransitionId { + machine: "tests::duplicate_transition_id::Flow", + transition: "publish", + }) + ); +} + +#[test] +fn rejects_external_graph_with_duplicate_target_states() { + assert_eq!( + MachineDoc::try_from_graph(&DUPLICATE_TARGET_GRAPH), + Err(MachineDocError::DuplicateTargetState { + machine: "tests::duplicate_target::Flow", + transition: "branch", + state: "Published", + }) + ); +} From 8c40b9f34980b263925f9f9d5f53811eca798cbb Mon Sep 17 00:00:00 2001 From: Eran Boodnero Date: Wed, 25 Mar 2026 11:24:04 -0700 Subject: [PATCH 7/9] fix(graph): reject duplicate transition sites --- statum-graph/src/lib.rs | 24 +++++++++++++++++++ statum-graph/tests/export.rs | 46 ++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/statum-graph/src/lib.rs b/statum-graph/src/lib.rs index 9a5ba02..7999551 100644 --- a/statum-graph/src/lib.rs +++ b/statum-graph/src/lib.rs @@ -40,6 +40,12 @@ pub enum MachineDocError { machine: &'static str, transition: &'static str, }, + /// One source state declares the same transition method name more than once. + DuplicateTransitionSite { + machine: &'static str, + state: &'static str, + transition: &'static str, + }, /// One transition source state is not present in the graph's state list. MissingSourceState { machine: &'static str, @@ -72,6 +78,14 @@ impl core::fmt::Display for MachineDocError { formatter, "machine graph `{machine}` contains duplicate transition id for transition `{transition}`" ), + Self::DuplicateTransitionSite { + machine, + state, + transition, + } => write!( + formatter, + "machine graph `{machine}` contains duplicate transition site `{state}::{transition}`" + ), Self::MissingSourceState { machine, transition, @@ -223,6 +237,7 @@ where } } + let mut transition_sites = HashSet::with_capacity(graph.transitions.len()); let mut transition_ids = Vec::with_capacity(graph.transitions.len()); for transition in graph.transitions.iter() { if transition_ids.contains(&transition.id) { @@ -240,6 +255,15 @@ where }); } + let from_state_name = state_names[&transition.from]; + if !transition_sites.insert((transition.from, transition.method_name)) { + return Err(MachineDocError::DuplicateTransitionSite { + machine: graph.machine.rust_type_path, + state: from_state_name, + transition: transition.method_name, + }); + } + let mut seen_targets = HashSet::with_capacity(transition.to.len()); for target in transition.to.iter().copied() { let Some(state_name) = state_names.get(&target).copied() else { diff --git a/statum-graph/tests/export.rs b/statum-graph/tests/export.rs index 33ee950..dabbf5e 100644 --- a/statum-graph/tests/export.rs +++ b/statum-graph/tests/export.rs @@ -306,6 +306,7 @@ enum InvalidStateId { enum InvalidTransitionId { Submit, Publish, + Archive, } static VALID_STATE_DESCRIPTORS: [StateDescriptor; 2] = [ @@ -389,6 +390,24 @@ static DUPLICATE_TARGET_TRANSITIONS: [TransitionDescriptor; 2] = [ + TransitionDescriptor { + id: InvalidTransitionId::Submit, + method_name: "review", + from: InvalidStateId::Draft, + to: &VALID_PUBLISHED_TARGET, + }, + TransitionDescriptor { + id: InvalidTransitionId::Archive, + method_name: "review", + from: InvalidStateId::Draft, + to: &VALID_PUBLISHED_TARGET, + }, +]; + fn invalid_source_transitions( ) -> &'static [TransitionDescriptor] { &INVALID_SOURCE_TRANSITIONS @@ -414,6 +433,11 @@ fn duplicate_target_transitions( &DUPLICATE_TARGET_TRANSITIONS } +fn duplicate_transition_site_transitions( +) -> &'static [TransitionDescriptor] { + &DUPLICATE_TRANSITION_SITE_TRANSITIONS +} + static INVALID_SOURCE_GRAPH: MachineGraph = MachineGraph { machine: MachineDescriptor { module_path: "tests::invalid_source", @@ -469,6 +493,16 @@ static DUPLICATE_TARGET_GRAPH: MachineGraph transitions: TransitionInventory::new(duplicate_target_transitions), }; +static DUPLICATE_TRANSITION_SITE_GRAPH: MachineGraph = + MachineGraph { + machine: MachineDescriptor { + module_path: "tests::duplicate_transition_site", + rust_type_path: "tests::duplicate_transition_site::Flow", + }, + states: &VALID_STATE_DESCRIPTORS, + transitions: TransitionInventory::new(duplicate_transition_site_transitions), + }; + #[test] fn rejects_external_graph_with_missing_transition_source() { assert_eq!( @@ -533,3 +567,15 @@ fn rejects_external_graph_with_duplicate_target_states() { }) ); } + +#[test] +fn rejects_external_graph_with_duplicate_transition_sites() { + assert_eq!( + MachineDoc::try_from_graph(&DUPLICATE_TRANSITION_SITE_GRAPH), + Err(MachineDocError::DuplicateTransitionSite { + machine: "tests::duplicate_transition_site::Flow", + state: "Draft", + transition: "review", + }) + ); +} From 0703832234cca9298e0da7fe3355808730bca906 Mon Sep 17 00:00:00 2001 From: Eran Boodnero Date: Wed, 25 Mar 2026 11:27:55 -0700 Subject: [PATCH 8/9] fix(graph): snapshot external transition inventory --- statum-graph/src/lib.rs | 40 +++++++++++++------------ statum-graph/tests/export.rs | 57 ++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 18 deletions(-) diff --git a/statum-graph/src/lib.rs b/statum-graph/src/lib.rs index 7999551..b1ee60e 100644 --- a/statum-graph/src/lib.rs +++ b/statum-graph/src/lib.rs @@ -176,8 +176,9 @@ where /// Exports one externally supplied machine graph after validating it. pub fn try_from_graph(graph: &'static MachineGraph) -> Result { - validate_graph(graph)?; - let incoming = incoming_states(graph); + let transitions = graph.transitions.as_slice(); + validate_graph(graph.machine, graph.states, transitions)?; + let incoming = incoming_states(transitions); let state_positions = state_positions(graph.states); let states = graph @@ -190,8 +191,7 @@ where }) .collect(); - let mut edges = graph - .transitions + let mut edges = transitions .iter() .copied() .map(|descriptor| EdgeDoc { descriptor }) @@ -222,27 +222,31 @@ pub struct EdgeDoc { pub descriptor: TransitionDescriptor, } -fn validate_graph(graph: &MachineGraph) -> Result<(), MachineDocError> +fn validate_graph( + machine: MachineDescriptor, + states: &[StateDescriptor], + transitions: &[TransitionDescriptor], +) -> Result<(), MachineDocError> where S: Copy + Eq + std::hash::Hash + 'static, T: Copy + Eq + 'static, { - let mut state_names = HashMap::with_capacity(graph.states.len()); - for state in graph.states.iter() { + let mut state_names = HashMap::with_capacity(states.len()); + for state in states.iter() { if state_names.insert(state.id, state.rust_name).is_some() { return Err(MachineDocError::DuplicateStateId { - machine: graph.machine.rust_type_path, + machine: machine.rust_type_path, state: state.rust_name, }); } } - let mut transition_sites = HashSet::with_capacity(graph.transitions.len()); - let mut transition_ids = Vec::with_capacity(graph.transitions.len()); - for transition in graph.transitions.iter() { + let mut transition_sites = HashSet::with_capacity(transitions.len()); + let mut transition_ids = Vec::with_capacity(transitions.len()); + for transition in transitions.iter() { if transition_ids.contains(&transition.id) { return Err(MachineDocError::DuplicateTransitionId { - machine: graph.machine.rust_type_path, + machine: machine.rust_type_path, transition: transition.method_name, }); } @@ -250,7 +254,7 @@ where if !state_names.contains_key(&transition.from) { return Err(MachineDocError::MissingSourceState { - machine: graph.machine.rust_type_path, + machine: machine.rust_type_path, transition: transition.method_name, }); } @@ -258,7 +262,7 @@ where let from_state_name = state_names[&transition.from]; if !transition_sites.insert((transition.from, transition.method_name)) { return Err(MachineDocError::DuplicateTransitionSite { - machine: graph.machine.rust_type_path, + machine: machine.rust_type_path, state: from_state_name, transition: transition.method_name, }); @@ -268,14 +272,14 @@ where for target in transition.to.iter().copied() { let Some(state_name) = state_names.get(&target).copied() else { return Err(MachineDocError::MissingTargetState { - machine: graph.machine.rust_type_path, + machine: machine.rust_type_path, transition: transition.method_name, }); }; if !seen_targets.insert(target) { return Err(MachineDocError::DuplicateTargetState { - machine: graph.machine.rust_type_path, + machine: machine.rust_type_path, transition: transition.method_name, state: state_name, }); @@ -286,13 +290,13 @@ where Ok(()) } -fn incoming_states(graph: &MachineGraph) -> HashSet +fn incoming_states(transitions: &[TransitionDescriptor]) -> HashSet where S: Copy + Eq + std::hash::Hash + 'static, T: Copy + Eq + 'static, { let mut incoming = HashSet::new(); - for transition in graph.transitions.iter() { + for transition in transitions.iter() { for target in transition.to.iter().copied() { incoming.insert(target); } diff --git a/statum-graph/tests/export.rs b/statum-graph/tests/export.rs index dabbf5e..bbbb3d2 100644 --- a/statum-graph/tests/export.rs +++ b/statum-graph/tests/export.rs @@ -1,5 +1,8 @@ #![allow(dead_code)] +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Mutex; + use statum::{ MachineDescriptor, MachineGraph, StateDescriptor, TransitionDescriptor, TransitionInventory, }; @@ -503,6 +506,37 @@ static DUPLICATE_TRANSITION_SITE_GRAPH: MachineGraph = Mutex::new(()); +static FLAKY_TRANSITION_CALLS: AtomicUsize = AtomicUsize::new(0); + +static FLAKY_VALID_TRANSITIONS: [TransitionDescriptor; 1] = + [TransitionDescriptor { + id: InvalidTransitionId::Submit, + method_name: "submit", + from: InvalidStateId::Draft, + to: &VALID_PUBLISHED_TARGET, + }]; + +static EMPTY_TRANSITIONS: [TransitionDescriptor; 0] = []; + +fn flaky_transitions() -> &'static [TransitionDescriptor] { + let call = FLAKY_TRANSITION_CALLS.fetch_add(1, Ordering::SeqCst); + if call.is_multiple_of(2) { + &FLAKY_VALID_TRANSITIONS + } else { + &EMPTY_TRANSITIONS + } +} + +static FLAKY_GRAPH: MachineGraph = MachineGraph { + machine: MachineDescriptor { + module_path: "tests::flaky_inventory", + rust_type_path: "tests::flaky_inventory::Flow", + }, + states: &VALID_STATE_DESCRIPTORS, + transitions: TransitionInventory::new(flaky_transitions), +}; + #[test] fn rejects_external_graph_with_missing_transition_source() { assert_eq!( @@ -579,3 +613,26 @@ fn rejects_external_graph_with_duplicate_transition_sites() { }) ); } + +#[test] +fn snapshots_external_transition_inventory_once_per_export() { + let _guard = FLAKY_INVENTORY_LOCK.lock().expect("flaky inventory lock"); + FLAKY_TRANSITION_CALLS.store(0, Ordering::SeqCst); + + let doc = MachineDoc::try_from_graph(&FLAKY_GRAPH) + .expect("flaky inventory should still export from one consistent snapshot"); + + assert_eq!( + doc.roots() + .map(|state| state.descriptor.rust_name) + .collect::>(), + vec!["Draft"] + ); + assert_eq!( + doc.edges() + .iter() + .map(|edge| edge.descriptor.method_name) + .collect::>(), + vec!["submit"] + ); +} From af3dacc58b084877cf79716fe9e823c1c71666be Mon Sep 17 00:00:00 2001 From: Eran Boodnero Date: Wed, 25 Mar 2026 11:31:50 -0700 Subject: [PATCH 9/9] fix(graph): reject empty external graph shapes --- statum-graph/src/lib.rs | 31 +++++++++++++++++++++ statum-graph/tests/export.rs | 54 ++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/statum-graph/src/lib.rs b/statum-graph/src/lib.rs index b1ee60e..43e1a34 100644 --- a/statum-graph/src/lib.rs +++ b/statum-graph/src/lib.rs @@ -30,6 +30,8 @@ pub struct MachineDoc { /// Error returned when a `MachineGraph` cannot be exported into a `MachineDoc`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum MachineDocError { + /// The graph's state list is empty. + EmptyStateList { machine: &'static str }, /// One state id appears more than once in the graph's state list. DuplicateStateId { machine: &'static str, @@ -56,6 +58,11 @@ pub enum MachineDocError { machine: &'static str, transition: &'static str, }, + /// One transition site declares no legal target states. + EmptyTargetSet { + machine: &'static str, + transition: &'static str, + }, /// One transition lists the same target state more than once. DuplicateTargetState { machine: &'static str, @@ -67,6 +74,10 @@ pub enum MachineDocError { impl core::fmt::Display for MachineDocError { fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { + Self::EmptyStateList { machine } => write!( + formatter, + "machine graph `{machine}` contains no states" + ), Self::DuplicateStateId { machine, state } => write!( formatter, "machine graph `{machine}` contains duplicate state id for state `{state}`" @@ -100,6 +111,13 @@ impl core::fmt::Display for MachineDocError { formatter, "machine graph `{machine}` contains transition `{transition}` whose target state is missing from the state list" ), + Self::EmptyTargetSet { + machine, + transition, + } => write!( + formatter, + "machine graph `{machine}` contains transition `{transition}` with no target states" + ), Self::DuplicateTargetState { machine, transition, @@ -231,6 +249,12 @@ where S: Copy + Eq + std::hash::Hash + 'static, T: Copy + Eq + 'static, { + if states.is_empty() { + return Err(MachineDocError::EmptyStateList { + machine: machine.rust_type_path, + }); + } + let mut state_names = HashMap::with_capacity(states.len()); for state in states.iter() { if state_names.insert(state.id, state.rust_name).is_some() { @@ -268,6 +292,13 @@ where }); } + if transition.to.is_empty() { + return Err(MachineDocError::EmptyTargetSet { + machine: machine.rust_type_path, + transition: transition.method_name, + }); + } + let mut seen_targets = HashSet::with_capacity(transition.to.len()); for target in transition.to.iter().copied() { let Some(state_name) = state_names.get(&target).copied() else { diff --git a/statum-graph/tests/export.rs b/statum-graph/tests/export.rs index bbbb3d2..c1dff3b 100644 --- a/statum-graph/tests/export.rs +++ b/statum-graph/tests/export.rs @@ -325,6 +325,9 @@ static VALID_STATE_DESCRIPTORS: [StateDescriptor; 2] = [ }, ]; +static EMPTY_STATE_DESCRIPTORS: [StateDescriptor; 0] = []; +static EMPTY_TARGET_IDS: [InvalidStateId; 0] = []; + static DUPLICATE_STATE_DESCRIPTORS: [StateDescriptor; 2] = [ StateDescriptor { id: InvalidStateId::Draft, @@ -518,6 +521,13 @@ static FLAKY_VALID_TRANSITIONS: [TransitionDescriptor; 0] = []; +static EMPTY_TARGET_TRANSITIONS: [TransitionDescriptor; 1] = + [TransitionDescriptor { + id: InvalidTransitionId::Submit, + method_name: "submit", + from: InvalidStateId::Draft, + to: &EMPTY_TARGET_IDS, + }]; fn flaky_transitions() -> &'static [TransitionDescriptor] { let call = FLAKY_TRANSITION_CALLS.fetch_add(1, Ordering::SeqCst); @@ -528,6 +538,11 @@ fn flaky_transitions() -> &'static [TransitionDescriptor &'static [TransitionDescriptor] +{ + &EMPTY_TARGET_TRANSITIONS +} + static FLAKY_GRAPH: MachineGraph = MachineGraph { machine: MachineDescriptor { module_path: "tests::flaky_inventory", @@ -537,6 +552,24 @@ static FLAKY_GRAPH: MachineGraph = MachineG transitions: TransitionInventory::new(flaky_transitions), }; +static EMPTY_STATE_GRAPH: MachineGraph = MachineGraph { + machine: MachineDescriptor { + module_path: "tests::empty_state_list", + rust_type_path: "tests::empty_state_list::Flow", + }, + states: &EMPTY_STATE_DESCRIPTORS, + transitions: TransitionInventory::new(|| &EMPTY_TRANSITIONS), +}; + +static EMPTY_TARGET_GRAPH: MachineGraph = MachineGraph { + machine: MachineDescriptor { + module_path: "tests::empty_target_set", + rust_type_path: "tests::empty_target_set::Flow", + }, + states: &VALID_STATE_DESCRIPTORS, + transitions: TransitionInventory::new(empty_target_transitions), +}; + #[test] fn rejects_external_graph_with_missing_transition_source() { assert_eq!( @@ -548,6 +581,16 @@ fn rejects_external_graph_with_missing_transition_source() { ); } +#[test] +fn rejects_external_graph_with_empty_state_list() { + assert_eq!( + MachineDoc::try_from_graph(&EMPTY_STATE_GRAPH), + Err(MachineDocError::EmptyStateList { + machine: "tests::empty_state_list::Flow", + }) + ); +} + #[test] fn rejects_external_graph_with_missing_transition_target() { assert_eq!( @@ -570,6 +613,17 @@ fn rejects_external_graph_with_duplicate_state_ids() { ); } +#[test] +fn rejects_external_graph_with_empty_target_set() { + assert_eq!( + MachineDoc::try_from_graph(&EMPTY_TARGET_GRAPH), + Err(MachineDocError::EmptyTargetSet { + machine: "tests::empty_target_set::Flow", + transition: "submit", + }) + ); +} + #[test] fn mermaid_escapes_external_edge_labels() { let doc = MachineDoc::try_from_graph(&PIPE_LABEL_GRAPH)