Skip to main content

headwater_cli/
taxonomy_graph.rs

1// SPDX-License-Identifier: Apache-2.0
2//! `headwater taxonomy graph`: the resolved taxonomy as a Mermaid flowchart.
3//!
4//! The drawing is a function of the `resolved` block of the lock and of
5//! nothing else. Every lane, node and edge comes out of that block, so a
6//! release that adds a kind or moves a relation moves the drawing with it, and
7//! no list of kinds, purposes or relations is written down in this file.
8//! [HW-DR-0082](../../../../docs/decisions/0082-the-resolved-taxonomy-is-drawn-by-a-verb-that-prints-mermaid-and-writes-no-file.md)
9//! is why this is a verb that prints and not a projection that writes.
10//!
11//! **Mermaid, because the layout belongs to the renderer.** GitHub, MkDocs and
12//! most Markdown viewers lay out a Mermaid flowchart, so this file carries no
13//! layout and no position.
14//!
15//! **Two views, because one drawing cannot hold both questions.** The
16//! concrete view answers which kind can relate to which. The abstract view
17//! answers what an abstract kind gives the kinds under it. Both read
18//! `abstract: true` and `is_a` from the lock, and neither names a kind.
19//!
20//! The concrete view carries four rules.
21//!
22//! - One subgraph for each purpose, holding each concrete kind whose purpose
23//!   it is. A kind that names no purpose inherits the purpose of the nearest
24//!   kind it is declared `is_a`.
25//! - One hexagon for each anchor, drawn whether or not a relation reaches it.
26//! - One edge for each `(from, to)` pair of each relation, labeled with the
27//!   relation's name and styled by its family.
28//! - **No edge where either end is an abstract kind.** An abstract kind stands
29//!   for every concrete kind declared under it, so an edge to it would read as
30//!   an edge to a node nobody can write. The abstract view draws those pairs.
31//!
32//! The abstract view draws each abstract kind as a dashed node, each kind
33//! declared under one in the lane of its purpose, an `is_a` arrow from each of
34//! those kinds to the kind it is declared under, and the pairs of relations
35//! that have an abstract kind at one end. An anchor appears only where one of
36//! those pairs reaches it.
37//!
38//! **A relation from a kind to itself is no edge.** Mermaid draws such an edge
39//! as a long detour that two of them turn into a knot. Each view writes it on
40//! the node as one line that starts with `↻`, in the color that its
41//! family gives an edge, so the family still reads.
42//!
43//! A key is optional. It draws each shape and each edge style the drawing
44//! uses, and it names a family from the same list that colors the edges.
45//!
46//! The output is sorted throughout and carries no clock and no digest, so two
47//! runs over one lock write the same bytes.
48
49use std::collections::{BTreeMap, BTreeSet};
50
51use headwater_yaml::{Mapping, Spanned, Value};
52
53/// A stroke color for each family, taken in the sorted order of the families
54/// the lock declares. The list is a palette and names no family, so a family
55/// a release adds takes the next color and a ninth one repeats the first.
56const PALETTE: [&str; 8] = [
57    "#1f77b4", "#d62728", "#2ca02c", "#9467bd", "#ff7f0e", "#8c564b", "#e377c2", "#17becf",
58];
59
60/// The stroke of an `is_a` arrow, which is no relation and has no family.
61const IS_A: &str = "#888";
62
63/// One `(relation, from, to, family)` pair a relation declares.
64type Pair = (String, String, String, Option<String>);
65
66/// Which drawing of the lock to print. A variant carries no doc comment, because
67/// `clap` prints each one on its own line in a zsh completion script.
68#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
69pub enum View {
70    // The concrete kinds, the anchors and the relations between them.
71    #[default]
72    Concrete,
73    // The abstract kinds, the kinds under them and the relations that name one.
74    Abstract,
75}
76
77/// What the lock says, read once and shared by both views.
78struct Model<'a> {
79    kinds: Option<&'a Mapping>,
80    anchors: BTreeSet<String>,
81    abstracts: BTreeSet<String>,
82    /// purpose -> concrete kinds, with `None` for a kind no purpose reaches.
83    lanes: BTreeMap<Option<String>, BTreeSet<String>>,
84    families: BTreeSet<String>,
85    /// Pairs with no abstract kind at either end.
86    drawn: Vec<Pair>,
87    /// Pairs with an abstract kind at one end or both.
88    captioned: Vec<Pair>,
89}
90
91impl<'a> Model<'a> {
92    fn of(resolved: &'a Mapping) -> Self {
93        let purposes = keys(resolved, "purposes");
94        let anchors = keys(resolved, "anchors");
95        let kinds = map(resolved, "kinds");
96
97        let mut abstracts = BTreeSet::new();
98        let mut lanes: BTreeMap<Option<String>, BTreeSet<String>> = BTreeMap::new();
99        for purpose in &purposes {
100            lanes.entry(Some(purpose.clone())).or_default();
101        }
102        if let Some(kinds) = kinds {
103            for entry in kinds {
104                let name = entry.key.value.clone();
105                let declared = entry.value.value.as_map();
106                if declared.and_then(|k| scalar(k, "abstract")).as_deref() == Some("true") {
107                    abstracts.insert(name);
108                    continue;
109                }
110                lanes
111                    .entry(purpose_of(kinds, &name))
112                    .or_default()
113                    .insert(name);
114            }
115        }
116
117        let mut families: BTreeSet<String> = BTreeSet::new();
118        let mut drawn: Vec<Pair> = Vec::new();
119        let mut captioned: Vec<Pair> = Vec::new();
120        if let Some(relations) = map(resolved, "relations") {
121            for entry in relations {
122                let Some(relation) = entry.value.value.as_map() else {
123                    continue;
124                };
125                let name = entry.key.value.clone();
126                let family = scalar(relation, "family");
127                if let Some(family) = &family {
128                    families.insert(family.clone());
129                }
130                for from in names(relation.get("from")) {
131                    for to in names(relation.get("to")) {
132                        let pair = (name.clone(), from.clone(), to.clone(), family.clone());
133                        if abstracts.contains(&from) || abstracts.contains(&to) {
134                            captioned.push(pair);
135                        } else {
136                            drawn.push(pair);
137                        }
138                    }
139                }
140            }
141        }
142        drawn.sort();
143        drawn.dedup();
144        captioned.sort();
145        captioned.dedup();
146
147        Model {
148            kinds,
149            anchors,
150            abstracts,
151            lanes,
152            families,
153            drawn,
154            captioned,
155        }
156    }
157
158    fn concrete(&self) -> BTreeSet<String> {
159        self.lanes.values().flatten().cloned().collect()
160    }
161
162    fn node(&self, name: &str) -> String {
163        if self.anchors.contains(name) {
164            format!("anchor_{}", ident(name))
165        } else {
166            format!("kind_{}", ident(name))
167        }
168    }
169
170    fn color(&self, family: &str) -> &'static str {
171        let at = self.families.iter().position(|f| f == family).unwrap_or(0);
172        PALETTE[at % PALETTE.len()]
173    }
174}
175
176/// Draw the resolved taxonomy of `package` at `version`.
177pub fn render(
178    package: &str,
179    version: &str,
180    resolved: &Mapping,
181    view: View,
182    legend: bool,
183) -> String {
184    let model = Model::of(resolved);
185    match view {
186        View::Concrete => concrete_view(package, version, &model, legend),
187        View::Abstract => abstract_view(package, version, &model, legend),
188    }
189}
190
191/// The relations a node has to itself, each with the family that colors it.
192type Loops = BTreeMap<String, Vec<(String, Option<String>)>>;
193
194/// Split the pairs of a view into its edges and its loops. A pair whose two
195/// ends are one node is drawn by Mermaid as a detour that reads badly, so the
196/// view writes it on the node instead of drawing it.
197fn split_loops(pairs: &[Pair]) -> (Vec<Pair>, Loops) {
198    let mut edges = Vec::new();
199    let mut loops: Loops = BTreeMap::new();
200    for pair in pairs {
201        let (relation, from, to, family) = pair;
202        if from == to {
203            loops
204                .entry(from.clone())
205                .or_default()
206                .push((relation.clone(), family.clone()));
207        } else {
208            edges.push(pair.clone());
209        }
210    }
211    (edges, loops)
212}
213
214/// The text of a node: its label, then one line for each relation the node has
215/// to itself, in the color of the relation's family. A viewer that drops the
216/// style keeps the text.
217fn with_loops(model: &Model, text: String, name: &str, loops: &Loops) -> String {
218    let mut out = text;
219    for (relation, family) in loops.get(name).into_iter().flatten() {
220        let line = format!("\u{21bb} {}", label(relation));
221        match family {
222            Some(family) => out.push_str(&format!(
223                "<br/><span style='color:{}'>{line}</span>",
224                model.color(family)
225            )),
226            None => out.push_str(&format!("<br/>{line}")),
227        }
228    }
229    out
230}
231
232fn concrete_view(package: &str, version: &str, model: &Model, legend: bool) -> String {
233    let concrete = model.concrete();
234    let (edges, loops) = split_loops(&model.drawn);
235
236    // An endpoint that is neither a declared kind nor an anchor. A validated
237    // lock carries none, and one drawn outside every lane is a fact a reader
238    // can see rather than an edge that silently went missing.
239    let mut strays: BTreeSet<String> = BTreeSet::new();
240    for (_, from, to, _) in &model.drawn {
241        for end in [from, to] {
242            if !concrete.contains(end) && !model.anchors.contains(end) {
243                strays.insert(end.clone());
244            }
245        }
246    }
247
248    let kind_line = |kind: &String, indent: &str| -> String {
249        format!(
250            "{indent}kind_{}[\"{}\"]\n",
251            ident(kind),
252            with_loops(model, label(kind), kind, &loops)
253        )
254    };
255
256    let mut out = String::new();
257    out.push_str("flowchart LR\n");
258    out.push_str(&format!(
259        "%% The resolved taxonomy of {package} {version}, drawn by `headwater taxonomy graph` from .headwater/taxonomy.lock.\n"
260    ));
261    out.push_str(
262        "%% A lane is a purpose, a rectangle is a concrete kind, a hexagon is an anchor, and an edge is labeled with its relation.\n",
263    );
264    if !loops.is_empty() {
265        out.push_str(
266            "%% A line that starts with \u{21bb} inside a node is a relation from that node to itself, in the color of its family.\n",
267        );
268    }
269    if !model.captioned.is_empty() {
270        out.push_str(&format!(
271            "%% Not drawn here: a relation with an abstract kind ({}) at one end. `headwater taxonomy graph --view abstract` draws each one.\n",
272            join(&model.abstracts)
273        ));
274    }
275
276    for (purpose, members) in &model.lanes {
277        let Some(purpose) = purpose else { continue };
278        out.push_str(&format!(
279            "  subgraph purpose_{}[\"{}\"]\n",
280            ident(purpose),
281            label(purpose)
282        ));
283        for kind in members {
284            out.push_str(&kind_line(kind, "    "));
285        }
286        out.push_str("  end\n");
287    }
288    let unlaned = model.lanes.get(&None).into_iter().flatten();
289    for kind in unlaned.chain(strays.iter()) {
290        out.push_str(&kind_line(kind, "  "));
291    }
292    for anchor in &model.anchors {
293        out.push_str(&format!(
294            "  anchor_{}{{{{\"{}\"}}}}\n",
295            ident(anchor),
296            with_loops(model, label(anchor), anchor, &loops)
297        ));
298    }
299    for pair in &edges {
300        out.push_str(&edge(model, pair));
301    }
302
303    let mut styles = Vec::new();
304    let mut anchor_ids: Vec<String> = model.anchors.iter().map(|a| model.node(a)).collect();
305    if legend {
306        let shown = Shown {
307            abstract_kind: false,
308            anchor: !anchor_ids.is_empty(),
309            is_a: false,
310            families: present(&edges, &loops),
311        };
312        styles = key(&mut out, model, &shown, edges.len());
313        if shown.anchor {
314            anchor_ids.push("key_anchor".to_string());
315        }
316    }
317    if !anchor_ids.is_empty() {
318        out.push_str("  classDef anchor fill:#eee,stroke:#555,color:#222\n");
319        out.push_str(&format!("  class {} anchor\n", anchor_ids.join(",")));
320    }
321    family_styles(&mut out, model, &edges, 0);
322    for style in styles {
323        out.push_str(&style);
324    }
325    out
326}
327
328fn abstract_view(package: &str, version: &str, model: &Model, legend: bool) -> String {
329    let mut out = String::new();
330    out.push_str("flowchart LR\n");
331    if model.abstracts.is_empty() {
332        out.push_str(&format!(
333            "%% The resolved taxonomy of {package} {version} declares no abstract kind, so this view has nothing to draw.\n"
334        ));
335        return out;
336    }
337    let (edges, loops) = split_loops(&model.captioned);
338
339    // Every kind declared under an abstract kind, at any depth.
340    let members: BTreeSet<String> = model
341        .concrete()
342        .into_iter()
343        .filter(|name| under_abstract(model, name))
344        .collect();
345    let mut lanes: BTreeMap<Option<String>, BTreeSet<String>> = BTreeMap::new();
346    for name in &members {
347        let purpose = model.kinds.and_then(|kinds| purpose_of(kinds, name));
348        lanes.entry(purpose).or_default().insert(name.clone());
349    }
350
351    // `is_a` arrows from every abstract and every member kind, sorted.
352    let mut is_a: Vec<(String, String)> = Vec::new();
353    if let Some(kinds) = model.kinds {
354        for name in members.iter().chain(model.abstracts.iter()) {
355            let parent = kinds
356                .get(name)
357                .and_then(|node| node.value.as_map())
358                .and_then(|kind| scalar(kind, "is_a"));
359            if let Some(parent) = parent {
360                is_a.push((name.clone(), parent));
361            }
362        }
363    }
364    is_a.sort();
365
366    // An endpoint of a pair that is no abstract kind, no member and no anchor.
367    let mut strays: BTreeSet<String> = BTreeSet::new();
368    let mut reached: BTreeSet<String> = BTreeSet::new();
369    for (_, from, to, _) in &model.captioned {
370        for end in [from, to] {
371            if model.anchors.contains(end) {
372                reached.insert(end.clone());
373            } else if !members.contains(end) && !model.abstracts.contains(end) {
374                strays.insert(end.clone());
375            }
376        }
377    }
378
379    let kind_line = |kind: &String, indent: &str| -> String {
380        format!(
381            "{indent}kind_{}[\"{}\"]\n",
382            ident(kind),
383            with_loops(model, label(kind), kind, &loops)
384        )
385    };
386
387    out.push_str(&format!(
388        "%% The abstract kinds of {package} {version} and the kinds declared under them, drawn by `headwater taxonomy graph --view abstract` from .headwater/taxonomy.lock.\n"
389    ));
390    out.push_str(
391        "%% A dashed rectangle is an abstract kind, a dotted arrow is is_a, a hexagon is an anchor, and a solid edge is labeled with its relation.\n",
392    );
393    out.push_str(
394        "%% A relation that names an abstract kind is open to every kind declared under it.\n",
395    );
396    if !loops.is_empty() {
397        out.push_str(
398            "%% A line that starts with \u{21bb} inside a node is a relation from that node to itself, in the color of its family.\n",
399        );
400    }
401
402    for (purpose, kinds) in &lanes {
403        match purpose {
404            Some(purpose) => {
405                out.push_str(&format!(
406                    "  subgraph purpose_{}[\"{}\"]\n",
407                    ident(purpose),
408                    label(purpose)
409                ));
410                for kind in kinds {
411                    out.push_str(&kind_line(kind, "    "));
412                }
413                out.push_str("  end\n");
414            }
415            None => {
416                for kind in kinds {
417                    out.push_str(&kind_line(kind, "  "));
418                }
419            }
420        }
421    }
422    for kind in &strays {
423        out.push_str(&kind_line(kind, "  "));
424    }
425    for name in &model.abstracts {
426        let mut text = format!("{}<br/>abstract", label(name));
427        let required = model
428            .kinds
429            .and_then(|kinds| kinds.get(name))
430            .and_then(|node| node.value.as_map())
431            .and_then(|kind| map(kind, "facets"))
432            .map(|facets| names(facets.get("require")))
433            .unwrap_or_default();
434        if !required.is_empty() {
435            text.push_str(&format!("<br/>requires: {}", label(&required.join(", "))));
436        }
437        let text = with_loops(model, text, name, &loops);
438        out.push_str(&format!("  kind_{}[\"{text}\"]\n", ident(name)));
439    }
440    for anchor in &reached {
441        out.push_str(&format!(
442            "  anchor_{}{{{{\"{}\"}}}}\n",
443            ident(anchor),
444            with_loops(model, label(anchor), anchor, &loops)
445        ));
446    }
447    for (kind, parent) in &is_a {
448        out.push_str(&format!(
449            "  {} -.-> {}\n",
450            model.node(kind),
451            model.node(parent)
452        ));
453    }
454    for pair in &edges {
455        out.push_str(&edge(model, pair));
456    }
457
458    let mut styles = Vec::new();
459    let mut anchor_ids: Vec<String> = reached.iter().map(|a| model.node(a)).collect();
460    let mut abstract_ids: Vec<String> = model.abstracts.iter().map(|a| model.node(a)).collect();
461    let offset = is_a.len() + edges.len();
462    if legend {
463        let shown = Shown {
464            abstract_kind: true,
465            anchor: !anchor_ids.is_empty(),
466            is_a: !is_a.is_empty(),
467            families: present(&edges, &loops),
468        };
469        styles = key(&mut out, model, &shown, offset);
470        abstract_ids.push("key_abstract".to_string());
471        if shown.anchor {
472            anchor_ids.push("key_anchor".to_string());
473        }
474    }
475    out.push_str("  classDef abstract fill:#eee,stroke:#555,color:#222,stroke-dasharray:5 3\n");
476    out.push_str(&format!("  class {} abstract\n", abstract_ids.join(",")));
477    if !anchor_ids.is_empty() {
478        out.push_str("  classDef anchor fill:#eee,stroke:#555,color:#222\n");
479        out.push_str(&format!("  class {} anchor\n", anchor_ids.join(",")));
480    }
481    if !is_a.is_empty() {
482        let at: Vec<String> = (0..is_a.len()).map(|i| i.to_string()).collect();
483        out.push_str(&format!("  linkStyle {} stroke:{IS_A}\n", at.join(",")));
484    }
485    family_styles(&mut out, model, &edges, is_a.len());
486    for style in styles {
487        out.push_str(&style);
488    }
489    out
490}
491
492/// Whether a kind is declared, at any depth, under an abstract kind.
493fn under_abstract(model: &Model, name: &str) -> bool {
494    let Some(kinds) = model.kinds else {
495        return false;
496    };
497    let mut seen = BTreeSet::new();
498    let mut at = name.to_string();
499    while seen.insert(at.clone()) {
500        let parent = kinds
501            .get(&at)
502            .and_then(|node| node.value.as_map())
503            .and_then(|kind| scalar(kind, "is_a"));
504        let Some(parent) = parent else { return false };
505        if model.abstracts.contains(&parent) {
506            return true;
507        }
508        at = parent;
509    }
510    false
511}
512
513fn edge(model: &Model, (relation, from, to, _): &Pair) -> String {
514    format!(
515        "  {} -->|{}| {}\n",
516        model.node(from),
517        label(relation),
518        model.node(to)
519    )
520}
521
522/// The families that an edge or a loop line of a view carries, in the order
523/// the lock sorts them.
524fn present(edges: &[Pair], loops: &Loops) -> Vec<String> {
525    let of_edges = edges.iter().filter_map(|(_, _, _, family)| family.clone());
526    let of_loops = loops
527        .values()
528        .flatten()
529        .filter_map(|(_, family)| family.clone());
530    of_edges
531        .chain(of_loops)
532        .collect::<BTreeSet<_>>()
533        .into_iter()
534        .collect()
535}
536
537/// One `linkStyle` for each family that has an edge, after `offset` edges of
538/// another kind that came first.
539fn family_styles(out: &mut String, model: &Model, edges: &[Pair], offset: usize) {
540    for family in &model.families {
541        let at: Vec<String> = edges
542            .iter()
543            .enumerate()
544            .filter(|(_, (_, _, _, f))| f.as_deref() == Some(family.as_str()))
545            .map(|(at, _)| (offset + at).to_string())
546            .collect();
547        if at.is_empty() {
548            continue;
549        }
550        out.push_str(&format!("  %% family {family}\n"));
551        out.push_str(&format!(
552            "  linkStyle {} stroke:{}\n",
553            at.join(","),
554            model.color(family)
555        ));
556    }
557}
558
559/// What a key has to explain, because the drawing uses it.
560struct Shown {
561    abstract_kind: bool,
562    anchor: bool,
563    is_a: bool,
564    families: Vec<String>,
565}
566
567/// The key, drawn as a lane of its own. Its edges come after every edge of the
568/// drawing, so they take the indices from `start` on, and the `linkStyle`
569/// lines this returns belong after every other one.
570fn key(out: &mut String, model: &Model, shown: &Shown, start: usize) -> Vec<String> {
571    out.push_str("  subgraph key[\"key\"]\n    direction TB\n");
572    out.push_str("    key_kind[\"concrete kind\"]\n");
573    if shown.abstract_kind {
574        out.push_str("    key_abstract[\"abstract kind, stands for every kind under it\"]\n");
575    }
576    if shown.anchor {
577        out.push_str("    key_anchor{{\"anchor, a thing a document points at\"}}\n");
578    }
579    let mut entries: Vec<(String, &str, bool)> = Vec::new();
580    if shown.is_a {
581        entries.push(("is_a".to_string(), IS_A, true));
582    }
583    for family in &shown.families {
584        entries.push((family.clone(), model.color(family), false));
585    }
586    let mut styles = Vec::new();
587    for (index, (name, color, dotted)) in entries.iter().enumerate() {
588        let arrow = if *dotted { "-.->" } else { "-->" };
589        out.push_str(&format!(
590            "    key_a{index}[\" \"] {arrow}|{}| key_b{index}[\" \"]\n",
591            label(name)
592        ));
593        styles.push(format!("  linkStyle {} stroke:{color}\n", start + index));
594    }
595    out.push_str("  end\n");
596    styles
597}
598
599/// The purpose a kind declares, or the one the nearest kind above it declares.
600fn purpose_of(kinds: &Mapping, name: &str) -> Option<String> {
601    let mut seen = BTreeSet::new();
602    let mut at = name.to_string();
603    while seen.insert(at.clone()) {
604        let kind = kinds.get(&at)?.value.as_map()?;
605        if let Some(purpose) = scalar(kind, "purpose") {
606            return Some(purpose);
607        }
608        at = scalar(kind, "is_a")?;
609    }
610    None
611}
612
613fn map<'a>(of: &'a Mapping, key: &str) -> Option<&'a Mapping> {
614    of.get(key).and_then(|node| node.value.as_map())
615}
616
617fn keys(of: &Mapping, key: &str) -> BTreeSet<String> {
618    map(of, key)
619        .map(|m| m.iter().map(|e| e.key.value.clone()).collect())
620        .unwrap_or_default()
621}
622
623fn scalar(of: &Mapping, key: &str) -> Option<String> {
624    of.get(key)
625        .and_then(|node| node.value.as_scalar())
626        .map(|s| s.text.clone())
627}
628
629fn names(node: Option<&Spanned<Value>>) -> Vec<String> {
630    match node.map(|n| &n.value) {
631        Some(Value::Seq(items)) => items
632            .iter()
633            .filter_map(|item| item.value.as_scalar().map(|s| s.text.clone()))
634            .collect(),
635        Some(Value::Scalar(s)) => vec![s.text.clone()],
636        _ => Vec::new(),
637    }
638}
639
640fn join(names: &BTreeSet<String>) -> String {
641    names.iter().cloned().collect::<Vec<_>>().join(", ")
642}
643
644/// A name as a Mermaid identifier: every character outside `[A-Za-z0-9_]`
645/// becomes `_`. Every node carries a prefix, so no name meets a keyword.
646fn ident(name: &str) -> String {
647    name.chars()
648        .map(|c| match c.is_ascii_alphanumeric() || c == '_' {
649            true => c,
650            false => '_',
651        })
652        .collect()
653}
654
655/// A name inside a quoted Mermaid label.
656fn label(name: &str) -> String {
657    name.replace('"', "#quot;")
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663
664    fn resolved(text: &str) -> Mapping {
665        let loaded = headwater_yaml::load(text).expect("the fixture loads");
666        loaded.value.as_map().expect("a mapping").clone()
667    }
668
669    #[test]
670    fn a_kind_with_no_purpose_of_its_own_takes_the_one_above_it() {
671        let taxonomy = resolved(
672            "purposes:\n  p: {}\nkinds:\n  base:\n    purpose: p\n  child:\n    is_a: base\n",
673        );
674        let text = render("x", "1", &taxonomy, View::Concrete, false);
675        let lane: Vec<&str> = text
676            .lines()
677            .skip_while(|l| l.trim() != "subgraph purpose_p[\"p\"]")
678            .take_while(|l| l.trim() != "end")
679            .collect();
680        assert!(lane.contains(&"    kind_child[\"child\"]"), "{text}");
681    }
682
683    #[test]
684    fn a_lock_with_no_abstract_pair_carries_no_caption() {
685        let taxonomy = resolved(
686            "purposes:\n  p: {}\nkinds:\n  a:\n    purpose: p\n  b:\n    purpose: p\nrelations:\n  r:\n    family: f\n    from: [a]\n    to: [b]\n",
687        );
688        let text = render("x", "1", &taxonomy, View::Concrete, false);
689        assert!(!text.contains("note_abstract"), "{text}");
690        assert!(text.contains("  kind_a -->|r| kind_b\n"), "{text}");
691        assert!(text.contains("  linkStyle 0 stroke:#1f77b4\n"), "{text}");
692    }
693    #[test]
694    fn a_lock_with_no_abstract_kind_has_nothing_for_the_abstract_view_to_draw() {
695        let taxonomy = resolved(
696            "purposes:\n  p: {}\nkinds:\n  a:\n    purpose: p\nrelations:\n  r:\n    family: f\n    from: [a]\n    to: [a]\n",
697        );
698        let text = render("x", "1", &taxonomy, View::Abstract, true);
699        assert!(text.contains("declares no abstract kind"), "{text}");
700        assert!(
701            !text.contains("subgraph") && !text.contains("-->"),
702            "{text}"
703        );
704    }
705
706    #[test]
707    fn a_family_takes_one_color_in_both_views() {
708        let taxonomy = resolved(
709            "purposes:\n  p: {}\nkinds:\n  base:\n    abstract: true\n  a:\n    is_a: base\n    purpose: p\n  b:\n    is_a: base\n    purpose: p\nrelations:\n  s:\n    family: f\n    from: [a]\n    to: [b]\n  t:\n    family: g\n    from: [a]\n    to: [base]\n",
710        );
711        let concrete = render("x", "1", &taxonomy, View::Concrete, false);
712        let abstract_view = render("x", "1", &taxonomy, View::Abstract, false);
713        assert!(
714            concrete.contains("linkStyle 0 stroke:#1f77b4"),
715            "{concrete}"
716        );
717        assert!(
718            abstract_view.contains("linkStyle 2 stroke:#d62728"),
719            "family g is second in both views, after two is_a arrows here:\n{abstract_view}"
720        );
721    }
722    #[test]
723    fn a_relation_from_a_kind_to_itself_is_a_colored_line_on_the_kind() {
724        let taxonomy = resolved(
725            "purposes:\n  p: {}\nkinds:\n  a:\n    purpose: p\n  b:\n    purpose: p\nrelations:\n  f_first:\n    family: f\n    from: [a]\n    to: [a]\n  g_edge:\n    family: g\n    from: [a]\n    to: [b]\n",
726        );
727        let text = render("x", "1", &taxonomy, View::Concrete, true);
728        assert!(
729            !text.contains("-->|f_first|"),
730            "no edge for the loop:\n{text}"
731        );
732        assert!(
733            text.contains(
734                "    kind_a[\"a<br/><span style='color:#1f77b4'>\u{21bb} f_first</span>\"]\n"
735            ),
736            "{text}"
737        );
738        assert!(
739            text.contains("  linkStyle 0 stroke:#d62728\n"),
740            "the one edge takes index 0 and the color of its own family:\n{text}"
741        );
742        assert!(
743            text.contains("-->|f| key_b0") && text.contains("-->|g| key_b1"),
744            "a family carried by a loop line only is keyed:\n{text}"
745        );
746    }
747
748    #[test]
749    fn a_pair_with_no_family_is_a_line_with_no_color() {
750        let taxonomy = resolved(
751            "purposes:\n  p: {}\nkinds:\n  a:\n    purpose: p\nrelations:\n  r:\n    from: [a]\n    to: [a]\n",
752        );
753        let text = render("x", "1", &taxonomy, View::Concrete, false);
754        assert!(
755            text.contains("    kind_a[\"a<br/>\u{21bb} r\"]\n"),
756            "{text}"
757        );
758    }
759}