beck_core/
compat.rs

1//! `beck check --wire-compat` — boundary versioning, §4.3.
2//!
3//! [`docs/04-compiler-architecture.md`](../../../../../docs/04-compiler-architecture.md) §4.3, which
4//! calls this "a hard requirement, not a nicety":
5//!
6//! > During a rolling deploy, old clients talk to new servers. Rules: operation ids are
7//! > content-derived; a removed operation is retained as a deprecated shim for N releases; the wire
8//! > format is field-tagged and tolerates unknown fields; `beck check --wire-compat
9//! > <previous-release>` runs in CI and fails on a breaking change without an explicit `@breaking`
10//! > marker. Getting this wrong produces the failure that kills adoption — "the deploy worked but
11//! > every open browser tab broke."
12//!
13//! # What "compatible" means, precisely
14//!
15//! Not "the interface is unchanged" — that would make every release breaking, and a rule nobody can
16//! satisfy is a rule everybody turns off. It means: **during the window when both versions are
17//! live, neither can produce something the other cannot read.** There are three populations to keep
18//! honest and they have different answers, which is why one rule would be wrong:
19//!
20//! | direction | who writes | who reads | so |
21//! |---|---|---|---|
22//! | **command** | the old client | the new server | the new server must accept every old command |
23//! | **event** | both versions | the log, forever | neither may write what the other cannot fold |
24//! | **state** | the fold | its own snapshots | a change needs §3.9's `migrate` |
25//!
26//! From those three, every rule below follows. A **new command variant** is compatible: no old
27//! client sends it. A **removed command variant** is breaking: an old tab still has the button. A
28//! **new event variant** is *breaking* even though nothing old sends one — because §3.1's
29//! exhaustiveness check means an old fold, still running during the rollout, would have no case for
30//! it. That asymmetry between commands and events is the interesting part of this file, and it is
31//! not obvious from either type on its own; it comes from which side of the boundary each one
32//! crosses.
33//!
34//! # What this deliberately does not do
35//!
36//! It does not diff behaviour, and it does not know about `migrate`/`upcast` functions, which are
37//! §3.9's and Phase 4's. It compares two published contracts and classifies the differences. That
38//! is the check §4.3 asks CI to run, and it is worth being clear that a green `--wire-compat` says
39//! "no old client breaks", not "this deploy is safe".
40
41use std::collections::BTreeSet;
42use std::fmt;
43
44use crate::iface::{Interface, Item, Kind};
45use crate::ty::{Effect, ImplSig, Ty, TyDecl};
46
47/// How bad a change is.
48#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
49pub enum Severity {
50    /// Old and new can coexist.
51    Compatible,
52    /// Something that was live will stop working during the rollout.
53    Breaking,
54}
55
56/// One difference between two releases.
57#[derive(Clone, Debug, PartialEq, Eq)]
58pub struct Change {
59    pub severity: Severity,
60    /// What changed, by name.
61    pub what: String,
62    /// The change, in one line.
63    pub detail: String,
64    /// Why it is or is not safe, in the terms of the table above.
65    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
83/// Compare a previous release's interface with this one.
84pub 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
94/// Is this release shippable against that one?
95pub 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 &current.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
129/// Traits, which are read by the *importing* module and so break in the ordinary direction.
130///
131/// The asymmetry that makes events different from commands does not apply here: a trait is not
132/// something either side writes onto a wire, it is what a call site resolves against. So removing
133/// anything an importer might have named is breaking, and adding a trait is not — with one
134/// exception, which is why this function exists at all.
135fn 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        // The exception, and the one that is easy to get wrong: an *added* method is breaking even
166        // though nobody calls it yet, because every existing `impl` — including ones in modules
167        // this release cannot see — is now incomplete.
168        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 &current.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 &current.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    // A declaration is compared once, parameterised — not once per instantiation. `Tree[Int]` and
220    // `Tree[Str]` are different types wherever they are *mentioned*, and the field comparisons
221    // below see that difference because a field's type carries its arguments. What changes here is
222    // the shape of the name itself, and adding or removing a parameter changes every mention of it
223    // at once.
224    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                // The asymmetry this whole file exists to get right.
258                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        // §4.4: "the wire format is field-tagged and tolerates unknown fields". The reverse — a
340        // *missing* field — is what a required addition is, from an old writer's side.
341        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 &current.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    // §3.6: "effect widening is a breaking API change flagged by `beck check --api` — a library
418    // that starts phoning home cannot do so silently — a novel supply-chain property."
419    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        // Placement is part of the signature (§3.6), but moving code between tiers does not change
443        // what crosses the wire — the splitter re-synthesises the boundary either way.
444        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    /// The sketch with a new command variant, handled — because §3.1's exhaustiveness check makes
495    /// the *within-module* half of this a compile error, and only the cross-release half is left
496    /// for `--wire-compat` to catch.
497    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    /// The same, for an event — with the fold case an old deployment would not have.
509    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    /// The sketch with `Delete` gone from the command union, and from the validator with it.
521    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        // The asymmetry, which is the whole reason this is a check and not a diff. Nothing old
535        // *sends* a new event either — but an old fold is still running during the rollout, and
536        // §3.1's exhaustiveness means it has no case for one.
537        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        // §3.6's supply-chain property. It is only worth having if it fails the build.
577        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        // The reverse: the *previous* release performed it and the current one does not.
591        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        // The decision `sicp/refusals/generic-type.beck` asked for, as a test. A declaration is
629        // compared once, parameterised — so adding a parameter breaks every mention of it at once,
630        // and renaming one changes nothing anybody can observe because the fields refer to it
631        // positionally.
632        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    /// The sketch with a trait and an impl, so the trait rules have something to compare.
648    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        // The other direction: what this release added, nothing in the previous one could name.
675        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        // The asymmetry worth getting right, and the trait version of the event/command one this
688        // file exists for: every impl of the trait is now incomplete, including impls in modules
689        // this release cannot see.
690        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        // A CI gate that says "breaking" and nothing else gets turned off. Each classification here
727        // has to say which of the three populations it is protecting.
728        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}