Skip to main content

asbuilt_core/
model.rs

1//! The architecture model every front-end produces and the emitter
2//! consumes.
3//!
4//! Ids are segment lists (`["playwright_rs", "server", "connection"]`),
5//! which LikeC4 renders as `playwright_rs.server.connection`. The model
6//! keeps real names; mapping them to LikeC4 identifiers happens in the
7//! emitter, and [`Model::validate`] is what says two names would
8//! collide there.
9
10use std::collections::BTreeMap;
11
12use crate::error::{Error, Result};
13
14/// A fully qualified element id: crate, then module path.
15pub type Id = Vec<String>;
16
17/// What a front-end found, plus the externals the config adds.
18#[derive(Debug, Clone, PartialEq, Eq, Default)]
19pub struct Model {
20    pub elements: Vec<Element>,
21    pub relations: Vec<Relation>,
22    pub deployment: Deployment,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Element {
27    pub id: Id,
28    pub kind: ElementKind,
29    pub title: String,
30    /// The first paragraph of the module's own docs, markdown kept.
31    pub description: Option<String>,
32    /// `library crate`, `binary crate`, `test crate` and the rest of the
33    /// front-end's labels, or whatever the config says for an external.
34    pub technology: Option<String>,
35    /// Relative to the surveyed root, `/`-separated on every platform.
36    /// `None` for an external.
37    pub path: Option<String>,
38    pub tags: Vec<String>,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42#[non_exhaustive]
43pub enum ElementKind {
44    /// A crate or package.
45    Container,
46    /// A module.
47    Component,
48    /// A binary's root beside a crate's lib: an entry point, not a module
49    /// of the lib. Also tagged `bin`.
50    Bin,
51    /// A crate's integration tests and benches, as one element. Also
52    /// tagged `tests`.
53    Tests,
54    /// A crate's examples, as one element. Also tagged `examples`.
55    Examples,
56    /// Something outside the code, from `[[externals]]`; carries the
57    /// LikeC4 element kind name (`process`, `browser`, ...).
58    External(String),
59}
60
61/// The kinds the survey generates. An external may not declare one of
62/// their names: it would be themed and read as the thing it is not.
63pub const SURVEY_KINDS: [ElementKind; 5] = [
64    ElementKind::Container,
65    ElementKind::Component,
66    ElementKind::Bin,
67    ElementKind::Tests,
68    ElementKind::Examples,
69];
70
71impl ElementKind {
72    /// The element kind declared in the LikeC4 specification: a
73    /// [`SURVEY_KINDS`] name, or an external's own kind name. `[theme]`
74    /// keys are these.
75    pub fn keyword(&self) -> &str {
76        match self {
77            ElementKind::Container => "container",
78            ElementKind::Component => "component",
79            ElementKind::Bin => "bin",
80            ElementKind::Tests => "tests",
81            ElementKind::Examples => "examples",
82            ElementKind::External(name) => name,
83        }
84    }
85}
86
87/// Evidence for a relation, strongest first. Aggregation keeps the
88/// minimum, so the declared order is the precedence and must not be
89/// reordered.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
91pub enum RelationKind {
92    Implements,
93    Constructs,
94    Calls,
95    NamesType,
96    Uses,
97}
98
99impl RelationKind {
100    /// The relationship kind declared in the LikeC4 specification.
101    pub fn keyword(self) -> &'static str {
102        match self {
103            RelationKind::Implements => "implements",
104            RelationKind::Constructs => "constructs",
105            RelationKind::Calls => "calls",
106            RelationKind::NamesType => "names",
107            RelationKind::Uses => "uses",
108        }
109    }
110
111    /// The line LikeC4 draws it with: solid for the strong evidence (it
112    /// implements, constructs or calls), dotted for the weak (it names a
113    /// type, or uses). An edge that merges relations of several kinds
114    /// keeps their line when they share one and is dashed (LikeC4's
115    /// default) when they do not.
116    pub(crate) fn line(self) -> &'static str {
117        match self {
118            RelationKind::Implements | RelationKind::Constructs | RelationKind::Calls => "solid",
119            RelationKind::NamesType | RelationKind::Uses => "dotted",
120        }
121    }
122
123    /// The arrowhead, one per kind, so kinds that share a line still
124    /// differ: a hollow triangle for implements (UML's for realizing an
125    /// interface), a filled diamond at the constructed end for constructs,
126    /// a chevron for calls, a filled dot for names, a hollow dot for uses.
127    /// No kind takes the filled triangle (`normal`, LikeC4's default
128    /// head): LikeC4 gives it to an edge merging relations of several
129    /// kinds, so it marks one whatever the line.
130    pub(crate) fn head(self) -> &'static str {
131        match self {
132            RelationKind::Implements => "onormal",
133            RelationKind::Constructs => "diamond",
134            RelationKind::Calls => "vee",
135            RelationKind::NamesType => "dot",
136            RelationKind::Uses => "odot",
137        }
138    }
139
140    /// Every kind, in precedence order; the emitter declares them all.
141    pub const ALL: [RelationKind; 5] = [
142        RelationKind::Implements,
143        RelationKind::Constructs,
144        RelationKind::Calls,
145        RelationKind::NamesType,
146        RelationKind::Uses,
147    ];
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct Relation {
152    pub source: Id,
153    pub target: Id,
154    pub kind: RelationKind,
155    /// The referenced item names, sorted and deduplicated; the label.
156    pub items: Vec<String>,
157    /// Externals only, from the config.
158    pub technology: Option<String>,
159}
160
161/// Deployment nodes and instances. Empty until an infrastructure
162/// front-end exists; it is here from day one because LikeC4 keeps a
163/// separate deployment model and retrofitting it would rework every
164/// front-end.
165#[derive(Debug, Clone, PartialEq, Eq, Default)]
166pub struct Deployment {
167    pub nodes: Vec<DeploymentNode>,
168    pub instances: Vec<DeploymentInstance>,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct DeploymentNode {
173    pub id: Id,
174    pub kind: String,
175    pub title: String,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct DeploymentInstance {
180    pub id: Id,
181    /// The model element deployed on the node this instance sits in.
182    pub element: Id,
183}
184
185/// Whether one id is an ancestor of, a descendant of, or equal to the
186/// other. `mod x;` and `pub use x::Y` are structure, not coupling, so a
187/// relation between lineal elements is never recorded.
188pub fn is_lineal(a: &[String], b: &[String]) -> bool {
189    a.starts_with(b) || b.starts_with(a)
190}
191
192/// An id as LikeC4 renders it, before sanitizing: segments joined by `.`.
193pub fn dotted(id: &[String]) -> String {
194    id.join(".")
195}
196
197/// The likec4 release whose grammar [`RESERVED`] was derived from. The
198/// CLI's tests hold it equal to the release they pin, so a bump fails
199/// until the list is re-derived: diff the keyword tokens of
200/// `packages/language-server/src/*.langium` between the two tags, probe
201/// any new word by validating a model that uses it as a nested element,
202/// then move this.
203pub const RESERVED_GRAMMAR_VERSION: &str = "1.59.4";
204
205/// Words likec4 refuses as an element id: every keyword token of the
206/// grammar at [`RESERVED_GRAMMAR_VERSION`] (extracted from the language
207/// server bundle) plus `this`, `it`, `true` and `false`, each confirmed
208/// by validating a model with the word as a nested element. `model`, `element`, `deployment` and
209/// `relationship` are accepted and so are not here. A module named one
210/// of these gets a trailing `_`.
211pub const RESERVED: &[&str] = &[
212    "BottomTop",
213    "LeftRight",
214    "RightLeft",
215    "TopBottom",
216    "and",
217    "autoLayout",
218    "border",
219    "color",
220    "deploymentNode",
221    "description",
222    "dynamic",
223    "dynamicPredicateGroup",
224    "exclude",
225    "extend",
226    "extends",
227    "false",
228    "from",
229    "global",
230    "head",
231    "icon",
232    "iconColor",
233    "iconPosition",
234    "iconSize",
235    "icons",
236    "import",
237    "include",
238    "includeAncestors",
239    "instanceOf",
240    "is",
241    "it",
242    "kind",
243    "likec4lib",
244    "line",
245    "link",
246    "metadata",
247    "multiple",
248    "navigateTo",
249    "not",
250    "notation",
251    "notes",
252    "of",
253    "opacity",
254    "or",
255    "order",
256    "padding",
257    "predicate",
258    "predicateGroup",
259    "rank",
260    "rgb",
261    "rgba",
262    "shape",
263    "size",
264    "specification",
265    "style",
266    "styleGroup",
267    "summary",
268    "tag",
269    "tail",
270    "technology",
271    "textSize",
272    "this",
273    "title",
274    "true",
275    "variant",
276    "view",
277    "views",
278    "where",
279    "with",
280];
281
282/// One segment as a LikeC4 identifier: `-` and any other character
283/// outside `[A-Za-z0-9_]` become `_`, a leading digit gets a `_` in
284/// front of it, and a [`RESERVED`] word gets one after it.
285pub fn sanitize_segment(segment: &str) -> String {
286    let mut out = String::with_capacity(segment.len() + 1);
287    if segment.chars().next().is_some_and(|c| c.is_ascii_digit()) {
288        out.push('_');
289    }
290    for c in segment.chars() {
291        out.push(if c.is_ascii_alphanumeric() || c == '_' {
292            c
293        } else {
294            '_'
295        });
296    }
297    if RESERVED.contains(&out.as_str()) {
298        out.push('_');
299    }
300    out
301}
302
303/// The whole id as a LikeC4 identifier path.
304pub fn sanitize_id(id: &[String]) -> String {
305    id.iter()
306        .map(|s| sanitize_segment(s))
307        .collect::<Vec<_>>()
308        .join(".")
309}
310
311impl Model {
312    /// Sort elements by id, merge relations on `(source, target)`
313    /// (strongest kind, union of items, first technology), and sort
314    /// them. Every path to the emitter goes through here, so output
315    /// order never depends on discovery order.
316    pub fn normalize(&mut self) {
317        self.elements.sort_by(|a, b| a.id.cmp(&b.id));
318
319        let mut merged: BTreeMap<(Id, Id), Relation> = BTreeMap::new();
320        for rel in self.relations.drain(..) {
321            let key = (rel.source.clone(), rel.target.clone());
322            match merged.get_mut(&key) {
323                None => {
324                    merged.insert(key, rel);
325                }
326                Some(existing) => {
327                    existing.kind = existing.kind.min(rel.kind);
328                    existing.items.extend(rel.items);
329                    if existing.technology.is_none() {
330                        existing.technology = rel.technology;
331                    }
332                }
333            }
334        }
335        self.relations = merged
336            .into_values()
337            .map(|mut rel| {
338                rel.items.sort();
339                rel.items.dedup();
340                rel
341            })
342            .collect();
343    }
344
345    /// Append another front-end's model and normalize.
346    pub fn merge(&mut self, other: Model) {
347        self.elements.extend(other.elements);
348        self.relations.extend(other.relations);
349        self.deployment.nodes.extend(other.deployment.nodes);
350        self.deployment.instances.extend(other.deployment.instances);
351        self.normalize();
352    }
353
354    /// Fail if two elements have the same id, or two ids that become the
355    /// same LikeC4 identifier.
356    pub fn validate(&self) -> Result<()> {
357        let mut seen: BTreeMap<String, &Id> = BTreeMap::new();
358        for element in &self.elements {
359            let key = sanitize_id(&element.id);
360            if let Some(first) = seen.insert(key.clone(), &element.id) {
361                return Err(Error::DuplicateId {
362                    id: key,
363                    first: dotted(first),
364                    second: dotted(&element.id),
365                });
366            }
367        }
368        Ok(())
369    }
370
371    pub fn element(&self, id: &[String]) -> Option<&Element> {
372        self.elements.iter().find(|e| e.id == id)
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    fn id(s: &str) -> Id {
381        s.split('.').map(str::to_string).collect()
382    }
383
384    fn element(s: &str) -> Element {
385        Element {
386            id: id(s),
387            kind: ElementKind::Component,
388            title: s.rsplit('.').next().unwrap().to_string(),
389            description: None,
390            technology: None,
391            path: None,
392            tags: vec![],
393        }
394    }
395
396    fn relation(source: &str, target: &str, kind: RelationKind, items: &[&str]) -> Relation {
397        Relation {
398            source: id(source),
399            target: id(target),
400            kind,
401            items: items.iter().map(|s| s.to_string()).collect(),
402            technology: None,
403        }
404    }
405
406    #[test]
407    fn precedence_is_the_declared_order_strongest_first() {
408        assert!(RelationKind::Implements < RelationKind::Constructs);
409        assert!(RelationKind::Constructs < RelationKind::Calls);
410        assert!(RelationKind::Calls < RelationKind::NamesType);
411        assert!(RelationKind::NamesType < RelationKind::Uses);
412    }
413
414    #[test]
415    fn the_surveyed_kinds_are_every_kind_but_external_in_specification_order() {
416        assert_eq!(
417            SURVEY_KINDS.map(|kind| kind.keyword().to_string()),
418            ["container", "component", "bin", "tests", "examples"]
419        );
420    }
421
422    #[test]
423    fn every_kind_has_a_distinct_keyword() {
424        let mut keywords: Vec<_> = RelationKind::ALL.iter().map(|k| k.keyword()).collect();
425        keywords.sort();
426        keywords.dedup();
427        assert_eq!(
428            keywords,
429            ["calls", "constructs", "implements", "names", "uses"]
430        );
431    }
432
433    #[test]
434    fn an_ancestor_and_its_descendant_are_lineal_in_both_directions() {
435        assert!(is_lineal(&id("a"), &id("a.b.c")));
436        assert!(is_lineal(&id("a.b.c"), &id("a")));
437    }
438
439    #[test]
440    fn an_element_is_lineal_with_itself() {
441        assert!(is_lineal(&id("a.b"), &id("a.b")));
442    }
443
444    #[test]
445    fn siblings_are_not_lineal() {
446        assert!(!is_lineal(&id("a.b"), &id("a.c")));
447    }
448
449    #[test]
450    fn a_shared_prefix_string_is_not_lineage() {
451        // `a.bc` is not under `a.b`; segments compare whole.
452        assert!(!is_lineal(&id("a.b"), &id("a.bc")));
453    }
454
455    #[test]
456    fn a_hyphen_becomes_an_underscore() {
457        assert_eq!(sanitize_segment("playwright-rs"), "playwright_rs");
458    }
459
460    #[test]
461    fn a_leading_digit_is_prefixed() {
462        assert_eq!(sanitize_segment("3d"), "_3d");
463    }
464
465    #[test]
466    fn a_reserved_word_gets_a_trailing_underscore() {
467        assert_eq!(sanitize_segment("view"), "view_");
468        assert_eq!(sanitize_segment("link"), "link_");
469        // `icons` was found the hard way: a playwright-rust module.
470        assert_eq!(sanitize_segment("icons"), "icons_");
471        assert_eq!(sanitize_segment("summary"), "summary_");
472    }
473
474    #[test]
475    fn model_and_element_are_not_reserved() {
476        // likec4 accepts both as ids (at `RESERVED_GRAMMAR_VERSION`); asbuilt-core's own `model`
477        // module keeps its name.
478        assert_eq!(sanitize_segment("model"), "model");
479        assert_eq!(sanitize_segment("element"), "element");
480    }
481
482    #[test]
483    fn the_reserved_list_is_sorted_and_unique() {
484        let mut sorted = RESERVED.to_vec();
485        sorted.sort_unstable();
486        sorted.dedup();
487        assert_eq!(sorted, RESERVED);
488    }
489
490    #[test]
491    fn a_plain_identifier_is_unchanged() {
492        assert_eq!(sanitize_segment("server_v2"), "server_v2");
493    }
494
495    #[test]
496    fn sanitizing_an_id_keeps_the_dots() {
497        assert_eq!(sanitize_id(&id("play-wright.a.1x")), "play_wright.a._1x");
498    }
499
500    #[test]
501    fn normalize_sorts_elements_by_id() {
502        let mut model = Model {
503            elements: vec![element("b"), element("a.z"), element("a")],
504            ..Default::default()
505        };
506        model.normalize();
507        let ids: Vec<_> = model.elements.iter().map(|e| dotted(&e.id)).collect();
508        assert_eq!(ids, ["a", "a.z", "b"]);
509    }
510
511    #[test]
512    fn normalize_merges_relations_on_the_pair_keeping_the_strongest_kind() {
513        let mut model = Model {
514            relations: vec![
515                relation("a", "b", RelationKind::Uses, &["Y"]),
516                relation("a", "b", RelationKind::Calls, &["X"]),
517            ],
518            ..Default::default()
519        };
520        model.normalize();
521        assert_eq!(
522            model.relations,
523            vec![relation("a", "b", RelationKind::Calls, &["X", "Y"])]
524        );
525    }
526
527    #[test]
528    fn normalize_dedups_items_within_a_relation() {
529        let mut model = Model {
530            relations: vec![relation("a", "b", RelationKind::Uses, &["Y", "X", "Y"])],
531            ..Default::default()
532        };
533        model.normalize();
534        assert_eq!(model.relations[0].items, ["X", "Y"]);
535    }
536
537    #[test]
538    fn normalize_sorts_relations_by_source_then_target() {
539        let mut model = Model {
540            relations: vec![
541                relation("b", "a", RelationKind::Uses, &[]),
542                relation("a", "c", RelationKind::Uses, &[]),
543                relation("a", "b", RelationKind::Uses, &[]),
544            ],
545            ..Default::default()
546        };
547        model.normalize();
548        let pairs: Vec<_> = model
549            .relations
550            .iter()
551            .map(|r| (dotted(&r.source), dotted(&r.target)))
552            .collect();
553        assert_eq!(
554            pairs,
555            [
556                ("a".into(), "b".into()),
557                ("a".into(), "c".into()),
558                ("b".into(), "a".into())
559            ]
560        );
561    }
562
563    #[test]
564    fn normalize_keeps_the_first_technology_seen() {
565        let mut first = relation("a", "b", RelationKind::Uses, &[]);
566        first.technology = Some("stdio".into());
567        let mut model = Model {
568            relations: vec![
569                relation("a", "b", RelationKind::Uses, &[]),
570                first,
571                relation("a", "b", RelationKind::Uses, &[]),
572            ],
573            ..Default::default()
574        };
575        model.normalize();
576        assert_eq!(model.relations[0].technology.as_deref(), Some("stdio"));
577    }
578
579    #[test]
580    fn merge_appends_and_normalizes() {
581        let mut model = Model {
582            elements: vec![element("b")],
583            ..Default::default()
584        };
585        model.merge(Model {
586            elements: vec![element("a")],
587            relations: vec![relation("a", "b", RelationKind::Uses, &["X"])],
588            ..Default::default()
589        });
590        let ids: Vec<_> = model.elements.iter().map(|e| dotted(&e.id)).collect();
591        assert_eq!(ids, ["a", "b"]);
592        assert_eq!(model.relations.len(), 1);
593    }
594
595    #[test]
596    fn two_ids_that_sanitize_alike_are_a_duplicate_naming_both() {
597        let model = Model {
598            elements: vec![element("foo-bar"), element("foo_bar")],
599            ..Default::default()
600        };
601        match model.validate() {
602            Err(Error::DuplicateId { id, first, second }) => {
603                assert_eq!(id, "foo_bar");
604                assert_eq!(first, "foo-bar");
605                assert_eq!(second, "foo_bar");
606            }
607            other => panic!("expected DuplicateId, got {other:?}"),
608        }
609    }
610
611    #[test]
612    fn an_id_that_appears_twice_is_a_duplicate() {
613        let model = Model {
614            elements: vec![element("app"), element("app")],
615            ..Default::default()
616        };
617        match model.validate() {
618            Err(Error::DuplicateId { id, first, second }) => {
619                assert_eq!(
620                    (id.as_str(), first.as_str(), second.as_str()),
621                    ("app", "app", "app")
622                );
623            }
624            other => panic!("expected DuplicateId, got {other:?}"),
625        }
626    }
627
628    #[test]
629    fn distinct_ids_validate() {
630        let model = Model {
631            elements: vec![element("a"), element("a.b"), element("b")],
632            ..Default::default()
633        };
634        assert!(model.validate().is_ok());
635    }
636
637    #[test]
638    fn element_finds_by_exact_id() {
639        let model = Model {
640            elements: vec![element("a"), element("a.b")],
641            ..Default::default()
642        };
643        assert_eq!(
644            model.element(&id("a.b")).map(|e| e.title.as_str()),
645            Some("b")
646        );
647        assert_eq!(model.element(&id("a.b.c")), None);
648    }
649
650    #[test]
651    fn each_relation_kind_is_drawn_with_its_line_and_head() {
652        assert_eq!(
653            RelationKind::ALL.map(|kind| (kind.keyword(), kind.line(), kind.head())),
654            [
655                ("implements", "solid", "onormal"),
656                ("constructs", "solid", "diamond"),
657                ("calls", "solid", "vee"),
658                ("names", "dotted", "dot"),
659                ("uses", "dotted", "odot"),
660            ]
661        );
662    }
663}