Skip to main content

asbuilt_core/
check.rs

1//! The drift check: the committed model against a fresh survey, as a
2//! unified diff when they differ, and as the changes behind it (an
3//! element added, a relation's items) when the committed model is one
4//! asbuilt wrote. Line endings are normalized first so a consumer with
5//! `autocrlf` gets a clean check.
6
7use similar::TextDiff;
8
9use crate::read::{Written, WrittenElement, WrittenRelation, read};
10
11/// What the comparison found.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum Outcome {
14    Current,
15    /// A unified diff from the committed text to the fresh one.
16    Drift(String),
17}
18
19pub(crate) fn normalize(text: &str) -> String {
20    text.replace("\r\n", "\n")
21}
22
23/// Compare the committed text with the fresh survey; `label` is the
24/// path shown in the diff header.
25pub fn compare(committed: &str, fresh: &str, label: &str) -> Outcome {
26    let committed = normalize(committed);
27    let fresh = normalize(fresh);
28    if committed == fresh {
29        return Outcome::Current;
30    }
31    let diff = TextDiff::from_lines(&committed, &fresh)
32        .unified_diff()
33        .header(&format!("a/{label}"), &format!("b/{label}"))
34        .to_string();
35    Outcome::Drift(diff)
36}
37
38/// One difference between the committed model and a fresh survey, named
39/// by what changed rather than which lines did. Ids are as the file
40/// writes them; kinds are the specification's keywords.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum Change {
43    ElementAdded {
44        id: String,
45        kind: String,
46    },
47    ElementRemoved {
48        id: String,
49        kind: String,
50    },
51    /// An element in both, with the fields that differ in the order emit
52    /// writes them; `kind` is the fresh one, `kind_was` the committed one
53    /// when it changed.
54    ElementChanged {
55        id: String,
56        kind: String,
57        kind_was: Option<String>,
58        fields: Vec<ElementField>,
59    },
60    RelationAdded {
61        from: String,
62        to: String,
63        kind: String,
64    },
65    RelationRemoved {
66        from: String,
67        to: String,
68        kind: String,
69    },
70    /// A relation in both: its fresh kind and the committed one when it
71    /// changed, whether its label changed and the names it gained and
72    /// lost, and whether its technology changed. The names are the label
73    /// split on `, ` (a surveyed relation's items, an external's title in
74    /// its pieces), so they describe a label change; a reorder or a repeat
75    /// changes the label with none gained or lost.
76    RelationChanged {
77        from: String,
78        to: String,
79        kind: String,
80        kind_was: Option<String>,
81        label: bool,
82        gained: Vec<String>,
83        lost: Vec<String>,
84        technology: bool,
85    },
86}
87
88/// An element field that can change, in the order emit writes them.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
90pub enum ElementField {
91    Kind,
92    Title,
93    Description,
94    Technology,
95    Path,
96    Tags,
97}
98
99impl ElementField {
100    /// The field's name in the `.c4` file.
101    pub fn name(self) -> &'static str {
102        match self {
103            ElementField::Kind => "kind",
104            ElementField::Title => "title",
105            ElementField::Description => "description",
106            ElementField::Technology => "technology",
107            ElementField::Path => "path",
108            ElementField::Tags => "tags",
109        }
110    }
111}
112
113/// The changes from the committed text to the fresh one, or `None` when
114/// either is not a model asbuilt wrote (the diff is then all there is).
115pub fn summarize(committed: &str, fresh: &str) -> Option<Vec<Change>> {
116    Some(changes(&read(committed)?, &read(fresh)?))
117}
118
119/// Every element added, removed or changed, then every relation, each in
120/// id order.
121pub fn changes(committed: &Written, fresh: &Written) -> Vec<Change> {
122    let mut out = Vec::new();
123    for (id, element) in &fresh.elements {
124        match committed.elements.get(id) {
125            None => out.push(Change::ElementAdded {
126                id: id.clone(),
127                kind: element.kind.clone(),
128            }),
129            Some(before) => {
130                let fields = element_fields(before, element);
131                if !fields.is_empty() {
132                    out.push(Change::ElementChanged {
133                        id: id.clone(),
134                        kind: element.kind.clone(),
135                        kind_was: (before.kind != element.kind).then(|| before.kind.clone()),
136                        fields,
137                    });
138                }
139            }
140        }
141    }
142    for (id, element) in &committed.elements {
143        if !fresh.elements.contains_key(id) {
144            out.push(Change::ElementRemoved {
145                id: id.clone(),
146                kind: element.kind.clone(),
147            });
148        }
149    }
150    sort_by_id(&mut out);
151    let mut relations = Vec::new();
152    for ((from, to), relation) in &fresh.relations {
153        let pair = (from.clone(), to.clone());
154        match committed.relations.get(&pair) {
155            None => relations.push(Change::RelationAdded {
156                from: from.clone(),
157                to: to.clone(),
158                kind: relation.kind.clone(),
159            }),
160            Some(before) => {
161                if let Some(change) = relation_change(from, to, before, relation) {
162                    relations.push(change);
163                }
164            }
165        }
166    }
167    for ((from, to), relation) in &committed.relations {
168        if !fresh.relations.contains_key(&(from.clone(), to.clone())) {
169            relations.push(Change::RelationRemoved {
170                from: from.clone(),
171                to: to.clone(),
172                kind: relation.kind.clone(),
173            });
174        }
175    }
176    sort_by_id(&mut relations);
177    out.extend(relations);
178    out
179}
180
181/// Element changes by id, relation changes by source then target.
182fn sort_by_id(changes: &mut [Change]) {
183    changes.sort_by(|a, b| key(a).cmp(&key(b)));
184}
185
186fn key(change: &Change) -> (&str, &str) {
187    match change {
188        Change::ElementAdded { id, .. }
189        | Change::ElementRemoved { id, .. }
190        | Change::ElementChanged { id, .. } => (id, ""),
191        Change::RelationAdded { from, to, .. }
192        | Change::RelationRemoved { from, to, .. }
193        | Change::RelationChanged { from, to, .. } => (from, to),
194    }
195}
196
197fn element_fields(before: &WrittenElement, after: &WrittenElement) -> Vec<ElementField> {
198    [
199        (ElementField::Kind, before.kind != after.kind),
200        (ElementField::Title, before.title != after.title),
201        (
202            ElementField::Description,
203            before.description != after.description,
204        ),
205        (
206            ElementField::Technology,
207            before.technology != after.technology,
208        ),
209        (ElementField::Path, before.path != after.path),
210        (ElementField::Tags, before.tags != after.tags),
211    ]
212    .into_iter()
213    .filter_map(|(field, differs)| differs.then_some(field))
214    .collect()
215}
216
217fn relation_change(
218    from: &str,
219    to: &str,
220    before: &WrittenRelation,
221    after: &WrittenRelation,
222) -> Option<Change> {
223    let names = |relation: &WrittenRelation| -> Vec<String> {
224        relation
225            .label
226            .as_deref()
227            .map(|label| label.split(", ").map(str::to_string).collect())
228            .unwrap_or_default()
229    };
230    let (old, new) = (names(before), names(after));
231    let gained: Vec<String> = new.iter().filter(|n| !old.contains(n)).cloned().collect();
232    let lost: Vec<String> = old.iter().filter(|n| !new.contains(n)).cloned().collect();
233    let kind_was = (before.kind != after.kind).then(|| before.kind.clone());
234    let label = before.label != after.label;
235    let technology = before.technology != after.technology;
236    if kind_was.is_none() && !label && !technology {
237        return None;
238    }
239    Some(Change::RelationChanged {
240        from: from.to_string(),
241        to: to.to_string(),
242        kind: after.kind.clone(),
243        kind_was,
244        label,
245        gained,
246        lost,
247        technology,
248    })
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn identical_text_is_current() {
257        assert_eq!(compare("a\nb\n", "a\nb\n", "m.c4"), Outcome::Current);
258    }
259
260    #[test]
261    fn crlf_in_the_committed_text_is_not_drift() {
262        assert_eq!(compare("a\r\nb\r\n", "a\nb\n", "m.c4"), Outcome::Current);
263    }
264
265    #[test]
266    fn a_changed_line_is_drift_with_both_versions_and_the_label_in_the_header() {
267        match compare("a\nb\nc\n", "a\nB\nc\n", "docs/model.c4") {
268            Outcome::Drift(diff) => {
269                assert!(
270                    diff.starts_with("--- a/docs/model.c4\n+++ b/docs/model.c4\n"),
271                    "{diff}"
272                );
273                assert!(diff.contains("\n-b\n"), "{diff}");
274                assert!(diff.contains("\n+B\n"), "{diff}");
275            }
276            other => panic!("expected Drift, got {other:?}"),
277        }
278    }
279
280    #[test]
281    fn an_added_line_is_drift() {
282        assert!(matches!(compare("a\n", "a\nb\n", "m"), Outcome::Drift(_)));
283    }
284
285    fn element(kind: &str, title: &str) -> WrittenElement {
286        WrittenElement {
287            kind: kind.into(),
288            title: title.into(),
289            ..Default::default()
290        }
291    }
292
293    fn relation(kind: &str, label: Option<&str>) -> WrittenRelation {
294        WrittenRelation {
295            kind: kind.into(),
296            label: label.map(str::to_string),
297            technology: None,
298        }
299    }
300
301    fn written(
302        elements: &[(&str, WrittenElement)],
303        relations: &[(&str, &str, WrittenRelation)],
304    ) -> Written {
305        Written {
306            elements: elements
307                .iter()
308                .map(|(id, e)| (id.to_string(), e.clone()))
309                .collect(),
310            relations: relations
311                .iter()
312                .map(|(from, to, r)| ((from.to_string(), to.to_string()), r.clone()))
313                .collect(),
314        }
315    }
316
317    fn base() -> Written {
318        written(
319            &[
320                ("app", element("container", "app")),
321                ("app.server", element("component", "server")),
322            ],
323            &[(
324                "app",
325                "app.server",
326                relation("calls", Some("Server, serve")),
327            )],
328        )
329    }
330
331    #[test]
332    fn identical_models_have_no_changes() {
333        assert_eq!(changes(&base(), &base()), vec![]);
334    }
335
336    #[test]
337    fn an_element_only_in_the_fresh_model_is_added() {
338        let mut fresh = base();
339        fresh
340            .elements
341            .insert("app.client".into(), element("component", "client"));
342        assert_eq!(
343            changes(&base(), &fresh),
344            vec![Change::ElementAdded {
345                id: "app.client".into(),
346                kind: "component".into()
347            }]
348        );
349    }
350
351    #[test]
352    fn an_element_only_in_the_committed_model_is_removed() {
353        let mut committed = base();
354        committed
355            .elements
356            .insert("app.old".into(), element("component", "old"));
357        assert_eq!(
358            changes(&committed, &base()),
359            vec![Change::ElementRemoved {
360                id: "app.old".into(),
361                kind: "component".into()
362            }]
363        );
364    }
365
366    #[test]
367    fn an_element_s_changed_fields_are_named_in_emit_s_order() {
368        let mut fresh = base();
369        let server = fresh.elements.get_mut("app.server").unwrap();
370        server.path = Some("app/src/server/mod.rs".into());
371        server.description = Some("Serves.".into());
372        assert_eq!(
373            changes(&base(), &fresh),
374            vec![Change::ElementChanged {
375                id: "app.server".into(),
376                kind: "component".into(),
377                kind_was: None,
378                fields: vec![ElementField::Description, ElementField::Path]
379            }]
380        );
381    }
382
383    #[test]
384    fn an_element_s_kind_change_names_both_kinds() {
385        let mut fresh = base();
386        fresh.elements.get_mut("app.server").unwrap().kind = "bin".into();
387        assert_eq!(
388            changes(&base(), &fresh),
389            vec![Change::ElementChanged {
390                id: "app.server".into(),
391                kind: "bin".into(),
392                kind_was: Some("component".into()),
393                fields: vec![ElementField::Kind]
394            }]
395        );
396    }
397
398    #[test]
399    fn a_relation_only_in_the_fresh_model_is_added() {
400        let mut fresh = base();
401        fresh.relations.insert(
402            ("app.server".into(), "app".into()),
403            relation("names", Some("App")),
404        );
405        assert_eq!(
406            changes(&base(), &fresh),
407            vec![Change::RelationAdded {
408                from: "app.server".into(),
409                to: "app".into(),
410                kind: "names".into()
411            }]
412        );
413    }
414
415    #[test]
416    fn a_relation_only_in_the_committed_model_is_removed() {
417        let mut fresh = base();
418        fresh.relations.clear();
419        assert_eq!(
420            changes(&base(), &fresh),
421            vec![Change::RelationRemoved {
422                from: "app".into(),
423                to: "app.server".into(),
424                kind: "calls".into()
425            }]
426        );
427    }
428
429    #[test]
430    fn a_relation_s_label_change_names_what_it_gained_and_lost() {
431        let mut fresh = base();
432        fresh.relations.insert(
433            ("app".into(), "app.server".into()),
434            relation("calls", Some("Server, start")),
435        );
436        assert_eq!(
437            changes(&base(), &fresh),
438            vec![Change::RelationChanged {
439                from: "app".into(),
440                to: "app.server".into(),
441                kind: "calls".into(),
442                kind_was: None,
443                label: true,
444                gained: vec!["start".into()],
445                lost: vec!["serve".into()],
446                technology: false
447            }]
448        );
449    }
450
451    #[test]
452    fn a_relation_s_kind_change_names_both_kinds() {
453        let mut fresh = base();
454        fresh.relations.insert(
455            ("app".into(), "app.server".into()),
456            relation("constructs", Some("Server, serve")),
457        );
458        assert_eq!(
459            changes(&base(), &fresh),
460            vec![Change::RelationChanged {
461                from: "app".into(),
462                to: "app.server".into(),
463                kind: "constructs".into(),
464                kind_was: Some("calls".into()),
465                label: false,
466                gained: vec![],
467                lost: vec![],
468                technology: false
469            }]
470        );
471    }
472
473    #[test]
474    fn a_relation_s_technology_change_is_flagged() {
475        let mut fresh = base();
476        fresh
477            .relations
478            .get_mut(&("app".to_string(), "app.server".to_string()))
479            .unwrap()
480            .technology = Some("stdio".into());
481        assert_eq!(
482            changes(&base(), &fresh),
483            vec![Change::RelationChanged {
484                from: "app".into(),
485                to: "app.server".into(),
486                kind: "calls".into(),
487                kind_was: None,
488                label: false,
489                gained: vec![],
490                lost: vec![],
491                technology: true
492            }]
493        );
494    }
495
496    #[test]
497    fn element_changes_come_first_in_id_order_then_relations() {
498        // The removed id sorts before the added one, which the order the
499        // two are found in would not give.
500        let mut fresh = base();
501        fresh.elements.remove("app.server");
502        fresh
503            .elements
504            .insert("app.z".into(), element("component", "z"));
505        fresh.relations.clear();
506        assert_eq!(
507            changes(&base(), &fresh),
508            vec![
509                Change::ElementRemoved {
510                    id: "app.server".into(),
511                    kind: "component".into()
512                },
513                Change::ElementAdded {
514                    id: "app.z".into(),
515                    kind: "component".into()
516                },
517                Change::RelationRemoved {
518                    from: "app".into(),
519                    to: "app.server".into(),
520                    kind: "calls".into()
521                },
522            ]
523        );
524    }
525
526    #[test]
527    fn a_committed_text_asbuilt_did_not_write_has_no_summary() {
528        assert_eq!(summarize("not a model\n", "not a model either\n"), None);
529    }
530
531    #[test]
532    fn a_label_that_changes_without_gaining_or_losing_a_name_is_a_change() {
533        let mut committed = base();
534        committed.relations.insert(
535            ("app".into(), "app.server".into()),
536            relation("uses", Some("reads, writes, writes")),
537        );
538        let mut fresh = base();
539        fresh.relations.insert(
540            ("app".into(), "app.server".into()),
541            relation("uses", Some("reads, writes")),
542        );
543        assert_eq!(
544            changes(&committed, &fresh),
545            vec![Change::RelationChanged {
546                from: "app".into(),
547                to: "app.server".into(),
548                kind: "uses".into(),
549                kind_was: None,
550                label: true,
551                gained: vec![],
552                lost: vec![],
553                technology: false
554            }]
555        );
556    }
557
558    #[test]
559    fn every_element_field_is_named_as_the_file_writes_it() {
560        assert_eq!(
561            [
562                ElementField::Kind,
563                ElementField::Title,
564                ElementField::Description,
565                ElementField::Technology,
566                ElementField::Path,
567                ElementField::Tags,
568            ]
569            .map(ElementField::name),
570            ["kind", "title", "description", "technology", "path", "tags"]
571        );
572    }
573}