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