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..43e1a34 --- /dev/null +++ b/statum-graph/src/lib.rs @@ -0,0 +1,381 @@ +//! 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 { + 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 { + /// 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, + state: &'static str, + }, + /// One transition id appears more than once in the graph's transition list. + DuplicateTransitionId { + 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, + transition: &'static str, + }, + /// One transition target state is not present in the graph's state list. + MissingTargetState { + 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, + transition: &'static str, + state: &'static str, + }, +} + +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}`" + ), + Self::DuplicateTransitionId { + machine, + transition, + } => write!( + 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, + } => 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" + ), + Self::EmptyTargetSet { + machine, + transition, + } => write!( + formatter, + "machine graph `{machine}` contains transition `{transition}` with no target states" + ), + Self::DuplicateTargetState { + machine, + transition, + state, + } => write!( + formatter, + "machine graph `{machine}` contains transition `{transition}` with duplicate target state `{state}`" + ), + } + } +} + +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 fn machine(&self) -> MachineDescriptor { + self.machine + } + + /// Exported states in the same order as the underlying static graph. + pub fn states(&self) -> &[StateDoc] { + &self.states + } + + /// Exported transition sites sorted stably for deterministic renderers. + 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 +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::try_from_graph(M::GRAPH) + .expect("Statum emitted an invalid MachineIntrospection::GRAPH") + } + + /// Exports one externally supplied machine graph after validating it. + pub fn try_from_graph(graph: &'static MachineGraph) -> Result { + 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 + .states + .iter() + .copied() + .map(|descriptor| StateDoc { + descriptor, + is_root: !incoming.contains(&descriptor.id), + }) + .collect(); + + let mut edges = transitions + .iter() + .copied() + .map(|descriptor| EdgeDoc { descriptor }) + .collect::>(); + edges.sort_by(|left, right| compare_edges(&state_positions, left, right)); + + Ok(Self { + machine: graph.machine, + states, + edges, + }) + } +} + +/// 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 validate_graph( + machine: MachineDescriptor, + states: &[StateDescriptor], + transitions: &[TransitionDescriptor], +) -> Result<(), MachineDocError> +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() { + return Err(MachineDocError::DuplicateStateId { + machine: machine.rust_type_path, + state: state.rust_name, + }); + } + } + + 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: machine.rust_type_path, + transition: transition.method_name, + }); + } + transition_ids.push(transition.id); + + if !state_names.contains_key(&transition.from) { + return Err(MachineDocError::MissingSourceState { + machine: machine.rust_type_path, + transition: transition.method_name, + }); + } + + let from_state_name = state_names[&transition.from]; + if !transition_sites.insert((transition.from, transition.method_name)) { + return Err(MachineDocError::DuplicateTransitionSite { + machine: machine.rust_type_path, + state: from_state_name, + transition: transition.method_name, + }); + } + + 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 { + return Err(MachineDocError::MissingTargetState { + machine: machine.rust_type_path, + transition: transition.method_name, + }); + }; + + if !seen_targets.insert(target) { + return Err(MachineDocError::DuplicateTargetState { + machine: machine.rust_type_path, + transition: transition.method_name, + state: state_name, + }); + } + } + } + + Ok(()) +} + +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 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..6f621f3 --- /dev/null +++ b/statum-graph/src/render.rs @@ -0,0 +1,76 @@ +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 { + let to = node_id(state_positions[target]); + lines.push(format!( + " {from} -->|{}| {to}", + escape_edge_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") +} + +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 new file mode 100644 index 0000000..c1dff3b --- /dev/null +++ b/statum-graph/tests/export.rs @@ -0,0 +1,692 @@ +#![allow(dead_code)] + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Mutex; + +use statum::{ + MachineDescriptor, MachineGraph, StateDescriptor, TransitionDescriptor, TransitionInventory, +}; +use statum_graph::{render, MachineDoc, MachineDocError}; + +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"] + ); +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +enum InvalidStateId { + Draft, + Published, + Missing, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +enum InvalidTransitionId { + Submit, + Publish, + Archive, +} + +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 EMPTY_STATE_DESCRIPTORS: [StateDescriptor; 0] = []; +static EMPTY_TARGET_IDS: [InvalidStateId; 0] = []; + +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 VALID_PUBLISHED_TARGET: [InvalidStateId; 1] = [InvalidStateId::Published]; +static DUPLICATE_PUBLISHED_TARGETS: [InvalidStateId; 2] = + [InvalidStateId::Published, InvalidStateId::Published]; + +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, + }]; + +static PIPE_LABEL_TRANSITIONS: [TransitionDescriptor; 1] = + [TransitionDescriptor { + id: InvalidTransitionId::Submit, + method_name: "submit|review", + from: InvalidStateId::Draft, + to: &VALID_PUBLISHED_TARGET, + }]; + +static DUPLICATE_TRANSITION_ID_TRANSITIONS: [TransitionDescriptor< + InvalidStateId, + InvalidTransitionId, +>; 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, +}]; + +static DUPLICATE_TRANSITION_SITE_TRANSITIONS: [TransitionDescriptor< + InvalidStateId, + InvalidTransitionId, +>; 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 +} + +fn invalid_target_transitions( +) -> &'static [TransitionDescriptor] { + &INVALID_TARGET_TRANSITIONS +} + +fn pipe_label_transitions() -> &'static [TransitionDescriptor] +{ + &PIPE_LABEL_TRANSITIONS +} + +fn duplicate_transition_id_transitions( +) -> &'static [TransitionDescriptor] { + &DUPLICATE_TRANSITION_ID_TRANSITIONS +} + +fn duplicate_target_transitions( +) -> &'static [TransitionDescriptor] { + &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", + 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), +}; + +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), +}; + +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), +}; + +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), +}; + +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), + }; + +static FLAKY_INVENTORY_LOCK: Mutex<()> = 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] = []; +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); + if call.is_multiple_of(2) { + &FLAKY_VALID_TRANSITIONS + } else { + &EMPTY_TRANSITIONS + } +} + +fn empty_target_transitions() -> &'static [TransitionDescriptor] +{ + &EMPTY_TARGET_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), +}; + +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!( + MachineDoc::try_from_graph(&INVALID_SOURCE_GRAPH), + Err(MachineDocError::MissingSourceState { + machine: "tests::invalid_source::Flow", + transition: "submit", + }) + ); +} + +#[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!( + MachineDoc::try_from_graph(&INVALID_TARGET_GRAPH), + Err(MachineDocError::MissingTargetState { + machine: "tests::invalid_target::Flow", + transition: "submit", + }) + ); +} + +#[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", + }) + ); +} + +#[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) + .expect("external graph with valid topology should export"); + let mermaid = render::mermaid(&doc); + + 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", + }) + ); +} + +#[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", + }) + ); +} + +#[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"] + ); +} 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: