Skip to main content

statum_graph/
lib.rs

1//! Static graph export built directly from `statum::MachineIntrospection::GRAPH`.
2//!
3//! This crate is authoritative only for machine-local topology:
4//! machine identity, states, transition sites, exact legal targets, and
5//! graph roots derivable from the static graph itself.
6//!
7//! For linked-build codebase export, use [`codebase::CodebaseDoc`]. That
8//! surface combines every linked compiled machine family, declared
9//! validator-entry surfaces emitted by compiled `#[validators]` impls, direct
10//! construction availability per state, legacy direct payload links, and exact
11//! static relations inferred from supported type syntax plus nominal
12//! `#[machine_ref(...)]` declarations. When the source machine is marked
13//! `role = composition` and the exact relation comes from direct child-machine
14//! type syntax, the codebase export also classifies it as composition-owned
15//! direct-child semantics for renderer and inspector projection. Validator
16//! node labels use the impl self
17//! type as written in source, so they are display syntax rather than canonical
18//! Rust type identity. Method-level `#[cfg]` and `#[cfg_attr]` on validator
19//! methods are rejected at the macro layer. `include!()`-generated validator
20//! impls are also rejected. The linked codebase surface also carries source
21//! rustdoc separately as `docs` on machines, states, transitions, and
22//! validator-entry surfaces.
23//!
24//! Use [`MachineDoc::from_machine`] for Statum-generated machine families and
25//! [`MachineDoc::try_from_graph`] when you need to validate an externally
26//! supplied [`MachineGraph`] before rendering or traversal.
27//!
28//! This crate does not model orchestration order across machines or
29//! runtime-selected branches for one run. Optional presentation metadata may
30//! be joined onto the validated machine graph for renderer output, but it does
31//! not change the authoritative structural surface. Use
32//! `#[present(description = ...)]` for concise renderer copy and ordinary outer
33//! rustdoc comments (`///`) for fuller codebase/inspector detail.
34
35use std::collections::{HashMap, HashSet};
36
37use statum::{
38    MachineDescriptor, MachineGraph, MachineIntrospection, StateDescriptor, TransitionDescriptor,
39};
40
41pub mod codebase;
42mod export;
43pub mod render;
44
45pub use codebase::{
46    CodebaseAttestedRoute, CodebaseDoc, CodebaseDocError, CodebaseLink, CodebaseMachine,
47    CodebaseMachineRelationGroup, CodebaseMachineRelationGroupSemantic, CodebaseRelation,
48    CodebaseRelationBasis, CodebaseRelationCount, CodebaseRelationDetail, CodebaseRelationKind,
49    CodebaseRelationSemantic, CodebaseRelationSource, CodebaseState, CodebaseTransition,
50    CodebaseValidatorEntry,
51};
52pub use export::{
53    ExportDoc, ExportDocError, ExportMachine, ExportSource, ExportState, ExportTransition,
54};
55
56/// Static machine graph exported directly from `MachineIntrospection::GRAPH`.
57///
58/// This type is authoritative only for machine-local topology:
59/// states, transition sites, exact legal targets, and graph roots derivable
60/// from the static graph itself.
61#[derive(Clone, Debug, PartialEq, Eq)]
62pub struct MachineDoc<S: 'static, T: 'static> {
63    machine: MachineDescriptor,
64    states: Vec<StateDoc<S>>,
65    edges: Vec<EdgeDoc<S, T>>,
66}
67
68/// Error returned when a `MachineGraph` cannot be exported into a `MachineDoc`.
69#[derive(Clone, Copy, Debug, PartialEq, Eq)]
70pub enum MachineDocError {
71    /// The graph's state list is empty.
72    EmptyStateList { machine: &'static str },
73    /// One state id appears more than once in the graph's state list.
74    DuplicateStateId {
75        machine: &'static str,
76        state: &'static str,
77    },
78    /// One transition id appears more than once in the graph's transition list.
79    DuplicateTransitionId {
80        machine: &'static str,
81        transition: &'static str,
82    },
83    /// One source state declares the same transition method name more than once.
84    DuplicateTransitionSite {
85        machine: &'static str,
86        state: &'static str,
87        transition: &'static str,
88    },
89    /// One transition source state is not present in the graph's state list.
90    MissingSourceState {
91        machine: &'static str,
92        transition: &'static str,
93    },
94    /// One transition target state is not present in the graph's state list.
95    MissingTargetState {
96        machine: &'static str,
97        transition: &'static str,
98    },
99    /// One transition site declares no legal target states.
100    EmptyTargetSet {
101        machine: &'static str,
102        transition: &'static str,
103    },
104    /// One transition lists the same target state more than once.
105    DuplicateTargetState {
106        machine: &'static str,
107        transition: &'static str,
108        state: &'static str,
109    },
110}
111
112impl core::fmt::Display for MachineDocError {
113    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
114        match self {
115            Self::EmptyStateList { machine } => write!(
116                formatter,
117                "machine graph `{machine}` contains no states"
118            ),
119            Self::DuplicateStateId { machine, state } => write!(
120                formatter,
121                "machine graph `{machine}` contains duplicate state id for state `{state}`"
122            ),
123            Self::DuplicateTransitionId {
124                machine,
125                transition,
126            } => write!(
127                formatter,
128                "machine graph `{machine}` contains duplicate transition id for transition `{transition}`"
129            ),
130            Self::DuplicateTransitionSite {
131                machine,
132                state,
133                transition,
134            } => write!(
135                formatter,
136                "machine graph `{machine}` contains duplicate transition site `{state}::{transition}`"
137            ),
138            Self::MissingSourceState {
139                machine,
140                transition,
141            } => write!(
142                formatter,
143                "machine graph `{machine}` contains transition `{transition}` whose source state is missing from the state list"
144            ),
145            Self::MissingTargetState {
146                machine,
147                transition,
148            } => write!(
149                formatter,
150                "machine graph `{machine}` contains transition `{transition}` whose target state is missing from the state list"
151            ),
152            Self::EmptyTargetSet {
153                machine,
154                transition,
155            } => write!(
156                formatter,
157                "machine graph `{machine}` contains transition `{transition}` with no target states"
158            ),
159            Self::DuplicateTargetState {
160                machine,
161                transition,
162                state,
163            } => write!(
164                formatter,
165                "machine graph `{machine}` contains transition `{transition}` with duplicate target state `{state}`"
166            ),
167        }
168    }
169}
170
171impl std::error::Error for MachineDocError {}
172
173impl<S, T> TryFrom<&'static MachineGraph<S, T>> for MachineDoc<S, T>
174where
175    S: Copy + Eq + std::hash::Hash + 'static,
176    T: Copy + Eq + 'static,
177{
178    type Error = MachineDocError;
179
180    fn try_from(graph: &'static MachineGraph<S, T>) -> Result<Self, Self::Error> {
181        Self::try_from_graph(graph)
182    }
183}
184
185impl<S, T> MachineDoc<S, T> {
186    /// Descriptor for the exported machine family.
187    pub fn machine(&self) -> MachineDescriptor {
188        self.machine
189    }
190
191    /// Exported states in the same order as the underlying static graph.
192    pub fn states(&self) -> &[StateDoc<S>] {
193        &self.states
194    }
195
196    /// Exported transition sites sorted stably for deterministic renderers.
197    pub fn edges(&self) -> &[EdgeDoc<S, T>] {
198        &self.edges
199    }
200}
201
202impl<S, T> MachineDoc<S, T>
203where
204    S: Copy + Eq + 'static,
205{
206    /// Returns the exported state descriptor for one generated state id.
207    pub fn state(&self, id: S) -> Option<&StateDoc<S>> {
208        self.states.iter().find(|state| state.descriptor.id == id)
209    }
210}
211
212impl<S, T> MachineDoc<S, T> {
213    /// Returns every state with no incoming edge in the exported topology.
214    pub fn roots(&self) -> impl Iterator<Item = &StateDoc<S>> {
215        self.states.iter().filter(|state| state.is_root)
216    }
217}
218
219impl<S, T> MachineDoc<S, T>
220where
221    S: Copy + Eq + std::hash::Hash + 'static,
222    T: Copy + Eq + 'static,
223{
224    /// Exports one machine family from a concrete `MachineIntrospection` type.
225    ///
226    /// This is the normal entry point when the graph comes from Statum itself.
227    /// It will panic only if Statum emitted an invalid
228    /// `MachineIntrospection::GRAPH`.
229    pub fn from_machine<M>() -> Self
230    where
231        M: MachineIntrospection<StateId = S, TransitionId = T>,
232    {
233        Self::try_from_graph(M::GRAPH)
234            .expect("Statum emitted an invalid MachineIntrospection::GRAPH")
235    }
236
237    /// Exports one externally supplied machine graph after validating it.
238    ///
239    /// Use this when the graph does not come from a concrete Statum machine
240    /// type and you want malformed external graphs to fail closed with
241    /// [`MachineDocError`] instead of being rendered best-effort.
242    pub fn try_from_graph(graph: &'static MachineGraph<S, T>) -> Result<Self, MachineDocError> {
243        let transitions = graph.transitions.as_slice();
244        validate_graph(graph.machine, graph.states, transitions)?;
245        let incoming = incoming_states(transitions);
246        let state_positions = state_positions(graph.states);
247
248        let states = graph
249            .states
250            .iter()
251            .copied()
252            .map(|descriptor| StateDoc {
253                descriptor,
254                is_root: !incoming.contains(&descriptor.id),
255            })
256            .collect();
257
258        let mut edges = transitions
259            .iter()
260            .copied()
261            .map(|descriptor| EdgeDoc { descriptor })
262            .collect::<Vec<_>>();
263        edges.sort_by(|left, right| compare_edges(&state_positions, left, right));
264
265        Ok(Self {
266            machine: graph.machine,
267            states,
268            edges,
269        })
270    }
271}
272
273/// Exported state metadata for one graph node.
274#[derive(Clone, Copy, Debug, PartialEq, Eq)]
275pub struct StateDoc<S: 'static> {
276    /// Underlying descriptor from `statum`.
277    pub descriptor: StateDescriptor<S>,
278    /// True when the exported topology has no incoming edge for this state.
279    pub is_root: bool,
280}
281
282/// Exported transition metadata for one graph edge site.
283#[derive(Clone, Copy, Debug, PartialEq, Eq)]
284pub struct EdgeDoc<S: 'static, T: 'static> {
285    /// Underlying descriptor from `statum`.
286    pub descriptor: TransitionDescriptor<S, T>,
287}
288
289fn validate_graph<S, T>(
290    machine: MachineDescriptor,
291    states: &[StateDescriptor<S>],
292    transitions: &[TransitionDescriptor<S, T>],
293) -> Result<(), MachineDocError>
294where
295    S: Copy + Eq + std::hash::Hash + 'static,
296    T: Copy + Eq + 'static,
297{
298    if states.is_empty() {
299        return Err(MachineDocError::EmptyStateList {
300            machine: machine.rust_type_path,
301        });
302    }
303
304    let mut state_names = HashMap::with_capacity(states.len());
305    for state in states.iter() {
306        if state_names.insert(state.id, state.rust_name).is_some() {
307            return Err(MachineDocError::DuplicateStateId {
308                machine: machine.rust_type_path,
309                state: state.rust_name,
310            });
311        }
312    }
313
314    let mut transition_sites = HashSet::with_capacity(transitions.len());
315    let mut transition_ids = Vec::with_capacity(transitions.len());
316    for transition in transitions.iter() {
317        if transition_ids.contains(&transition.id) {
318            return Err(MachineDocError::DuplicateTransitionId {
319                machine: machine.rust_type_path,
320                transition: transition.method_name,
321            });
322        }
323        transition_ids.push(transition.id);
324
325        if !state_names.contains_key(&transition.from) {
326            return Err(MachineDocError::MissingSourceState {
327                machine: machine.rust_type_path,
328                transition: transition.method_name,
329            });
330        }
331
332        let from_state_name = state_names[&transition.from];
333        if !transition_sites.insert((transition.from, transition.method_name)) {
334            return Err(MachineDocError::DuplicateTransitionSite {
335                machine: machine.rust_type_path,
336                state: from_state_name,
337                transition: transition.method_name,
338            });
339        }
340
341        if transition.to.is_empty() {
342            return Err(MachineDocError::EmptyTargetSet {
343                machine: machine.rust_type_path,
344                transition: transition.method_name,
345            });
346        }
347
348        let mut seen_targets = HashSet::with_capacity(transition.to.len());
349        for target in transition.to.iter().copied() {
350            let Some(state_name) = state_names.get(&target).copied() else {
351                return Err(MachineDocError::MissingTargetState {
352                    machine: machine.rust_type_path,
353                    transition: transition.method_name,
354                });
355            };
356
357            if !seen_targets.insert(target) {
358                return Err(MachineDocError::DuplicateTargetState {
359                    machine: machine.rust_type_path,
360                    transition: transition.method_name,
361                    state: state_name,
362                });
363            }
364        }
365    }
366
367    Ok(())
368}
369
370fn incoming_states<S, T>(transitions: &[TransitionDescriptor<S, T>]) -> HashSet<S>
371where
372    S: Copy + Eq + std::hash::Hash + 'static,
373    T: Copy + Eq + 'static,
374{
375    let mut incoming = HashSet::new();
376    for transition in transitions.iter() {
377        for target in transition.to.iter().copied() {
378            incoming.insert(target);
379        }
380    }
381
382    incoming
383}
384
385fn state_positions<S>(states: &[StateDescriptor<S>]) -> HashMap<S, usize>
386where
387    S: Copy + Eq + std::hash::Hash + 'static,
388{
389    states
390        .iter()
391        .enumerate()
392        .map(|(index, state)| (state.id, index))
393        .collect()
394}
395
396fn compare_edges<S, T>(
397    state_positions: &HashMap<S, usize>,
398    left: &EdgeDoc<S, T>,
399    right: &EdgeDoc<S, T>,
400) -> std::cmp::Ordering
401where
402    S: Copy + Eq + std::hash::Hash + 'static,
403    T: Copy + Eq + 'static,
404{
405    state_positions[&left.descriptor.from]
406        .cmp(&state_positions[&right.descriptor.from])
407        .then_with(|| {
408            left.descriptor
409                .method_name
410                .cmp(right.descriptor.method_name)
411        })
412        .then_with(|| compare_targets(state_positions, left.descriptor.to, right.descriptor.to))
413}
414
415fn compare_targets<S>(
416    state_positions: &HashMap<S, usize>,
417    left: &[S],
418    right: &[S],
419) -> std::cmp::Ordering
420where
421    S: Copy + Eq + std::hash::Hash + 'static,
422{
423    let left = left.iter().map(|state| state_positions[state]);
424    let right = right.iter().map(|state| state_positions[state]);
425
426    left.cmp(right)
427}