1use std::collections::BTreeSet;
42use std::fmt;
43
44use crate::iface::{Interface, Item, Kind};
45use crate::ty::{Effect, ImplSig, Ty, TyDecl};
46
47#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
49pub enum Severity {
50 Compatible,
52 Breaking,
54}
55
56#[derive(Clone, Debug, PartialEq, Eq)]
58pub struct Change {
59 pub severity: Severity,
60 pub what: String,
62 pub detail: String,
64 pub because: &'static str,
66}
67
68impl fmt::Display for Change {
69 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70 write!(
71 f,
72 "{} {}: {}",
73 match self.severity {
74 Severity::Compatible => "compatible",
75 Severity::Breaking => "BREAKING ",
76 },
77 self.what,
78 self.detail
79 )
80 }
81}
82
83pub fn compare(previous: &Interface, current: &Interface) -> Vec<Change> {
85 let mut out = Vec::new();
86 types(previous, current, &mut out);
87 traits(previous, current, &mut out);
88 impls(previous, current, &mut out);
89 items(previous, current, &mut out);
90 out.sort_by(|a, b| b.severity.cmp(&a.severity).then(a.what.cmp(&b.what)));
91 out
92}
93
94pub fn is_breaking(changes: &[Change]) -> bool {
96 changes.iter().any(|c| c.severity == Severity::Breaking)
97}
98
99fn find<'a>(iface: &'a Interface, name: &str) -> Option<&'a TyDecl> {
100 iface.types.iter().find(|t| t.name().as_ref() == name)
101}
102
103fn types(previous: &Interface, current: &Interface, out: &mut Vec<Change>) {
104 for old in &previous.types {
105 let name = old.name().to_string();
106 let Some(new) = find(current, &name) else {
107 out.push(Change {
108 severity: Severity::Breaking,
109 what: name.clone(),
110 detail: "removed".into(),
111 because: "a value of this type may already be in the log or in a live client",
112 });
113 continue;
114 };
115 compare_decl(&name, old, new, out);
116 }
117 for new in ¤t.types {
118 if find(previous, new.name()).is_none() {
119 out.push(Change {
120 severity: Severity::Compatible,
121 what: new.name().to_string(),
122 detail: "added".into(),
123 because: "nothing in the previous release refers to it",
124 });
125 }
126 }
127}
128
129fn traits(previous: &Interface, current: &Interface, out: &mut Vec<Change>) {
136 for old in &previous.traits {
137 let Some(new) = current.traits.iter().find(|t| t.name == old.name) else {
138 out.push(Change {
139 severity: Severity::Breaking,
140 what: old.name.to_string(),
141 detail: "trait removed".into(),
142 because: "an importing module's calls resolve against it, and a bound naming it \
143 stops type-checking",
144 });
145 continue;
146 };
147 for m in &old.methods {
148 match new.methods.iter().find(|x| x.name == m.name) {
149 None => out.push(Change {
150 severity: Severity::Breaking,
151 what: format!("{}.{}", old.name, m.name),
152 detail: "method removed".into(),
153 because: "a call to it in another module has nothing left to resolve to",
154 }),
155 Some(now) if now != m => out.push(Change {
156 severity: Severity::Breaking,
157 what: format!("{}.{}", old.name, m.name),
158 detail: "signature changed".into(),
159 because: "the caller and the implementation agree on this signature and \
160 nothing else, so changing it breaks both at once",
161 }),
162 Some(_) => {}
163 }
164 }
165 for m in &new.methods {
169 if !old.methods.iter().any(|x| x.name == m.name) {
170 out.push(Change {
171 severity: Severity::Breaking,
172 what: format!("{}.{}", old.name, m.name),
173 detail: "method added".into(),
174 because: "every impl of this trait is now incomplete, including the ones in \
175 modules this release cannot see",
176 });
177 }
178 }
179 }
180 for new in ¤t.traits {
181 if !previous.traits.iter().any(|t| t.name == new.name) {
182 out.push(Change {
183 severity: Severity::Compatible,
184 what: new.name.to_string(),
185 detail: "trait added".into(),
186 because: "nothing in the previous release refers to it",
187 });
188 }
189 }
190}
191
192fn impls(previous: &Interface, current: &Interface, out: &mut Vec<Change>) {
193 let key = |i: &ImplSig| format!("{} for {}", i.trait_name, i.head());
194 for old in &previous.impls {
195 if !current.impls.iter().any(|n| key(n) == key(old)) {
196 out.push(Change {
197 severity: Severity::Breaking,
198 what: key(old),
199 detail: "impl removed".into(),
200 because:
201 "a call in another module resolved to it, and coherence means there is no \
202 second one to fall back on",
203 });
204 }
205 }
206 for new in ¤t.impls {
207 if !previous.impls.iter().any(|o| key(o) == key(new)) {
208 out.push(Change {
209 severity: Severity::Compatible,
210 what: key(new),
211 detail: "impl added".into(),
212 because: "no previous call could have resolved to it",
213 });
214 }
215 }
216}
217
218fn compare_decl(name: &str, old: &TyDecl, new: &TyDecl, out: &mut Vec<Change>) {
219 if old.arity() != new.arity() {
225 out.push(Change {
226 severity: Severity::Breaking,
227 what: name.to_string(),
228 detail: format!(
229 "type parameters changed from {} to {}",
230 old.arity(),
231 new.arity()
232 ),
233 because: "every mention of this type has to be rewritten, so no old signature that \
234 names it still type-checks",
235 });
236 return;
237 }
238 match (old, new) {
239 (TyDecl::Union { variants: a, .. }, TyDecl::Union { variants: b, .. }) => {
240 let is_event = name == "Event";
241 let old_names: BTreeSet<&str> = a.iter().map(|v| v.name.as_ref()).collect();
242 let new_names: BTreeSet<&str> = b.iter().map(|v| v.name.as_ref()).collect();
243 for gone in old_names.difference(&new_names) {
244 out.push(Change {
245 severity: Severity::Breaking,
246 what: format!("{name}.{gone}"),
247 detail: "variant removed".into(),
248 because: if is_event {
249 "the log still holds these, and replay must reproduce state from the \
250 first event"
251 } else {
252 "an old client still has the button that sends it"
253 },
254 });
255 }
256 for added in new_names.difference(&old_names) {
257 out.push(if is_event {
259 Change {
260 severity: Severity::Breaking,
261 what: format!("{name}.{added}"),
262 detail: "variant added".into(),
263 because: "an old fold is still running during the rollout, and §3.1's \
264 exhaustiveness means it has no case for this",
265 }
266 } else {
267 Change {
268 severity: Severity::Compatible,
269 what: format!("{name}.{added}"),
270 detail: "variant added".into(),
271 because: "no old client sends it, and the new server understands it",
272 }
273 });
274 }
275 for v in b {
276 let Some(o) = a.iter().find(|x| x.name == v.name) else {
277 continue;
278 };
279 compare_fields(
280 &format!("{name}.{}", v.name),
281 &o.fields,
282 &v.fields,
283 is_event,
284 out,
285 );
286 }
287 }
288 (TyDecl::Model { fields: a, .. }, TyDecl::Model { fields: b, .. }) => {
289 compare_fields(name, a, b, false, out);
290 }
291 (TyDecl::Newtype { inner: a, .. }, TyDecl::Newtype { inner: b, .. })
292 | (TyDecl::Alias { ty: a, .. }, TyDecl::Alias { ty: b, .. }) => {
293 if a != b {
294 out.push(Change {
295 severity: Severity::Breaking,
296 what: name.to_string(),
297 detail: format!("changed from `{a}` to `{b}`"),
298 because: "the encoding of every value of this type changes with it",
299 });
300 }
301 }
302 _ => out.push(Change {
303 severity: Severity::Breaking,
304 what: name.to_string(),
305 detail: "changed kind".into(),
306 because: "a model and a union do not encode the same way",
307 }),
308 }
309}
310
311fn compare_fields(
312 what: &str,
313 old: &[(std::sync::Arc<str>, Ty)],
314 new: &[(std::sync::Arc<str>, Ty)],
315 is_event: bool,
316 out: &mut Vec<Change>,
317) {
318 for (name, ty) in old {
319 match new.iter().find(|(n, _)| n == name) {
320 None => out.push(Change {
321 severity: Severity::Breaking,
322 what: format!("{what}.{name}"),
323 detail: "field removed".into(),
324 because: "a reader of the old shape expects it to be there",
325 }),
326 Some((_, t)) if t != ty => out.push(Change {
327 severity: Severity::Breaking,
328 what: format!("{what}.{name}"),
329 detail: format!("type changed from `{ty}` to `{t}`"),
330 because: "the old and new encodings of this field disagree",
331 }),
332 Some(_) => {}
333 }
334 }
335 for (name, _) in new {
336 if old.iter().any(|(n, _)| n == name) {
337 continue;
338 }
339 out.push(Change {
342 severity: Severity::Breaking,
343 what: format!("{what}.{name}"),
344 detail: "field added".into(),
345 because: if is_event {
346 "an old event in the log has no value for it, so replay would have to invent one"
347 } else {
348 "an old client sends this without the field, so the new server has none to read"
349 },
350 });
351 }
352}
353
354fn items(previous: &Interface, current: &Interface, out: &mut Vec<Change>) {
355 for old in &previous.items {
356 let Some(new) = current.item(&old.name) else {
357 out.push(Change {
358 severity: Severity::Breaking,
359 what: old.name.to_string(),
360 detail: "removed".into(),
361 because: "§4.3 asks for a deprecated shim rather than a removal",
362 });
363 continue;
364 };
365 compare_item(old, new, out);
366 }
367 for new in ¤t.items {
368 if previous.item(&new.name).is_none() {
369 out.push(Change {
370 severity: Severity::Compatible,
371 what: new.name.to_string(),
372 detail: "added".into(),
373 because: "nothing in the previous release calls it",
374 });
375 }
376 }
377}
378
379fn compare_item(old: &Item, new: &Item, out: &mut Vec<Change>) {
380 let name = old.name.to_string();
381 match (&old.kind, &new.kind) {
382 (
383 Kind::Function {
384 params: a, ret: ra, ..
385 },
386 Kind::Function {
387 params: b, ret: rb, ..
388 },
389 ) => {
390 if a.len() != b.len() || a.iter().zip(b).any(|((_, x), (_, y))| x != y) || ra != rb {
391 out.push(Change {
392 severity: Severity::Breaking,
393 what: name.clone(),
394 detail: "signature changed".into(),
395 because: "a caller compiled against the old shape is still in the cluster",
396 });
397 }
398 }
399 (Kind::Signal { ty: a }, Kind::Signal { ty: b }) => {
400 if a != b {
401 out.push(Change {
402 severity: Severity::Breaking,
403 what: name.clone(),
404 detail: format!("type changed from `{a}` to `{b}`"),
405 because: "a subscriber resuming at a `seq` expects the shape it left",
406 });
407 }
408 }
409 _ => out.push(Change {
410 severity: Severity::Breaking,
411 what: name.clone(),
412 detail: "changed between a function and a signal".into(),
413 because: "one is called and the other subscribed to",
414 }),
415 }
416
417 let before: BTreeSet<&Effect> = old.effects.iter().collect();
420 let after: BTreeSet<&Effect> = new.effects.iter().collect();
421 let widened: Vec<String> = after.difference(&before).map(|e| e.name()).collect();
422 let narrowed: Vec<String> = before.difference(&after).map(|e| e.name()).collect();
423 if !widened.is_empty() {
424 out.push(Change {
425 severity: Severity::Breaking,
426 what: name.clone(),
427 detail: format!("effects widened: +{{{}}}", widened.join(", ")),
428 because: "a library that starts phoning home cannot do so silently — this is the \
429 supply-chain property, and it is only worth having if it fails the build",
430 });
431 }
432 if !narrowed.is_empty() {
433 out.push(Change {
434 severity: Severity::Compatible,
435 what: name.clone(),
436 detail: format!("effects narrowed: -{{{}}}", narrowed.join(", ")),
437 because: "doing less than promised breaks nobody",
438 });
439 }
440
441 if old.tier != new.tier {
442 out.push(Change {
445 severity: Severity::Compatible,
446 what: name,
447 detail: format!("moved from {} to {}", old.tier.name(), new.tier.name()),
448 because: "where code runs is a deployment change, not a wire change",
449 });
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456 use crate::compile_str;
457
458 fn iface(src: &str) -> Interface {
459 let (placed, d, map) = compile_str("todo.beck", src);
460 assert!(!d.has_errors(), "{}", d.render(&map));
461 Interface::of(&placed.expect("it compiles").program)
462 }
463
464 fn changes(edit: impl Fn(&str) -> String) -> Vec<Change> {
465 let before = iface(crate::split::tests::TODO);
466 let after = iface(&edit(crate::split::tests::TODO));
467 compare(&before, &after)
468 }
469
470 fn breaking_about(changes: &[Change], what: &str) -> bool {
471 changes
472 .iter()
473 .any(|c| c.severity == Severity::Breaking && c.what.contains(what))
474 }
475
476 #[test]
477 fn a_release_is_compatible_with_itself() {
478 let i = iface(crate::split::tests::TODO);
479 assert!(compare(&i, &i).is_empty());
480 assert!(!is_breaking(&compare(&i, &i)));
481 }
482
483 #[test]
484 fn a_body_edit_is_not_a_wire_change_at_all() {
485 let c = changes(|s| {
486 s.replace(
487 r#""done" if t.done else """#,
488 r#""done" if t.done else " ""#,
489 )
490 });
491 assert!(c.is_empty(), "{c:?}");
492 }
493
494 fn with_new_command(s: &str) -> String {
498 s.replace(
499 " Toggle(id: Id)\n Delete(id: Id)\n\nunion Event:",
500 " Toggle(id: Id)\n Delete(id: Id)\n Star(id: Id)\n\nunion Event:",
501 )
502 .replace(
503 " case Delete(id):\n return owned(s, p, id, [Deleted(id=id)])",
504 " case Delete(id):\n return owned(s, p, id, [Deleted(id=id)])\n case Star(id):\n return owned(s, p, id, [Toggled(id=id)])",
505 )
506 }
507
508 fn with_new_event(s: &str) -> String {
510 s.replace(
511 " Toggled(id: Id)\n Deleted(id: Id)",
512 " Toggled(id: Id)\n Deleted(id: Id)\n Starred(id: Id)",
513 )
514 .replace(
515 " case Deleted(id):\n return s.with(todos=map_remove(s.todos, id))",
516 " case Deleted(id):\n return s.with(todos=map_remove(s.todos, id))\n case Starred(id):\n return toggle(s, id)",
517 )
518 }
519
520 fn without_delete_command(s: &str) -> String {
522 s.replace(
523 " Toggle(id: Id)\n Delete(id: Id)\n\nunion Event:",
524 " Toggle(id: Id)\n\nunion Event:",
525 )
526 .replace(
527 " case Delete(id):\n return owned(s, p, id, [Deleted(id=id)])\n",
528 "",
529 )
530 }
531
532 #[test]
533 fn a_new_command_is_compatible_and_a_new_event_is_not() {
534 let added_command = changes(with_new_command);
538 assert!(!is_breaking(&added_command), "{added_command:?}");
539 assert!(
540 added_command
541 .iter()
542 .any(|c| c.what == "Command.Star" && c.severity == Severity::Compatible),
543 "{added_command:?}"
544 );
545
546 let added_event = changes(with_new_event);
547 assert!(
548 breaking_about(&added_event, "Event.Starred"),
549 "{added_event:?}"
550 );
551 }
552
553 #[test]
554 fn removing_a_command_variant_breaks_the_tab_that_still_has_the_button() {
555 let c = changes(without_delete_command);
556 assert!(breaking_about(&c, "Command.Delete"), "{c:?}");
557 }
558
559 #[test]
560 fn adding_a_field_to_an_event_is_breaking_because_the_log_has_no_value_for_it() {
561 let c = changes(|s| {
562 s.replace(
563 "union Event:\n Added(id: Id, text: Str)",
564 "union Event:\n Added(id: Id, text: Str, priority: Int)",
565 )
566 .replace(
567 "return Ok(value=[Added(id=id, text=text)])",
568 "return Ok(value=[Added(id=id, text=text, priority=0)])",
569 )
570 });
571 assert!(breaking_about(&c, "Event.Added.priority"), "{c:?}");
572 }
573
574 #[test]
575 fn widening_an_effect_is_breaking_and_narrowing_one_is_not() {
576 let widened = changes(|s| {
578 s.replace(
579 "def owned(s: State, p: Proposal, id: Id, evs: list[Event]) -> Result[list[Event], Rejection]:",
580 "def owned(s: State, p: Proposal, id: Id, evs: list[Event]) -> Result[list[Event], Rejection] uses net.out(audit.example.com):",
581 )
582 });
583 assert!(
584 widened.iter().any(|c| c.severity == Severity::Breaking
585 && c.detail.contains("effects widened")
586 && c.detail.contains("net.out(audit.example.com)")),
587 "{widened:?}"
588 );
589
590 let before = iface(&crate::split::tests::TODO.replace(
592 "def owned(s: State, p: Proposal, id: Id, evs: list[Event]) -> Result[list[Event], Rejection]:",
593 "def owned(s: State, p: Proposal, id: Id, evs: list[Event]) -> Result[list[Event], Rejection] uses net.out(audit.example.com):",
594 ));
595 let after = iface(crate::split::tests::TODO);
596 let narrowed = compare(&before, &after);
597 assert!(!is_breaking(&narrowed), "{narrowed:?}");
598 assert!(narrowed
599 .iter()
600 .any(|c| c.detail.contains("effects narrowed")));
601 }
602
603 #[test]
604 fn moving_code_between_tiers_is_not_a_wire_change() {
605 let c = changes(|s| s.replace("@on(data)\ntodos", "@on(server)\ntodos"));
606 assert!(!is_breaking(&c), "{c:?}");
607 assert!(
608 c.iter()
609 .any(|x| x.detail.contains("moved from data to server")),
610 "{c:?}"
611 );
612 }
613
614 #[test]
615 fn changing_the_state_type_is_breaking_because_snapshots_are_of_the_old_one() {
616 let c = changes(|s| {
617 s.replace(
618 "model State:\n todos: Map[Id, Todo]",
619 "model State:\n todos: Map[Id, Todo]\n revision: Int",
620 )
621 .replace("State(todos={})", "State(todos={}, revision=0)")
622 });
623 assert!(breaking_about(&c, "State.revision"), "{c:?}");
624 }
625
626 #[test]
627 fn giving_a_type_a_parameter_is_breaking_and_renaming_one_is_not() {
628 let c = changes(|s| {
633 s.replace("model Todo:", "model Todo[T]:")
634 .replace("todos: Map[Id, Todo]", "todos: Map[Id, Todo[Str]]")
635 .replace("-> Todo:", "-> Todo[Str]:")
636 .replace("(t: Todo)", "(t: Todo[Str])")
637 .replace("list[Todo]", "list[Todo[Str]]")
638 });
639 assert!(breaking_about(&c, "Todo"), "{c:?}");
640 assert!(
641 c.iter()
642 .any(|x| x.detail.contains("type parameters changed")),
643 "{c:?}"
644 );
645 }
646
647 const WITH_TRAIT: &str = "
649trait Labelled:
650 def label(self) -> Str
651
652impl Labelled for Todo:
653 def label(self):
654 return t_text(self)
655
656def t_text(t: Todo) -> Str:
657 return t.text
658";
659
660 fn trait_changes(edit: impl Fn(&str) -> String) -> Vec<Change> {
661 let base = format!("{}{WITH_TRAIT}", crate::split::tests::TODO);
662 let before = iface(&base);
663 let after = iface(&edit(&base));
664 compare(&before, &after)
665 }
666
667 #[test]
668 fn removing_a_trait_or_an_impl_is_breaking_and_adding_one_is_not() {
669 let gone = trait_changes(|s| s.replace(WITH_TRAIT, "\n"));
670 assert!(breaking_about(&gone, "Labelled"), "{gone:?}");
671 assert!(gone.iter().any(|c| c.detail == "trait removed"), "{gone:?}");
672 assert!(gone.iter().any(|c| c.detail == "impl removed"), "{gone:?}");
673
674 let added = {
676 let before = iface(crate::split::tests::TODO);
677 let after = iface(&format!("{}{WITH_TRAIT}", crate::split::tests::TODO));
678 compare(&before, &after)
679 };
680 assert!(!is_breaking(&added), "{added:?}");
681 assert!(added.iter().any(|c| c.detail == "trait added"), "{added:?}");
682 assert!(added.iter().any(|c| c.detail == "impl added"), "{added:?}");
683 }
684
685 #[test]
686 fn adding_a_method_to_a_trait_is_breaking_even_though_nobody_calls_it() {
687 let c = trait_changes(|s| {
691 s.replace(
692 " def label(self) -> Str
693",
694 " def label(self) -> Str
695 def short(self) -> Str
696",
697 )
698 .replace(
699 " def label(self):
700 return t_text(self)
701",
702 " def label(self):
703 return t_text(self)
704
705 def short(self):
706 return t_text(self)
707",
708 )
709 });
710 assert!(breaking_about(&c, "Labelled.short"), "{c:?}");
711 assert!(c.iter().any(|x| x.detail == "method added"), "{c:?}");
712 }
713
714 #[test]
715 fn changing_a_trait_methods_signature_is_breaking() {
716 let c = trait_changes(|s| {
717 s.replace("def label(self) -> Str", "def label(self) -> Int")
718 .replace("return t_text(self)", "return 1")
719 });
720 assert!(breaking_about(&c, "Labelled.label"), "{c:?}");
721 assert!(c.iter().any(|x| x.detail == "signature changed"), "{c:?}");
722 }
723
724 #[test]
725 fn every_change_carries_a_reason_someone_can_argue_with() {
726 let c = changes(with_new_event);
729 assert!(!c.is_empty());
730 for change in &c {
731 assert!(!change.because.is_empty(), "{change:?}");
732 assert!(!change.detail.is_empty(), "{change:?}");
733 }
734 }
735}