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