1use similar::TextDiff;
8
9use crate::read::{Written, WrittenElement, WrittenRelation, read};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum Outcome {
14 Current,
15 Drift(String),
17}
18
19pub(crate) fn normalize(text: &str) -> String {
20 text.replace("\r\n", "\n")
21}
22
23pub 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#[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 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 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#[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 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
113pub fn summarize(committed: &str, fresh: &str) -> Option<Vec<Change>> {
116 Some(changes(&read(committed)?, &read(fresh)?))
117}
118
119pub 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
181fn 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 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}