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}