Skip to main content

majordomus_cli/
diagram.rs

1//! Diagrams, canonically: nodes, edges and groups in Rust, derived from whatever already
2//! states the topology, and rendered to Mermaid by a function that is one reader among
3//! several.
4//!
5//! # The authority this takes away
6//!
7//! Before this module the model *was* the Mermaid. `site/data/generated/diagrams.json`
8//! carried six records shaped `{mermaid, source, title}`, and `mermaid` was a source string
9//! composed by `printf` in `scripts/generate-site-data`:
10//!
11//! ```text
12//!   lib/finish.sh ── grep ──> "completed|partial|…" ── printf ──> "stateDiagram-v2\n …"
13//!                                                                          │
14//!                                                            the only statement of structure
15//! ```
16//!
17//! Nothing downstream could read that. A second rendering of the same lifecycle — a
18//! Graphviz export, a Cockpit panel, a check asking whether every outcome the tool accepts
19//! is actually drawn — had to re-type the topology, and a diagram the repository publishes
20//! is then an independent claim about the system rather than a projection of it. That is
21//! exactly what `project.a-diagram-is-a-projection` forbids everywhere else.
22//!
23//! So the canonical form is [`Diagram`], and Mermaid is [`Diagram::to_mermaid`]: one
24//! renderer, replaceable, with no privilege over any other.
25//!
26//! ```
27//! use majordomus_cli::diagram::{Diagram, Edge, Kind, Node, Provenance};
28//!
29//! let drawn = Diagram::new("greeting", "Two states", Kind::State,
30//!         Provenance::new("lib/finish.sh", "the outcome vocabulary"))
31//!     .with_node(Node::new("active", "active"))
32//!     .with_node(Node::new("done", "done"))
33//!     .with_edge(Edge::new("active", "done").labelled("finish"));
34//!
35//! assert_eq!(drawn.to_mermaid(), "stateDiagram-v2\n    active --> done: finish");
36//! assert!(drawn.validate().is_empty(), "a well-formed model has nothing to report");
37//! ```
38//!
39//! # Why the order is the derivation's and not the ids'
40//!
41//! [`crate::graph`] sorts its nodes, because a graph is a set and two runs must agree. A
42//! diagram is a *picture*, and Mermaid lays one out in declaration order: sorting would make
43//! the drawing a function of how the nodes happen to be named. So a [`Diagram`] keeps the
44//! order its derivation built it in, and determinism is the derivation's obligation — the
45//! same source text builds the same model, and the same model renders to the same bytes.
46
47use schemars::JsonSchema;
48use serde::{Deserialize, Serialize};
49
50/// The shape every projection of a diagram declares itself to be, so that a record written
51/// today can still be read when the fields below change.
52pub const DIAGRAM_SCHEMA: &str = "majordomus/diagram/v1";
53
54/// What kind of thing the diagram draws, which is also what Mermaid dialect renders it.
55///
56/// The two are one choice rather than two, because a state machine drawn as a flowchart
57/// loses its start and end and a flow drawn as a state machine cannot carry a subgraph:
58///
59/// ```
60/// use majordomus_cli::diagram::Kind;
61/// assert_eq!(Kind::State.as_str(), "state");
62/// assert_eq!(Kind::Flow.as_str(), "flow");
63/// ```
64#[derive(
65    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
66)]
67#[serde(rename_all = "snake_case")]
68#[schemars(rename = "DiagramKind")]
69pub enum Kind {
70    /// Boxes and arrows: something moves from one place to another.
71    Flow,
72    /// A state machine: a thing is in exactly one of these at a time.
73    State,
74}
75
76impl Kind {
77    /// The word every surface writes for this kind — the JSON projection, a report, a
78    /// filter — so that no caller invents a second spelling.
79    ///
80    /// ```
81    /// use majordomus_cli::diagram::Kind;
82    /// assert_eq!(Kind::Flow.as_str(), "flow");
83    /// ```
84    pub fn as_str(self) -> &'static str {
85        match self {
86            Kind::Flow => "flow",
87            Kind::State => "state",
88        }
89    }
90}
91
92/// Which way a flowchart runs. Ignored by [`Kind::State`], which Mermaid always lays out
93/// top-down.
94///
95/// ```
96/// use majordomus_cli::diagram::Orientation;
97/// assert_eq!(Orientation::LeftRight.as_str(), "LR", "the token Mermaid itself reads");
98/// assert_eq!(Orientation::default(), Orientation::TopDown);
99/// ```
100#[derive(
101    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize, JsonSchema,
102)]
103#[serde(rename_all = "snake_case")]
104#[schemars(rename = "DiagramOrientation")]
105pub enum Orientation {
106    /// Top to bottom: the default, and what a hierarchy wants.
107    #[default]
108    TopDown,
109    /// Left to right: what a pipeline wants.
110    LeftRight,
111}
112
113impl Orientation {
114    /// The token Mermaid reads after `flowchart`.
115    ///
116    /// ```
117    /// use majordomus_cli::diagram::Orientation;
118    /// assert_eq!(Orientation::TopDown.as_str(), "TD");
119    /// ```
120    pub fn as_str(self) -> &'static str {
121        match self {
122            Orientation::TopDown => "TD",
123            Orientation::LeftRight => "LR",
124        }
125    }
126}
127
128/// What a node is drawn as. A shape is meaning, not decoration: a [`Shape::Cylinder`] says
129/// the thing is stored, a [`Shape::Terminal`] says the diagram begins or ends there.
130///
131/// ```
132/// use majordomus_cli::diagram::Shape;
133/// assert_eq!(Shape::default(), Shape::Box, "an unremarkable thing is a box");
134/// ```
135#[derive(
136    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize, JsonSchema,
137)]
138#[serde(rename_all = "snake_case")]
139#[schemars(rename = "DiagramShape")]
140pub enum Shape {
141    /// A plain rectangle: a step, a component, a file.
142    #[default]
143    Box,
144    /// A rounded rectangle: something softer than a step — a note, a context.
145    Rounded,
146    /// A stadium: an entry point a person reaches for.
147    Stadium,
148    /// A cylinder: data at rest.
149    Cylinder,
150    /// The start or the end. Rendered `[*]` in a state diagram, where Mermaid decides from
151    /// the arrow's direction which of the two it is, and as a circle in a flowchart.
152    Terminal,
153}
154
155/// Whether an edge is what happens or merely what is implied.
156///
157/// ```
158/// use majordomus_cli::diagram::EdgeStyle;
159/// assert_eq!(EdgeStyle::default(), EdgeStyle::Solid);
160/// ```
161#[derive(
162    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize, JsonSchema,
163)]
164#[serde(rename_all = "snake_case")]
165#[schemars(rename = "DiagramEdgeStyle")]
166pub enum EdgeStyle {
167    /// It happens: control or data really moves along this arrow.
168    #[default]
169    Solid,
170    /// It is implied: a stamp, an observation, a relationship that is not a transfer.
171    Dotted,
172}
173
174/// One node: a state, a step, a store. `id` is unique within the diagram and is what an
175/// [`Edge`] names; `label` is what a reader sees. The two are separate because a label is
176/// prose that may hold spaces, punctuation and a line break, and an id is a token a
177/// renderer writes into its own syntax:
178///
179/// ```
180/// use majordomus_cli::diagram::{Diagram, Kind, Node, Provenance};
181/// let node = Node::new("handed_over", "handed over to the next session");
182/// let drawn = Diagram::new("x", "X", Kind::Flow, Provenance::new("a", "b")).with_node(node);
183/// assert!(drawn.to_mermaid().contains(r#"handed_over["handed over to the next session"]"#));
184/// assert!(drawn.validate().is_empty(), "the id is a token even though the label is not");
185/// ```
186#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
187#[schemars(rename = "DiagramNode")]
188pub struct Node {
189    /// Unique within the diagram, and safe to write into Mermaid source unquoted.
190    pub id: String,
191    /// The short text drawn inside the node.
192    pub label: String,
193    /// What it is drawn as.
194    #[serde(default)]
195    pub shape: Shape,
196}
197
198impl Node {
199    /// A box with this id and label — the node most derivations want.
200    ///
201    /// ```
202    /// use majordomus_cli::diagram::{Node, Shape};
203    /// let node = Node::new("active", "active");
204    /// assert_eq!(node.shape, Shape::Box);
205    /// assert_eq!(node.id, "active");
206    /// ```
207    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Node {
208        Node {
209            id: id.into(),
210            label: label.into(),
211            shape: Shape::default(),
212        }
213    }
214
215    /// The same node with a different [`Shape`] — which is a statement about the thing and
216    /// not about the picture: a derivation says a node is a store or a terminal, and every
217    /// renderer then decides for itself how to draw that.
218    ///
219    /// ```
220    /// use majordomus_cli::diagram::{Node, Shape};
221    /// let node = Node::new("entry", "entry").shaped(Shape::Terminal);
222    /// assert_eq!(node.shape, Shape::Terminal);
223    /// ```
224    pub fn shaped(mut self, shape: Shape) -> Node {
225        self.shape = shape;
226        self
227    }
228}
229
230/// One edge, from a node id to a node id, with the transition or the reason written on it.
231///
232/// An edge names ids and never nodes, so a derivation can state a relationship before it has
233/// built both ends — and [`Diagram::validate`] is what refuses an end that never arrived,
234/// rather than a renderer discovering it while writing broken syntax:
235///
236/// ```
237/// use majordomus_cli::diagram::{Diagram, Edge, Kind, Node, Provenance};
238/// let drawn = Diagram::new("x", "X", Kind::Flow, Provenance::new("a", "b"))
239///     .with_node(Node::new("a", "A"))
240///     .with_node(Node::new("b", "B"))
241///     .with_edge(Edge::new("a", "b").labelled("moves").dotted());
242/// assert!(drawn.to_mermaid().ends_with("a -.->|moves| b"));
243/// ```
244#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
245#[schemars(rename = "DiagramEdge")]
246pub struct Edge {
247    /// The id of the node the arrow leaves.
248    pub from: String,
249    /// The id of the node the arrow reaches.
250    pub to: String,
251    /// What is written on the arrow, when anything is.
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub label: Option<String>,
254    /// Whether it happens or is implied.
255    #[serde(default)]
256    pub style: EdgeStyle,
257}
258
259impl Edge {
260    /// A plain, unlabelled arrow between two node ids.
261    ///
262    /// ```
263    /// use majordomus_cli::diagram::{Edge, EdgeStyle};
264    /// let edge = Edge::new("active", "failed");
265    /// assert_eq!(edge.label, None);
266    /// assert_eq!(edge.style, EdgeStyle::Solid);
267    /// ```
268    pub fn new(from: impl Into<String>, to: impl Into<String>) -> Edge {
269        Edge {
270            from: from.into(),
271            to: to.into(),
272            label: None,
273            style: EdgeStyle::default(),
274        }
275    }
276
277    /// The same arrow with text on it: the command, the event, the condition.
278    ///
279    /// ```
280    /// use majordomus_cli::diagram::Edge;
281    /// let edge = Edge::new("active", "failed").labelled("finish --outcome failed");
282    /// assert_eq!(edge.label.as_deref(), Some("finish --outcome failed"));
283    /// ```
284    pub fn labelled(mut self, label: impl Into<String>) -> Edge {
285        self.label = Some(label.into());
286        self
287    }
288
289    /// The same arrow, implied rather than travelled.
290    ///
291    /// ```
292    /// use majordomus_cli::diagram::{Edge, EdgeStyle};
293    /// assert_eq!(Edge::new("a", "b").dotted().style, EdgeStyle::Dotted);
294    /// ```
295    pub fn dotted(mut self) -> Edge {
296        self.style = EdgeStyle::Dotted;
297        self
298    }
299}
300
301/// A box drawn around several nodes: Mermaid's `subgraph`. A flowchart concept — a state
302/// machine has composite states instead, which this model does not carry, and
303/// [`Diagram::validate`] says so rather than dropping the group in silence.
304///
305/// ```
306/// use majordomus_cli::diagram::{Diagram, Group, Kind, Node, Provenance};
307/// let drawn = Diagram::new("x", "X", Kind::Flow, Provenance::new("a", "b"))
308///     .with_node(Node::new("a", "A"))
309///     .with_node(Node::new("loose", "Loose"))
310///     .with_group(Group::new("canonical", "canonical", ["a"]));
311/// let mermaid = drawn.to_mermaid();
312/// assert!(mermaid.contains("    subgraph canonical [\"canonical\"]\n        a[\"A\"]\n    end"));
313/// assert!(mermaid.contains("\n    loose[\"Loose\"]"), "an ungrouped node stays outside");
314/// ```
315#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
316#[schemars(rename = "DiagramGroup")]
317pub struct Group {
318    /// Unique within the diagram, and distinct from every node id.
319    pub id: String,
320    /// The text drawn on the box.
321    pub label: String,
322    /// The ids of the nodes inside it, in the order they should be drawn.
323    pub members: Vec<String>,
324}
325
326impl Group {
327    /// A group over the node ids given, in that order.
328    ///
329    /// ```
330    /// use majordomus_cli::diagram::Group;
331    /// let group = Group::new("canonical", "canonical", ["a", "b"]);
332    /// assert_eq!(group.members, vec!["a".to_string(), "b".to_string()]);
333    /// ```
334    pub fn new(
335        id: impl Into<String>,
336        label: impl Into<String>,
337        members: impl IntoIterator<Item = impl Into<String>>,
338    ) -> Group {
339        Group {
340            id: id.into(),
341            label: label.into(),
342            members: members.into_iter().map(Into::into).collect(),
343        }
344    }
345}
346
347/// Where the topology came from. A diagram that cannot say this is an authored claim about
348/// the system, which is the thing this module exists to stop.
349///
350/// It is a required field of [`Diagram`] rather than an optional one for that reason: a
351/// reader who doubts the picture must be able to go to the file it was read out of.
352///
353/// ```
354/// use majordomus_cli::diagram::{lifecycle, Provenance};
355/// let from = Provenance::new("lib/finish.sh", "the outcome vocabulary");
356/// assert_eq!(from.derivation, "the outcome vocabulary");
357/// let drawn = lifecycle("case \"$outcome\" in completed|failed) ;;").expect("derives");
358/// assert_eq!(drawn.provenance, from, "the derivation records the file it read");
359/// ```
360#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
361#[schemars(rename = "DiagramProvenance")]
362pub struct Provenance {
363    /// The repository-relative path the structure was read out of.
364    pub source: String,
365    /// One line saying what was read out of it, so a reader can check the derivation
366    /// against the file rather than against the picture.
367    pub derivation: String,
368}
369
370impl Provenance {
371    /// The file and what was taken from it.
372    ///
373    /// ```
374    /// use majordomus_cli::diagram::Provenance;
375    /// let from = Provenance::new("lib/finish.sh", "the outcome vocabulary");
376    /// assert_eq!(from.source, "lib/finish.sh");
377    /// ```
378    pub fn new(source: impl Into<String>, derivation: impl Into<String>) -> Provenance {
379        Provenance {
380            source: source.into(),
381            derivation: derivation.into(),
382        }
383    }
384}
385
386/// A diagram, canonically. Every renderer is a function of this; nothing is a function of a
387/// renderer's output.
388///
389/// The whole point is that the structure survives without the syntax: a caller can ask this
390/// value a question — is every outcome the tool accepts drawn? — which a Mermaid string
391/// could only be asked by re-parsing a picture.
392///
393/// ```
394/// use majordomus_cli::diagram::{lifecycle, outcomes, Diagram};
395///
396/// let shell = "  case \"$outcome\" in completed|partial|failed) ;;";
397/// let drawn: Diagram = lifecycle(shell).expect("the vocabulary is readable");
398///
399/// // the question, asked of the model rather than of the picture
400/// for outcome in outcomes(shell).expect("a vocabulary") {
401///     assert!(drawn.nodes.iter().any(|n| n.id == outcome), "{outcome} is not drawn");
402///     assert!(drawn.edges.iter().any(|e| e.to == outcome), "nothing reaches {outcome}");
403/// }
404/// assert!(drawn.to_mermaid().starts_with("stateDiagram-v2"), "Mermaid is one reader of it");
405/// ```
406#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
407#[schemars(rename = "Diagram")]
408pub struct Diagram {
409    /// [`DIAGRAM_SCHEMA`].
410    pub schema: String,
411    /// Stable identity: the key the site and every other surface holds this diagram under.
412    pub id: String,
413    /// The heading a reader sees above the drawing.
414    pub title: String,
415    /// What it draws, and therefore which dialect renders it.
416    pub kind: Kind,
417    /// Which way a flowchart runs; ignored for [`Kind::State`].
418    #[serde(default)]
419    pub orientation: Orientation,
420    /// What the structure was derived from.
421    pub provenance: Provenance,
422    /// The nodes, in the order the derivation built them, which is the order they are drawn.
423    pub nodes: Vec<Node>,
424    /// The edges, in derivation order.
425    pub edges: Vec<Edge>,
426    /// The boxes drawn around groups of nodes; empty for most diagrams.
427    #[serde(default, skip_serializing_if = "Vec::is_empty")]
428    pub groups: Vec<Group>,
429}
430
431impl Diagram {
432    /// An empty diagram of this kind, which a derivation then fills.
433    ///
434    /// ```
435    /// use majordomus_cli::diagram::{Diagram, Kind, Provenance, DIAGRAM_SCHEMA};
436    /// let drawn = Diagram::new("x", "X", Kind::Flow, Provenance::new("a", "b"));
437    /// assert_eq!(drawn.schema, DIAGRAM_SCHEMA);
438    /// assert!(drawn.nodes.is_empty());
439    /// ```
440    pub fn new(
441        id: impl Into<String>,
442        title: impl Into<String>,
443        kind: Kind,
444        provenance: Provenance,
445    ) -> Diagram {
446        Diagram {
447            schema: DIAGRAM_SCHEMA.to_string(),
448            id: id.into(),
449            title: title.into(),
450            kind,
451            orientation: Orientation::default(),
452            provenance,
453            nodes: Vec::new(),
454            edges: Vec::new(),
455            groups: Vec::new(),
456        }
457    }
458
459    /// The same diagram laid out the other way.
460    ///
461    /// ```
462    /// use majordomus_cli::diagram::{Diagram, Kind, Orientation, Provenance};
463    /// let drawn = Diagram::new("x", "X", Kind::Flow, Provenance::new("a", "b"))
464    ///     .oriented(Orientation::LeftRight);
465    /// assert_eq!(drawn.orientation, Orientation::LeftRight);
466    /// ```
467    pub fn oriented(mut self, orientation: Orientation) -> Diagram {
468        self.orientation = orientation;
469        self
470    }
471
472    /// The same diagram with one more node, appended in drawing order.
473    ///
474    /// ```
475    /// use majordomus_cli::diagram::{Diagram, Kind, Node, Provenance};
476    /// let drawn = Diagram::new("x", "X", Kind::Flow, Provenance::new("a", "b"))
477    ///     .with_node(Node::new("a", "A"));
478    /// assert_eq!(drawn.nodes.len(), 1);
479    /// ```
480    pub fn with_node(mut self, node: Node) -> Diagram {
481        self.nodes.push(node);
482        self
483    }
484
485    /// The same diagram with one more edge, appended in drawing order.
486    ///
487    /// ```
488    /// use majordomus_cli::diagram::{Diagram, Edge, Kind, Node, Provenance};
489    /// let drawn = Diagram::new("x", "X", Kind::Flow, Provenance::new("a", "b"))
490    ///     .with_node(Node::new("a", "A"))
491    ///     .with_node(Node::new("b", "B"))
492    ///     .with_edge(Edge::new("a", "b"));
493    /// assert_eq!(drawn.edges.len(), 1);
494    /// ```
495    pub fn with_edge(mut self, edge: Edge) -> Diagram {
496        self.edges.push(edge);
497        self
498    }
499
500    /// The same diagram with one more group.
501    ///
502    /// ```
503    /// use majordomus_cli::diagram::{Diagram, Group, Kind, Node, Provenance};
504    /// let drawn = Diagram::new("x", "X", Kind::Flow, Provenance::new("a", "b"))
505    ///     .with_node(Node::new("a", "A"))
506    ///     .with_group(Group::new("g", "Group", ["a"]));
507    /// assert_eq!(drawn.groups.len(), 1);
508    /// ```
509    pub fn with_group(mut self, group: Group) -> Diagram {
510        self.groups.push(group);
511        self
512    }
513
514    /// Everything wrong with the model, in the order it was found, as lines a report can
515    /// print. Empty means the model renders to Mermaid a parser accepts.
516    ///
517    /// A renderer must never be the thing that discovers a dangling edge, because by then
518    /// the only way to say so is broken output:
519    ///
520    /// ```
521    /// use majordomus_cli::diagram::{Diagram, Edge, Kind, Node, Provenance};
522    /// let drawn = Diagram::new("x", "X", Kind::Flow, Provenance::new("a", "b"))
523    ///     .with_node(Node::new("a", "A"))
524    ///     .with_edge(Edge::new("a", "nowhere"));
525    /// let found = drawn.validate();
526    /// assert_eq!(found.len(), 1);
527    /// assert!(found[0].contains("nowhere"), "{found:?}");
528    /// ```
529    pub fn validate(&self) -> Vec<String> {
530        let mut found = Vec::new();
531        let mut seen: Vec<&str> = Vec::new();
532        for node in &self.nodes {
533            if node.id.is_empty() || !node.id.chars().all(is_id_char) {
534                found.push(format!(
535                    "node id {:?} is not a Mermaid identifier ([A-Za-z0-9_] only)",
536                    node.id
537                ));
538            }
539            if MERMAID_KEYWORDS.contains(&node.id.as_str()) {
540                found.push(format!("node id {:?} is a Mermaid keyword", node.id));
541            }
542            if seen.contains(&node.id.as_str()) {
543                found.push(format!("node id {:?} is declared twice", node.id));
544            }
545            seen.push(&node.id);
546        }
547        for edge in &self.edges {
548            for end in [&edge.from, &edge.to] {
549                if !seen.contains(&end.as_str()) {
550                    found.push(format!(
551                        "edge {:?} -> {:?} names {end:?}, which is not a node",
552                        edge.from, edge.to
553                    ));
554                }
555            }
556            if edge.style == EdgeStyle::Dotted && self.kind == Kind::State {
557                found.push(format!(
558                    "edge {:?} -> {:?} is dotted, which a state diagram cannot draw",
559                    edge.from, edge.to
560                ));
561            }
562        }
563        for group in &self.groups {
564            if self.kind == Kind::State {
565                found.push(format!(
566                    "group {:?} is a flowchart concept; a state diagram has composite \
567                     states, which this model does not carry",
568                    group.id
569                ));
570            }
571            for member in &group.members {
572                if !seen.contains(&member.as_str()) {
573                    found.push(format!(
574                        "group {:?} names {member:?}, which is not a node",
575                        group.id
576                    ));
577                }
578            }
579        }
580        found
581    }
582
583    /// This diagram as Mermaid source — one renderer of the model, with no authority over
584    /// it. No trailing newline: the result is a value a JSON field holds, not a file.
585    ///
586    /// ```
587    /// use majordomus_cli::diagram::{Diagram, Edge, Kind, Node, Orientation, Provenance};
588    /// let drawn = Diagram::new("x", "X", Kind::Flow, Provenance::new("a", "b"))
589    ///     .oriented(Orientation::LeftRight)
590    ///     .with_node(Node::new("a", "A"))
591    ///     .with_node(Node::new("b", "B"))
592    ///     .with_edge(Edge::new("a", "b").labelled("moves"));
593    /// assert_eq!(
594    ///     drawn.to_mermaid(),
595    ///     "flowchart LR\n    a[\"A\"]\n    b[\"B\"]\n    a -->|moves| b",
596    /// );
597    /// ```
598    ///
599    /// A diagram with no nodes still renders to something a parser accepts, rather than to
600    /// a bare header that may or may not be a document:
601    ///
602    /// ```
603    /// use majordomus_cli::diagram::{Diagram, Kind, Provenance};
604    /// let empty = Diagram::new("x", "X", Kind::State, Provenance::new("a", "b"));
605    /// assert_eq!(empty.to_mermaid(), "stateDiagram-v2\n    %% no nodes were derived");
606    /// ```
607    pub fn to_mermaid(&self) -> String {
608        let mut lines = vec![match self.kind {
609            Kind::Flow => format!("flowchart {}", self.orientation.as_str()),
610            Kind::State => "stateDiagram-v2".to_string(),
611        }];
612
613        if self.nodes.is_empty() {
614            lines.push("    %% no nodes were derived".to_string());
615            return lines.join("\n");
616        }
617
618        match self.kind {
619            Kind::Flow => self.render_flow(&mut lines),
620            Kind::State => self.render_state(&mut lines),
621        }
622        lines.join("\n")
623    }
624
625    /// Boxes, subgraphs and arrows.
626    fn render_flow(&self, lines: &mut Vec<String>) {
627        let grouped: Vec<&str> = self
628            .groups
629            .iter()
630            .flat_map(|g| g.members.iter().map(String::as_str))
631            .collect();
632        for group in &self.groups {
633            lines.push(format!(
634                "    subgraph {} [{}]",
635                group.id,
636                quoted(&group.label)
637            ));
638            for member in &group.members {
639                if let Some(node) = self.nodes.iter().find(|n| &n.id == member) {
640                    lines.push(format!("        {}", flow_node(node)));
641                }
642            }
643            lines.push("    end".to_string());
644        }
645        for node in self
646            .nodes
647            .iter()
648            .filter(|n| !grouped.contains(&n.id.as_str()))
649        {
650            lines.push(format!("    {}", flow_node(node)));
651        }
652        for edge in &self.edges {
653            let arrow = match edge.style {
654                EdgeStyle::Solid => "-->",
655                EdgeStyle::Dotted => "-.->",
656            };
657            let arrow = match &edge.label {
658                Some(label) => format!("{arrow}|{}|", inline(label)),
659                None => arrow.to_string(),
660            };
661            lines.push(format!("    {} {arrow} {}", edge.from, edge.to));
662        }
663    }
664
665    /// States and transitions. `[*]` is written for every [`Shape::Terminal`] node: Mermaid
666    /// reads the arrow's direction to decide whether that is the start or the end, which is
667    /// why a lifecycle carries two terminal nodes and not one.
668    fn render_state(&self, lines: &mut Vec<String>) {
669        for node in &self.nodes {
670            if node.shape != Shape::Terminal && node.label != node.id {
671                lines.push(format!("    state {} as {}", quoted(&node.label), node.id));
672            }
673        }
674        let terminals: Vec<&str> = self
675            .nodes
676            .iter()
677            .filter(|n| n.shape == Shape::Terminal)
678            .map(|n| n.id.as_str())
679            .collect();
680        let name = |id: &str| {
681            if terminals.contains(&id) {
682                "[*]".to_string()
683            } else {
684                id.to_string()
685            }
686        };
687        for edge in &self.edges {
688            let tail = match &edge.label {
689                Some(label) => format!(": {}", inline(label)),
690                None => String::new(),
691            };
692            lines.push(format!(
693                "    {} --> {}{tail}",
694                name(&edge.from),
695                name(&edge.to)
696            ));
697        }
698    }
699}
700
701/// Characters a Mermaid identifier may hold. Deliberately narrow: everything outside it is
702/// a [`Diagram::validate`] finding rather than an escaping problem at render time.
703fn is_id_char(c: char) -> bool {
704    c.is_ascii_alphanumeric() || c == '_'
705}
706
707/// The words Mermaid reserves. `end` is the dangerous one: as a node id inside a subgraph it
708/// closes the subgraph instead.
709const MERMAID_KEYWORDS: &[&str] = &[
710    "end",
711    "graph",
712    "subgraph",
713    "state",
714    "class",
715    "click",
716    "style",
717    "flowchart",
718    "direction",
719];
720
721/// A label as a Mermaid quoted string. `"` becomes the entity Mermaid reads, and a newline
722/// becomes the break a label can actually hold.
723fn quoted(label: &str) -> String {
724    format!(
725        "\"{}\"",
726        label.replace('"', "#quot;").replace('\n', "<br/>")
727    )
728}
729
730/// A label written on an arrow, where the text runs to the end of the line: it cannot hold a
731/// newline, and in a flowchart it cannot hold the `|` that closes it.
732fn inline(label: &str) -> String {
733    label
734        .replace(['\n', '\r'], " ")
735        .replace('|', "/")
736        .trim()
737        .to_string()
738}
739
740/// One node's declaration in a flowchart, in the brackets its shape asks for.
741fn flow_node(node: &Node) -> String {
742    let label = quoted(&node.label);
743    match node.shape {
744        Shape::Box => format!("{}[{label}]", node.id),
745        Shape::Rounded => format!("{}({label})", node.id),
746        Shape::Stadium => format!("{}([{label}])", node.id),
747        Shape::Cylinder => format!("{}[({label})]", node.id),
748        Shape::Terminal => format!("{}(({label}))", node.id),
749    }
750}
751
752/// The outcome vocabulary `majordomus finish` accepts, read out of the shell source that
753/// enforces it.
754///
755/// This is the canonical statement of the set: the `case` arm in `lib/finish.sh` is what
756/// actually refuses an unknown outcome, so a drawing derived from anything else — a second
757/// list, a hand-written diagram — can disagree with the tool and look authoritative while
758/// doing it. `None` when the source carries no vocabulary this can recognise, because a
759/// wrong set silently drawn is worse than no drawing.
760///
761/// ```
762/// use majordomus_cli::diagram::outcomes;
763/// let shell = "  case \"$outcome\" in completed|partial|failed) ;;\n";
764/// assert_eq!(outcomes(shell), Some(vec!["completed".into(), "partial".into(), "failed".into()]));
765/// assert_eq!(outcomes("nothing of the sort"), None);
766/// ```
767pub fn outcomes(shell: &str) -> Option<Vec<String>> {
768    const MARKER: &str = "case \"$outcome\" in";
769    let after = shell.lines().find_map(|line| line.split_once(MARKER))?.1;
770    let words: Vec<String> = after
771        .split_once(')')?
772        .0
773        .split('|')
774        .map(|word| word.trim().to_string())
775        .collect();
776    let named = |word: &String| {
777        !word.is_empty() && word.chars().all(|c| c.is_ascii_lowercase() || c == '_')
778    };
779    if words.is_empty() || !words.iter().all(named) {
780        return None;
781    }
782    Some(words)
783}
784
785/// The task lifecycle as a typed diagram, derived from the text of `lib/finish.sh`.
786///
787/// The states a task can end in are not written here: they are [`outcomes`] of the source
788/// passed in, so an outcome added to the tool appears in every rendering of the lifecycle
789/// without anyone drawing it. The transitions that are not outcomes — start, checkpoint,
790/// handover — are the lifecycle the workflow defines, and they are stated once, here.
791///
792/// ```
793/// use majordomus_cli::diagram::{lifecycle, Kind};
794///
795/// let shell = "  case \"$outcome\" in completed|failed) ;;\n";
796/// let drawn = lifecycle(shell).expect("the vocabulary is readable");
797///
798/// assert_eq!(drawn.kind, Kind::State);
799/// assert_eq!(drawn.provenance.source, "lib/finish.sh");
800/// assert!(drawn.nodes.iter().any(|n| n.id == "failed"), "every outcome is a state");
801/// assert!(drawn.to_mermaid().contains("active --> failed: finish --outcome failed"));
802/// assert!(drawn.validate().is_empty());
803///
804/// assert!(lifecycle("no case arm here").is_none(), "no vocabulary, no diagram");
805/// ```
806pub fn lifecycle(shell: &str) -> Option<Diagram> {
807    let outcomes = outcomes(shell)?;
808    let mut drawn = Diagram::new(
809        "lifecycle",
810        "Task lifecycle",
811        Kind::State,
812        Provenance::new("lib/finish.sh", "the outcome vocabulary"),
813    )
814    .with_node(Node::new("entry", "entry").shaped(Shape::Terminal))
815    .with_node(Node::new("active", "active"))
816    .with_node(Node::new("handed_over", "handed_over"));
817    for outcome in &outcomes {
818        drawn = drawn.with_node(Node::new(outcome.clone(), outcome.clone()));
819    }
820    drawn = drawn
821        .with_node(Node::new("exit", "exit").shaped(Shape::Terminal))
822        .with_edge(Edge::new("entry", "active").labelled("start"))
823        .with_edge(Edge::new("active", "active").labelled("check --checkpoint"))
824        .with_edge(Edge::new("active", "handed_over").labelled("handover --close"))
825        .with_edge(Edge::new("handed_over", "exit").labelled("next start archives the record"));
826    for outcome in &outcomes {
827        drawn = drawn
828            .with_edge(
829                Edge::new("active", outcome.clone())
830                    .labelled(format!("finish --outcome {outcome}")),
831            )
832            .with_edge(Edge::new(outcome.clone(), "exit"));
833    }
834    Some(drawn)
835}
836
837#[cfg(test)]
838mod tests {
839    //! What a renderer must not do, as tests.
840    //!
841    //! They live in this file because `project.rust-command-tested-in-file` asks for it, and
842    //! because the quality scanner counts a module as behaviourally tested only from the
843    //! tests it can see beside the code.
844    use super::*;
845
846    /// The vocabulary `lib/finish.sh` carries in this repository, as the derivation reads it.
847    const FINISH_CASE: &str =
848        "  case \"$outcome\" in completed|partial|blocked|no_match|failed) ;;\n    \"\") mj_die\n";
849
850    /// A flowchart with a group, a shape of every kind and both edge styles.
851    fn pipeline() -> Diagram {
852        Diagram::new(
853            "pipeline",
854            "How this site is derived",
855            Kind::Flow,
856            Provenance::new("scripts/generate-site-data", "its declared inputs"),
857        )
858        .oriented(Orientation::LeftRight)
859        .with_node(Node::new("a", "share/skeleton"))
860        .with_node(Node::new("b", "docs + README"))
861        .with_node(Node::new("g", "generate-site-data").shaped(Shape::Stadium))
862        .with_node(Node::new("d", "site/data/generated").shaped(Shape::Cylinder))
863        .with_node(Node::new("p", "GitHub Pages").shaped(Shape::Rounded))
864        .with_group(Group::new("canonical", "canonical", ["a", "b"]))
865        .with_edge(Edge::new("a", "g"))
866        .with_edge(Edge::new("b", "g"))
867        .with_edge(Edge::new("g", "d").labelled("writes"))
868        .with_edge(Edge::new("d", "p"))
869        .with_edge(Edge::new("g", "p").dotted().labelled("stamps"))
870    }
871
872    #[test]
873    fn a_state_diagram_renders_the_dialect_and_writes_both_terminals_as_the_same_token() {
874        let drawn = lifecycle(FINISH_CASE).expect("the vocabulary parses");
875        let mermaid = drawn.to_mermaid();
876        assert!(mermaid.starts_with("stateDiagram-v2\n"), "{mermaid}");
877        assert!(mermaid.contains("    [*] --> active: start\n"), "{mermaid}");
878        assert!(mermaid.contains("\n    failed --> [*]"), "{mermaid}");
879        assert!(
880            !mermaid.contains("entry") && !mermaid.contains("exit"),
881            "the terminal ids are internal to the model: {mermaid}"
882        );
883        assert!(
884            !mermaid.ends_with('\n'),
885            "the renderer yields a value, not a file"
886        );
887    }
888
889    #[test]
890    fn a_flow_diagram_renders_every_shape_the_group_and_both_arrows() {
891        let mermaid = pipeline().to_mermaid();
892        assert_eq!(
893            mermaid,
894            concat!(
895                "flowchart LR\n",
896                "    subgraph canonical [\"canonical\"]\n",
897                "        a[\"share/skeleton\"]\n",
898                "        b[\"docs + README\"]\n",
899                "    end\n",
900                "    g([\"generate-site-data\"])\n",
901                "    d[(\"site/data/generated\")]\n",
902                "    p(\"GitHub Pages\")\n",
903                "    a --> g\n",
904                "    b --> g\n",
905                "    g -->|writes| d\n",
906                "    d --> p\n",
907                "    g -.->|stamps| p",
908            )
909        );
910    }
911
912    #[test]
913    fn rendering_is_deterministic() {
914        for drawn in [lifecycle(FINISH_CASE).expect("parses"), pipeline()] {
915            let once = drawn.to_mermaid();
916            assert_eq!(
917                once,
918                drawn.to_mermaid(),
919                "the same model renders the same bytes"
920            );
921            // and the model itself is a function of its source, not of the run
922            let round: Diagram =
923                serde_json::from_str(&serde_json::to_string(&drawn).expect("serialises"))
924                    .expect("deserialises");
925            assert_eq!(round, drawn, "the JSON projection loses nothing");
926            assert_eq!(round.to_mermaid(), once);
927        }
928        assert_eq!(
929            lifecycle(FINISH_CASE).map(|d| d.to_mermaid()),
930            lifecycle(FINISH_CASE).map(|d| d.to_mermaid()),
931            "two derivations from one source agree",
932        );
933    }
934
935    #[test]
936    fn a_model_with_no_nodes_renders_a_document_rather_than_a_bare_header() {
937        for kind in [Kind::Flow, Kind::State] {
938            let empty = Diagram::new("nothing", "Nothing", kind, Provenance::new("a", "b"));
939            let mermaid = empty.to_mermaid();
940            assert!(
941                mermaid.ends_with("\n    %% no nodes were derived"),
942                "{mermaid}"
943            );
944            assert!(
945                empty.validate().is_empty(),
946                "empty is not malformed, only empty"
947            );
948        }
949    }
950
951    #[test]
952    fn validate_names_what_a_renderer_must_never_be_the_first_to_notice() {
953        let bad = Diagram::new("bad", "Bad", Kind::State, Provenance::new("a", "b"))
954            .with_node(Node::new("a b", "A"))
955            .with_node(Node::new("end", "End"))
956            .with_node(Node::new("end", "End again"))
957            .with_group(Group::new("g", "G", ["ghost"]))
958            .with_edge(Edge::new("a b", "nowhere").dotted());
959        let found = bad.validate();
960        let joined = found.join("\n");
961        for expected in [
962            "is not a Mermaid identifier",
963            "is a Mermaid keyword",
964            "is declared twice",
965            "which is not a node",
966            "a state diagram cannot draw",
967            "flowchart concept",
968        ] {
969            assert!(
970                joined.contains(expected),
971                "missing {expected:?} in:\n{joined}"
972            );
973        }
974    }
975
976    #[test]
977    fn a_label_never_breaks_the_line_it_is_written_on() {
978        let drawn = Diagram::new("q", "Q", Kind::Flow, Provenance::new("a", "b"))
979            .with_node(Node::new("a", "a \"quoted\"\nlabel"))
980            .with_node(Node::new("b", "B"))
981            .with_edge(Edge::new("a", "b").labelled("one\ntwo | three"));
982        let mermaid = drawn.to_mermaid();
983        assert!(
984            mermaid.contains("a[\"a #quot;quoted#quot;<br/>label\"]"),
985            "{mermaid}"
986        );
987        assert!(mermaid.contains("a -->|one two / three| b"), "{mermaid}");
988        assert_eq!(
989            mermaid.lines().count(),
990            4,
991            "nothing spilled onto a new line"
992        );
993    }
994
995    #[test]
996    fn the_outcome_vocabulary_is_read_and_never_guessed() {
997        assert_eq!(
998            outcomes(FINISH_CASE),
999            Some(vec![
1000                "completed".into(),
1001                "partial".into(),
1002                "blocked".into(),
1003                "no_match".into(),
1004                "failed".into()
1005            ])
1006        );
1007        assert_eq!(
1008            outcomes("case \"$outcome\" in $VAR) ;;"),
1009            None,
1010            "a variable is not a word"
1011        );
1012        assert_eq!(
1013            outcomes("case \"$other\" in a|b) ;;"),
1014            None,
1015            "a different case is not it"
1016        );
1017        assert_eq!(lifecycle("nothing"), None);
1018    }
1019
1020    #[test]
1021    fn the_model_reproduces_the_lifecycle_the_site_already_publishes() {
1022        // The point of the slice: the typed model is not a second statement of the
1023        // lifecycle. It renders, byte for byte, what `scripts/generate-site-data`
1024        // composes by `printf` today — so replacing that producer changes no published
1025        // page, and everything else that wants this topology can stop re-typing it.
1026        let expected = concat!(
1027            "stateDiagram-v2\n",
1028            "    [*] --> active: start\n",
1029            "    active --> active: check --checkpoint\n",
1030            "    active --> handed_over: handover --close\n",
1031            "    handed_over --> [*]: next start archives the record\n",
1032            "    active --> completed: finish --outcome completed\n",
1033            "    completed --> [*]\n",
1034            "    active --> partial: finish --outcome partial\n",
1035            "    partial --> [*]\n",
1036            "    active --> blocked: finish --outcome blocked\n",
1037            "    blocked --> [*]\n",
1038            "    active --> no_match: finish --outcome no_match\n",
1039            "    no_match --> [*]\n",
1040            "    active --> failed: finish --outcome failed\n",
1041            "    failed --> [*]",
1042        );
1043        let source = std::fs::read_to_string(
1044            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../lib/finish.sh"),
1045        )
1046        .expect("the repository's own lib/finish.sh");
1047        let drawn = lifecycle(&source).expect("the real source carries the vocabulary");
1048        assert_eq!(drawn.to_mermaid(), expected);
1049        assert_eq!(drawn.title, "Task lifecycle");
1050    }
1051}