Skip to main content

statum_graph/
export.rs

1use std::borrow::Cow;
2
3use serde::Serialize;
4use statum::MachinePresentation;
5
6use crate::MachineDoc;
7
8/// Stable export model for one validated machine graph.
9///
10/// This type is the canonical renderer input for Mermaid, DOT, PlantUML, and
11/// JSON output. Structure comes from [`statum::MachineIntrospection::GRAPH`];
12/// labels and descriptions may be joined from a matching
13/// [`statum::MachinePresentation`].
14#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
15pub struct ExportDoc {
16    /// Exported machine metadata.
17    machine: ExportMachine,
18    /// Exported states in stable graph order.
19    states: Vec<ExportState>,
20    /// Exported transition sites in stable graph order.
21    transitions: Vec<ExportTransition>,
22}
23
24impl ExportDoc {
25    /// Exported machine metadata.
26    pub fn machine(&self) -> ExportMachine {
27        self.machine
28    }
29
30    /// Exported states in stable graph order.
31    pub fn states(&self) -> &[ExportState] {
32        &self.states
33    }
34
35    /// Exported transition sites in stable graph order.
36    pub fn transitions(&self) -> &[ExportTransition] {
37        &self.transitions
38    }
39
40    /// Returns one exported state by its stable state index.
41    pub fn state(&self, index: usize) -> Option<&ExportState> {
42        self.states.get(index)
43    }
44
45    /// Returns one exported transition site by its stable transition index.
46    pub fn transition(&self, index: usize) -> Option<&ExportTransition> {
47        self.transitions.get(index)
48    }
49}
50
51/// Machine metadata preserved in the stable export surface.
52#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
53pub struct ExportMachine {
54    /// `module_path!()` for the source module that owns the machine.
55    pub module_path: &'static str,
56    /// Fully qualified Rust type path for the machine family.
57    pub rust_type_path: &'static str,
58    /// Optional human-facing label from presentation metadata.
59    pub label: Option<&'static str>,
60    /// Optional human-facing description from presentation metadata.
61    pub description: Option<&'static str>,
62}
63
64/// State metadata preserved in the stable export surface.
65#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
66pub struct ExportState {
67    /// Stable export-local state index.
68    pub index: usize,
69    /// Rust variant name emitted by Statum.
70    pub rust_name: &'static str,
71    /// Optional human-facing label from presentation metadata.
72    pub label: Option<&'static str>,
73    /// Optional human-facing description from presentation metadata.
74    pub description: Option<&'static str>,
75    /// Whether the state carries `state_data`.
76    pub has_data: bool,
77    /// Whether the state has no incoming edge in the exported topology.
78    pub is_root: bool,
79}
80
81impl ExportState {
82    /// Stable renderer node id for this state.
83    pub fn node_id(&self) -> String {
84        format!("s{}", self.index)
85    }
86
87    /// Human-facing state label used by text renderers.
88    pub fn display_label(&self) -> Cow<'static, str> {
89        match self.label {
90            Some(label) => Cow::Borrowed(label),
91            None if self.has_data => Cow::Owned(format!("{} (data)", self.rust_name)),
92            None => Cow::Borrowed(self.rust_name),
93        }
94    }
95}
96
97/// Transition-site metadata preserved in the stable export surface.
98#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
99pub struct ExportTransition {
100    /// Stable export-local transition index.
101    pub index: usize,
102    /// Rust method name emitted by Statum.
103    pub method_name: &'static str,
104    /// Optional human-facing label from presentation metadata.
105    pub label: Option<&'static str>,
106    /// Optional human-facing description from presentation metadata.
107    pub description: Option<&'static str>,
108    /// Stable source-state index.
109    pub from: usize,
110    /// Stable legal target-state indices for this transition site.
111    pub to: Vec<usize>,
112}
113
114impl ExportTransition {
115    /// Stable renderer transition id for this transition site.
116    pub fn transition_id(&self) -> String {
117        format!("t{}", self.index)
118    }
119
120    /// Human-facing edge label used by text renderers.
121    pub fn display_label(&self) -> &'static str {
122        self.label.unwrap_or(self.method_name)
123    }
124}
125
126/// Error returned when presentation metadata cannot be joined onto a
127/// validated machine graph.
128#[derive(Clone, Copy, Debug, Eq, PartialEq)]
129pub enum ExportDocError {
130    /// One state presentation entry points at a state id that is not in the
131    /// validated graph.
132    UnknownStatePresentation { machine: &'static str, entry: usize },
133    /// One state id appears more than once in the presentation overlay.
134    DuplicateStatePresentation { machine: &'static str, entry: usize },
135    /// One transition presentation entry points at a transition id that is not
136    /// in the validated graph.
137    UnknownTransitionPresentation { machine: &'static str, entry: usize },
138    /// One transition id appears more than once in the presentation overlay.
139    DuplicateTransitionPresentation { machine: &'static str, entry: usize },
140}
141
142impl core::fmt::Display for ExportDocError {
143    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
144        match self {
145            Self::UnknownStatePresentation { machine, entry } => write!(
146                formatter,
147                "presentation for machine `{machine}` contains state entry {} whose id is missing from the graph",
148                entry + 1
149            ),
150            Self::DuplicateStatePresentation { machine, entry } => write!(
151                formatter,
152                "presentation for machine `{machine}` contains duplicate state id at entry {}",
153                entry + 1
154            ),
155            Self::UnknownTransitionPresentation { machine, entry } => write!(
156                formatter,
157                "presentation for machine `{machine}` contains transition entry {} whose id is missing from the graph",
158                entry + 1
159            ),
160            Self::DuplicateTransitionPresentation { machine, entry } => write!(
161                formatter,
162                "presentation for machine `{machine}` contains duplicate transition id at entry {}",
163                entry + 1
164            ),
165        }
166    }
167}
168
169impl std::error::Error for ExportDocError {}
170
171impl<S, T> From<&MachineDoc<S, T>> for ExportDoc
172where
173    S: Eq,
174{
175    fn from(doc: &MachineDoc<S, T>) -> Self {
176        Self {
177            machine: ExportMachine {
178                module_path: doc.machine().module_path,
179                rust_type_path: doc.machine().rust_type_path,
180                label: None,
181                description: None,
182            },
183            states: doc
184                .states()
185                .iter()
186                .enumerate()
187                .map(|(index, state)| ExportState {
188                    index,
189                    rust_name: state.descriptor.rust_name,
190                    label: None,
191                    description: None,
192                    has_data: state.descriptor.has_data,
193                    is_root: state.is_root,
194                })
195                .collect(),
196            transitions: doc
197                .edges()
198                .iter()
199                .enumerate()
200                .map(|(index, edge)| ExportTransition {
201                    index,
202                    method_name: edge.descriptor.method_name,
203                    label: None,
204                    description: None,
205                    from: doc
206                        .states()
207                        .iter()
208                        .position(|state| state.descriptor.id == edge.descriptor.from)
209                        .expect("MachineDoc state ids should align with edges"),
210                    to: edge
211                        .descriptor
212                        .to
213                        .iter()
214                        .map(|target| {
215                            doc.states()
216                                .iter()
217                                .position(|state| state.descriptor.id == *target)
218                                .expect("MachineDoc target ids should align with states")
219                        })
220                        .collect(),
221                })
222                .collect(),
223        }
224    }
225}
226
227pub trait ExportSource {
228    fn export_doc(&self) -> Cow<'_, ExportDoc>;
229}
230
231impl ExportSource for ExportDoc {
232    fn export_doc(&self) -> Cow<'_, ExportDoc> {
233        Cow::Borrowed(self)
234    }
235}
236
237impl<S, T> ExportSource for MachineDoc<S, T>
238where
239    S: Eq,
240{
241    fn export_doc(&self) -> Cow<'_, ExportDoc> {
242        Cow::Owned(self.export())
243    }
244}
245
246impl<S, T> MachineDoc<S, T>
247where
248    S: Eq,
249{
250    /// Builds the canonical stable export model without presentation metadata.
251    pub fn export(&self) -> ExportDoc {
252        ExportDoc::from(self)
253    }
254}
255
256impl<S, T> MachineDoc<S, T>
257where
258    S: Copy + Eq + 'static,
259    T: Copy + Eq + 'static,
260{
261    /// Builds the canonical stable export model and joins matching labels and
262    /// descriptions from a presentation overlay.
263    pub fn export_with_presentation<MachineMeta, StateMeta, TransitionMeta>(
264        &self,
265        presentation: &MachinePresentation<S, T, MachineMeta, StateMeta, TransitionMeta>,
266    ) -> Result<ExportDoc, ExportDocError> {
267        let mut export = self.export();
268
269        if let Some(machine) = &presentation.machine {
270            export.machine.label = machine.label;
271            export.machine.description = machine.description;
272        }
273
274        let mut seen_states = vec![false; export.states.len()];
275        for (entry, presented_state) in presentation.states.iter().enumerate() {
276            let Some(index) = self
277                .states()
278                .iter()
279                .position(|state| state.descriptor.id == presented_state.id)
280            else {
281                return Err(ExportDocError::UnknownStatePresentation {
282                    machine: self.machine().rust_type_path,
283                    entry,
284                });
285            };
286
287            if std::mem::replace(&mut seen_states[index], true) {
288                return Err(ExportDocError::DuplicateStatePresentation {
289                    machine: self.machine().rust_type_path,
290                    entry,
291                });
292            }
293
294            let export_state = &mut export.states[index];
295            export_state.label = presented_state.label;
296            export_state.description = presented_state.description;
297        }
298
299        let mut seen_transitions = vec![false; export.transitions.len()];
300        for (entry, presented_transition) in presentation.transitions.iter().enumerate() {
301            let Some(index) = self
302                .edges()
303                .iter()
304                .position(|edge| edge.descriptor.id == presented_transition.id)
305            else {
306                return Err(ExportDocError::UnknownTransitionPresentation {
307                    machine: self.machine().rust_type_path,
308                    entry,
309                });
310            };
311
312            if std::mem::replace(&mut seen_transitions[index], true) {
313                return Err(ExportDocError::DuplicateTransitionPresentation {
314                    machine: self.machine().rust_type_path,
315                    entry,
316                });
317            }
318
319            let export_transition = &mut export.transitions[index];
320            export_transition.label = presented_transition.label;
321            export_transition.description = presented_transition.description;
322        }
323
324        Ok(export)
325    }
326}