Skip to main content

yo_shape/
diff.rs

1//! What changed, and whether it is safe (`15` sections 3.2 and 5).
2//!
3//! A tag comparison answers "the same or not". That answer on its own is the
4//! worst error message a database can produce, because the person reading it
5//! knows something moved and nothing about what. This module is the other
6//! half: it finds the first real difference between two descriptions, says it
7//! in a sentence, and says whether it is additive or breaking.
8
9use core::fmt;
10
11use yo_common::{Code, Error, Result};
12
13use crate::desc::Desc;
14use crate::parse::{Type, parse};
15
16/// The kinds of difference worth naming separately.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum ChangeKind {
19    /// One type became a different kind of type, or one primitive another.
20    TypeChanged,
21    /// A struct or an enum kept its shape and changed its name.
22    TypeRenamed,
23    /// A struct gained a field.
24    FieldAdded,
25    /// A struct lost a field.
26    FieldRemoved,
27    /// A field kept its position and changed its name.
28    FieldRenamed,
29    /// The same fields came back in a different order.
30    FieldsReordered,
31    /// A field kept its name and changed its type.
32    FieldTypeChanged,
33    /// An enum gained a variant.
34    VariantAdded,
35    /// An enum lost a variant.
36    VariantRemoved,
37    /// A variant kept its position and changed its name.
38    VariantRenamed,
39    /// A vector changed its width or the way it is compared.
40    VectorChanged,
41}
42
43impl ChangeKind {
44    /// Whether a change of this kind can be read through (`15` section 5).
45    ///
46    /// Only growth is additive: a new field reads as its default and a new
47    /// variant is simply never seen in old elements. Everything else either
48    /// moves bytes or drops them, and both need `migrate`.
49    ///
50    /// A rename annotated `#[yo(was = "old")]` is additive, and it is additive
51    /// because the annotation makes the description keep the old name, so it
52    /// never reaches this function as a rename at all.
53    #[must_use]
54    pub const fn is_additive(self) -> bool {
55        matches!(self, ChangeKind::FieldAdded | ChangeKind::VariantAdded)
56    }
57}
58
59/// The first difference between two shapes.
60///
61/// First rather than all: a shape mismatch is usually one edit, and a list of
62/// every consequence of that edit is harder to read than the edit.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Change {
65    kind: ChangeKind,
66    /// Where the owner sits in the shape, empty at the top.
67    path: String,
68    /// The struct or enum this is about, empty when the type has no name.
69    owner: String,
70    /// The field or variant this is about, empty when it is about a type.
71    subject: String,
72    stored: String,
73    opening: String,
74    position: Option<usize>,
75}
76
77impl Change {
78    /// What kind of difference this is.
79    #[must_use]
80    pub const fn kind(&self) -> ChangeKind {
81        self.kind
82    }
83
84    /// The path to the containing type, empty at the top.
85    #[must_use]
86    pub fn path(&self) -> &str {
87        &self.path
88    }
89
90    /// The struct or enum the difference is in, empty when the type has no
91    /// name of its own.
92    #[must_use]
93    pub fn owner(&self) -> &str {
94        &self.owner
95    }
96
97    /// The field or variant the difference is about, empty when it is about a
98    /// whole type.
99    #[must_use]
100    pub fn subject(&self) -> &str {
101        &self.subject
102    }
103
104    /// The stored side, rendered.
105    #[must_use]
106    pub fn stored(&self) -> &str {
107        &self.stored
108    }
109
110    /// The opening side, rendered.
111    #[must_use]
112    pub fn opening(&self) -> &str {
113        &self.opening
114    }
115
116    /// The field or variant position, where the difference has one.
117    #[must_use]
118    pub const fn position(&self) -> Option<usize> {
119        self.position
120    }
121
122    /// Whether this change can be read through without a migration.
123    #[must_use]
124    pub const fn is_additive(&self) -> bool {
125        self.kind.is_additive()
126    }
127}
128
129impl fmt::Display for Change {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        let n = self.position.unwrap_or_default();
132        match self.kind {
133            ChangeKind::TypeChanged => {
134                write!(
135                    f,
136                    "the type changed from {} to {}",
137                    self.stored, self.opening
138                )?;
139            }
140            ChangeKind::TypeRenamed => write!(
141                f,
142                "the {} was renamed from \"{}\" to \"{}\", and a name is part of the shape",
143                self.subject, self.stored, self.opening
144            )?,
145            ChangeKind::FieldAdded => write!(
146                f,
147                "struct {} gained field \"{}\" at position {n}",
148                self.owner, self.opening
149            )?,
150            ChangeKind::FieldRemoved => write!(
151                f,
152                "struct {} lost field \"{}\", which was at position {n}",
153                self.owner, self.stored
154            )?,
155            ChangeKind::FieldRenamed => write!(
156                f,
157                "struct {} renamed field {n} from \"{}\" to \"{}\"",
158                self.owner, self.stored, self.opening
159            )?,
160            ChangeKind::FieldsReordered => write!(
161                f,
162                "struct {} has the same fields in a different order, and field order is layout",
163                self.owner
164            )?,
165            ChangeKind::FieldTypeChanged => write!(
166                f,
167                "field \"{}\" of struct {} changed type from {} to {}",
168                self.subject, self.owner, self.stored, self.opening
169            )?,
170            ChangeKind::VariantAdded => write!(
171                f,
172                "enum {} gained variant \"{}\" at position {n}",
173                self.owner, self.opening
174            )?,
175            ChangeKind::VariantRemoved => write!(
176                f,
177                "enum {} lost variant \"{}\", which was at position {n}",
178                self.owner, self.stored
179            )?,
180            ChangeKind::VariantRenamed => write!(
181                f,
182                "enum {} renamed variant {n} from \"{}\" to \"{}\"",
183                self.owner, self.stored, self.opening
184            )?,
185            ChangeKind::VectorChanged => write!(
186                f,
187                "the vector changed from {} to {}",
188                self.stored, self.opening
189            )?,
190        }
191        // A named type says which one it is, so the path would only repeat
192        // itself. An unnamed one needs it to be findable at all.
193        if self.owner.is_empty() && !self.path.is_empty() {
194            write!(f, " (at {})", self.path)?;
195        }
196        Ok(())
197    }
198}
199
200/// Who created a collection, as the catalogue records it.
201///
202/// "Who wrote this and with what" is the first question when a diff is
203/// surprising, so the answer travels with the error rather than being one more
204/// thing to go and look up.
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct Provenance {
207    /// When the collection was created, however the catalogue spells a date.
208    pub created: String,
209    /// The SDK that created it, such as `yo-python`.
210    pub sdk: String,
211    /// That SDK's version.
212    pub version: String,
213}
214
215/// The first difference between two parsed shapes, or `None` if they agree.
216#[must_use]
217pub fn compare(stored: &Type, opening: &Type) -> Option<Change> {
218    walk(stored, opening, "")
219}
220
221/// The check a typed handle runs when it opens a collection.
222///
223/// An empty stored description means the collection was created over RESP3 and
224/// has no shape, which is not an error: it is checked per element instead
225/// (`15` section 3.3).
226///
227/// The compatibility list that makes an additive change open silently lives in
228/// the catalogue and arrives with it. Until then an additive change is
229/// reported like any other, and says that it is additive.
230///
231/// # Errors
232///
233/// [`Code::ShapeMismatch`], with both shapes rendered, the difference
234/// underlined, and `change=additive` or `change=breaking` in the detail.
235pub fn check(
236    collection: &str,
237    stored: &Desc,
238    opening: &Desc,
239    by: Option<&Provenance>,
240) -> Result<()> {
241    if stored.is_empty() || stored.as_bytes() == opening.as_bytes() {
242        return Ok(());
243    }
244    Err(mismatch(collection, stored, opening, by))
245}
246
247/// Build the mismatch error for two descriptions that differ.
248///
249/// Public because the wire path (`09` section 6) and the migration tool report
250/// the same thing from descriptions they already hold.
251#[must_use]
252pub fn mismatch(collection: &str, stored: &Desc, opening: &Desc, by: Option<&Provenance>) -> Error {
253    let (left, right) = (parse(stored), parse(opening));
254    let (Ok(left), Ok(right)) = (left, right) else {
255        // One of them will not parse, which is a different problem and not one
256        // to dress up as a shape diff.
257        return Error::fmt(
258            Code::Corrupt,
259            format_args!(
260                "collection \"{collection}\" has a stored shape this build cannot read: {}",
261                stored.as_text()
262            ),
263        );
264    };
265
266    let change = compare(&left, &right);
267    let (stored_line, opening_line) = (left.to_string(), right.to_string());
268    let credit = by.map_or_else(String::new, |p| {
269        format!(" (created {} by {} {})", p.created, p.sdk, p.version)
270    });
271
272    let mut message = format!(
273        "collection \"{collection}\" was created with a different shape\n\n  stored{credit}:\n      {stored_line}\n  opening:\n      {opening_line}\n"
274    );
275    if let Some(line) = underline(&stored_line, &opening_line) {
276        message.push_str(&line);
277        message.push('\n');
278    }
279    let additive = change.as_ref().is_some_and(Change::is_additive);
280    if let Some(change) = &change {
281        message.push_str(&format!("  {change}\n"));
282    }
283    message.push_str(if additive {
284        "\n  This is an additive change."
285    } else {
286        "\n  This is a breaking change, so it needs a migrate."
287    });
288
289    Error::new(Code::ShapeMismatch, message).with_detail(if additive {
290        "change=additive"
291    } else {
292        "change=breaking"
293    })
294}
295
296/// Tildes under the part of the opening line that is not in the stored line.
297///
298/// Character based rather than byte based, because a name can be any UTF-8 and
299/// an underline that is off by a few columns under a non-ASCII field name is
300/// worse than none.
301fn underline(stored: &str, opening: &str) -> Option<String> {
302    let left: Vec<char> = stored.chars().collect();
303    let right: Vec<char> = opening.chars().collect();
304
305    let prefix = left.iter().zip(&right).take_while(|(a, b)| a == b).count();
306    if prefix == left.len() && prefix == right.len() {
307        return None;
308    }
309    let suffix = left[prefix..]
310        .iter()
311        .rev()
312        .zip(right[prefix..].iter().rev())
313        .take_while(|(a, b)| a == b)
314        .count();
315
316    let width = right.len() - prefix - suffix;
317    // Nothing was added, only removed, so mark where it used to be.
318    let width = width.max(1);
319    let mut line = String::with_capacity(6 + prefix + width);
320    line.push_str("      ");
321    for _ in 0..prefix {
322        line.push(' ');
323    }
324    for _ in 0..width {
325        line.push('~');
326    }
327    Some(line)
328}
329
330fn join(path: &str, segment: &str) -> String {
331    if path.is_empty() {
332        segment.to_owned()
333    } else {
334        format!("{path}.{segment}")
335    }
336}
337
338fn walk(stored: &Type, opening: &Type, path: &str) -> Option<Change> {
339    match (stored, opening) {
340        (Type::Prim(a), Type::Prim(b)) if a == b => None,
341        (Type::Optional(a), Type::Optional(b)) => walk(a, b, path),
342        (Type::List(a), Type::List(b)) => walk(a, b, &join(path, "item")),
343        (Type::Map(ak, av), Type::Map(bk, bv)) => {
344            walk(ak, bk, &join(path, "key")).or_else(|| walk(av, bv, &join(path, "value")))
345        }
346        (Type::Ref(a), Type::Ref(b)) if a == b => None,
347        (
348            Type::Vector {
349                dim: ad,
350                metric: am,
351            },
352            Type::Vector {
353                dim: bd,
354                metric: bm,
355            },
356        ) => {
357            if ad == bd && am == bm {
358                None
359            } else {
360                Some(Change {
361                    kind: ChangeKind::VectorChanged,
362                    path: path.to_owned(),
363                    owner: String::new(),
364                    subject: String::new(),
365                    stored: format!("{ad} {am}"),
366                    opening: format!("{bd} {bm}"),
367                    position: None,
368                })
369            }
370        }
371        (
372            Type::Struct {
373                name: an,
374                fields: af,
375            },
376            Type::Struct {
377                name: bn,
378                fields: bf,
379            },
380        ) => {
381            if an != bn {
382                return Some(renamed("struct", an, bn, path));
383            }
384            fields(an, af, bf, path)
385        }
386        (
387            Type::Enum {
388                name: an,
389                variants: av,
390            },
391            Type::Enum {
392                name: bn,
393                variants: bv,
394            },
395        ) => {
396            if an != bn {
397                return Some(renamed("enum", an, bn, path));
398            }
399            variants(an, av, bv, path)
400        }
401        (a, b) if a == b => None,
402        (a, b) => Some(Change {
403            kind: ChangeKind::TypeChanged,
404            path: path.to_owned(),
405            owner: String::new(),
406            subject: String::new(),
407            stored: rendered(a),
408            opening: rendered(b),
409            position: None,
410        }),
411    }
412}
413
414fn renamed(what: &str, stored: &str, opening: &str, path: &str) -> Change {
415    Change {
416        kind: ChangeKind::TypeRenamed,
417        path: path.to_owned(),
418        owner: String::new(),
419        subject: what.to_owned(),
420        stored: stored.to_owned(),
421        opening: opening.to_owned(),
422        position: None,
423    }
424}
425
426/// A type as it appears inside a sentence, which is the same rendering the
427/// shape lines use.
428fn rendered(ty: &Type) -> String {
429    match ty {
430        Type::Struct { .. } => ty.name().unwrap_or_default().to_owned(),
431        other => other.to_string(),
432    }
433}
434
435fn fields(
436    owner: &str,
437    stored: &[(String, Type)],
438    opening: &[(String, Type)],
439    path: &str,
440) -> Option<Change> {
441    let names =
442        |list: &[(String, Type)]| -> Vec<String> { list.iter().map(|(n, _)| n.clone()).collect() };
443    let (a, b) = (names(stored), names(opening));
444
445    for i in 0..a.len().max(b.len()) {
446        match (a.get(i), b.get(i)) {
447            (Some(x), Some(y)) if x == y => {
448                let here = join(path, x);
449                if let Some(change) = walk(&stored[i].1, &opening[i].1, &here) {
450                    // A plain type swap right here reads better as a sentence
451                    // about the field than as one about a path.
452                    if change.kind == ChangeKind::TypeChanged && change.path == here {
453                        return Some(Change {
454                            kind: ChangeKind::FieldTypeChanged,
455                            path: path.to_owned(),
456                            owner: owner.to_owned(),
457                            subject: x.clone(),
458                            ..change
459                        });
460                    }
461                    return Some(change);
462                }
463            }
464            (s, o) => {
465                return Some(list_change(
466                    ChangeKind::FieldAdded,
467                    ChangeKind::FieldRemoved,
468                    ChangeKind::FieldRenamed,
469                    ChangeKind::FieldsReordered,
470                    owner,
471                    path,
472                    &a,
473                    &b,
474                    i,
475                    s,
476                    o,
477                ));
478            }
479        }
480    }
481    None
482}
483
484fn variants(owner: &str, stored: &[String], opening: &[String], path: &str) -> Option<Change> {
485    for i in 0..stored.len().max(opening.len()) {
486        let (s, o) = (stored.get(i), opening.get(i));
487        if s == o {
488            continue;
489        }
490        return Some(list_change(
491            ChangeKind::VariantAdded,
492            ChangeKind::VariantRemoved,
493            ChangeKind::VariantRenamed,
494            ChangeKind::FieldsReordered,
495            owner,
496            path,
497            stored,
498            opening,
499            i,
500            s,
501            o,
502        ));
503    }
504    None
505}
506
507/// The one piece of reasoning both lists share: at the first position where
508/// two name lists disagree, decide whether something was added, removed,
509/// renamed or only moved.
510#[expect(
511    clippy::too_many_arguments,
512    reason = "four kinds and two lists, all of which the caller has and this does not want to own"
513)]
514fn list_change(
515    added: ChangeKind,
516    removed: ChangeKind,
517    renamed_kind: ChangeKind,
518    reordered: ChangeKind,
519    owner: &str,
520    path: &str,
521    stored: &[String],
522    opening: &[String],
523    at: usize,
524    s: Option<&String>,
525    o: Option<&String>,
526) -> Change {
527    let has = |list: &[String], name: &String| list.iter().any(|n| n == name);
528    let kind = match (s, o) {
529        (None, Some(_)) => added,
530        (Some(_), None) => removed,
531        (Some(s), Some(o)) => {
532            let (kept, brought) = (has(opening, s), has(stored, o));
533            match (kept, brought) {
534                // The stored name is still there further along and the new one
535                // is genuinely new, so something was inserted.
536                (true, false) => added,
537                (false, true) => removed,
538                (true, true) => reordered,
539                (false, false) => renamed_kind,
540            }
541        }
542        (None, None) => unreachable!("the loop only reaches a position one of the lists has"),
543    };
544    Change {
545        kind,
546        path: path.to_owned(),
547        owner: owner.to_owned(),
548        subject: String::new(),
549        stored: s.cloned().unwrap_or_default(),
550        opening: o.cloned().unwrap_or_default(),
551        position: Some(at),
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558    use crate::desc::{Describe, Metric, Shape};
559
560    fn ty(build: impl FnOnce(&mut Desc)) -> Type {
561        parse(&desc(build)).expect("this description was just written")
562    }
563
564    fn desc(build: impl FnOnce(&mut Desc)) -> Desc {
565        let mut d = Desc::new();
566        build(&mut d);
567        d
568    }
569
570    fn order(fields: &[(&str, Describe)]) -> Desc {
571        let owned: Vec<(&str, Describe)> = fields.to_vec();
572        desc(move |d| d.strukt("Order", &owned))
573    }
574
575    #[test]
576    fn the_same_shape_has_no_change() {
577        let a = ty(|d| d.strukt("P", &[("x", u64::describe)]));
578        assert_eq!(compare(&a, &a), None);
579    }
580
581    #[test]
582    fn a_new_field_is_additive_and_says_where() {
583        let a = ty(|d| d.strukt("Order", &[("id", u64::describe)]));
584        let b = ty(|d| {
585            d.strukt("Order", &[("id", u64::describe), ("total", f64::describe)]);
586        });
587        let change = compare(&a, &b).expect("a field appeared");
588        assert_eq!(change.kind(), ChangeKind::FieldAdded);
589        assert_eq!(change.position(), Some(1));
590        assert!(change.is_additive());
591        assert_eq!(
592            change.to_string(),
593            "struct Order gained field \"total\" at position 1"
594        );
595    }
596
597    /// Inserted in the middle rather than appended, which is the case a naive
598    /// pairwise walk calls a rename.
599    #[test]
600    fn a_field_inserted_in_the_middle_is_still_an_addition() {
601        let a = ty(|d| {
602            d.strukt("Order", &[("id", u64::describe), ("total", f64::describe)]);
603        });
604        let b = ty(|d| {
605            d.strukt(
606                "Order",
607                &[
608                    ("id", u64::describe),
609                    ("customer", u64::describe),
610                    ("total", f64::describe),
611                ],
612            );
613        });
614        let change = compare(&a, &b).expect("a field appeared");
615        assert_eq!(change.kind(), ChangeKind::FieldAdded);
616        assert_eq!(change.position(), Some(1));
617        assert_eq!(change.opening(), "customer");
618    }
619
620    #[test]
621    fn a_lost_field_is_breaking() {
622        let a = order(&[("id", u64::describe), ("note", String::describe)]);
623        let b = order(&[("id", u64::describe)]);
624        let change = compare(&parse(&a).unwrap(), &parse(&b).unwrap()).expect("a field went");
625        assert_eq!(change.kind(), ChangeKind::FieldRemoved);
626        assert!(!change.is_additive());
627        assert_eq!(
628            change.to_string(),
629            "struct Order lost field \"note\", which was at position 1"
630        );
631    }
632
633    #[test]
634    fn a_reorder_is_named_as_a_reorder() {
635        let a = order(&[("id", u64::describe), ("total", f64::describe)]);
636        let b = order(&[("total", f64::describe), ("id", u64::describe)]);
637        let change = compare(&parse(&a).unwrap(), &parse(&b).unwrap()).expect("they moved");
638        assert_eq!(change.kind(), ChangeKind::FieldsReordered);
639        assert!(change.to_string().contains("field order is layout"));
640    }
641
642    #[test]
643    fn a_rename_is_a_rename_and_not_two_edits() {
644        let a = order(&[("id", u64::describe)]);
645        let b = order(&[("key", u64::describe)]);
646        let change = compare(&parse(&a).unwrap(), &parse(&b).unwrap()).expect("it was renamed");
647        assert_eq!(change.kind(), ChangeKind::FieldRenamed);
648        assert_eq!(
649            change.to_string(),
650            "struct Order renamed field 0 from \"id\" to \"key\""
651        );
652    }
653
654    #[test]
655    fn a_widened_field_says_both_types() {
656        let a = order(&[("id", u32::describe)]);
657        let b = order(&[("id", u64::describe)]);
658        let change = compare(&parse(&a).unwrap(), &parse(&b).unwrap()).expect("it widened");
659        assert_eq!(change.kind(), ChangeKind::FieldTypeChanged);
660        assert!(!change.is_additive());
661        assert_eq!(
662            change.to_string(),
663            "field \"id\" of struct Order changed type from u32 to u64"
664        );
665    }
666
667    #[test]
668    fn a_container_swap_is_a_type_change_with_a_path() {
669        let a = order(&[("tags", <Vec<String> as Shape>::describe)]);
670        let b = order(&[("tags", <Option<String> as Shape>::describe)]);
671        let change = compare(&parse(&a).unwrap(), &parse(&b).unwrap()).expect("it changed");
672        assert_eq!(change.kind(), ChangeKind::FieldTypeChanged);
673        assert_eq!(
674            change.to_string(),
675            "field \"tags\" of struct Order changed type from L str to O str"
676        );
677    }
678
679    /// The difference is two levels down, so the sentence names the inner type
680    /// and the path leads to it.
681    #[test]
682    fn a_difference_inside_a_list_is_found() {
683        let a = ty(|d| d.list(|d: &mut Desc| d.prim(crate::desc::Prim::U32)));
684        let b = ty(|d| d.list(|d: &mut Desc| d.prim(crate::desc::Prim::U64)));
685        let change = compare(&a, &b).expect("the element changed");
686        assert_eq!(change.kind(), ChangeKind::TypeChanged);
687        assert_eq!(
688            change.to_string(),
689            "the type changed from u32 to u64 (at item)"
690        );
691    }
692
693    #[test]
694    fn a_renamed_struct_is_reported_as_a_rename() {
695        let a = ty(|d| d.strukt("Order", &[("id", u64::describe)]));
696        let b = ty(|d| d.strukt("Purchase", &[("id", u64::describe)]));
697        let change = compare(&a, &b).expect("the name changed");
698        assert_eq!(change.kind(), ChangeKind::TypeRenamed);
699        assert!(change.to_string().starts_with("the struct was renamed"));
700    }
701
702    #[test]
703    fn a_new_variant_is_additive() {
704        let a = ty(|d| d.enumeration("Status", &["Open", "Paid", "Shipped"]));
705        let b = ty(|d| d.enumeration("Status", &["Open", "Paid", "Shipped", "Cancelled"]));
706        let change = compare(&a, &b).expect("a variant appeared");
707        assert_eq!(change.kind(), ChangeKind::VariantAdded);
708        assert!(change.is_additive());
709        assert_eq!(
710            change.to_string(),
711            "enum Status gained variant \"Cancelled\" at position 3"
712        );
713    }
714
715    #[test]
716    fn a_lost_variant_is_breaking() {
717        let a = ty(|d| d.enumeration("Status", &["Open", "Paid"]));
718        let b = ty(|d| d.enumeration("Status", &["Open"]));
719        let change = compare(&a, &b).expect("a variant went");
720        assert_eq!(change.kind(), ChangeKind::VariantRemoved);
721        assert!(!change.is_additive());
722    }
723
724    #[test]
725    fn a_vector_that_changed_metric_says_both() {
726        let a = ty(|d| d.vector(768, Metric::Cosine));
727        let b = ty(|d| d.vector(768, Metric::L2));
728        let change = compare(&a, &b).expect("the metric changed");
729        assert_eq!(change.kind(), ChangeKind::VectorChanged);
730        assert_eq!(
731            change.to_string(),
732            "the vector changed from 768 cosine to 768 l2"
733        );
734    }
735
736    #[test]
737    fn an_untyped_collection_opens_with_any_type() {
738        let opening = order(&[("id", u64::describe)]);
739        assert!(check("orders", &Desc::new(), &opening, None).is_ok());
740    }
741
742    #[test]
743    fn the_same_shape_opens() {
744        let a = order(&[("id", u64::describe)]);
745        let b = order(&[("id", u64::describe)]);
746        assert!(check("orders", &a, &b, None).is_ok());
747    }
748
749    /// The whole message, because the message is the feature. This is the
750    /// example in `15` section 3.2 with the parts that need a catalogue left
751    /// out.
752    #[test]
753    fn the_mismatch_message_shows_both_shapes_and_underlines_the_difference() {
754        fn open_status(d: &mut Desc) {
755            d.enumeration("Status", &["Open", "Paid", "Shipped"]);
756        }
757        fn cancelled_status(d: &mut Desc) {
758            d.enumeration("Status", &["Open", "Paid", "Shipped", "Cancelled"]);
759        }
760        let stored = order(&[("id", u64::describe), ("status", open_status)]);
761        let opening = order(&[("id", u64::describe), ("status", cancelled_status)]);
762        let by = Provenance {
763            created: "2026-08-01".into(),
764            sdk: "yo-python".into(),
765            version: "0.3.1".into(),
766        };
767
768        let e = check("orders", &stored, &opening, Some(&by)).expect_err("the shape moved");
769        assert_eq!(e.code(), Code::ShapeMismatch);
770        assert_eq!(e.detail(), Some("change=additive"));
771        assert_eq!(
772            e.message(),
773            "collection \"orders\" was created with a different shape\n\
774             \n\
775             \x20 stored (created 2026-08-01 by yo-python 0.3.1):\n\
776             \x20     Order { id: u64, status: E Status[Open,Paid,Shipped] }\n\
777             \x20 opening:\n\
778             \x20     Order { id: u64, status: E Status[Open,Paid,Shipped,Cancelled] }\n\
779             \x20                                                        ~~~~~~~~~~\n\
780             \x20 enum Status gained variant \"Cancelled\" at position 3\n\
781             \n\
782             \x20 This is an additive change."
783        );
784        assert!(
785            e.to_string()
786                .contains("https://yo.tamnd.dev/errors/shape-mismatch")
787        );
788    }
789
790    #[test]
791    fn a_breaking_message_says_so_and_says_migrate() {
792        let stored = order(&[("id", u64::describe)]);
793        let opening = order(&[("id", String::describe)]);
794        let e = check("orders", &stored, &opening, None).expect_err("the shape moved");
795        assert_eq!(e.detail(), Some("change=breaking"));
796        assert!(e.message().contains("needs a migrate"), "{e}");
797        assert!(e.message().contains("  stored:\n"), "{e}");
798    }
799
800    /// A removal has nothing to underline in the opening line, so the mark
801    /// goes where the removed part used to start rather than nowhere.
802    #[test]
803    fn a_removal_still_gets_a_mark() {
804        let line = underline("Order { id: u64, note: str }", "Order { id: u64 }").unwrap();
805        assert_eq!(line.trim_start(), "~");
806        assert_eq!(line.len(), 6 + "Order { id: u64".len() + 1);
807    }
808
809    #[test]
810    fn identical_lines_have_no_underline() {
811        assert_eq!(underline("Order { id: u64 }", "Order { id: u64 }"), None);
812    }
813
814    /// A description the file has and this build cannot read is a corruption,
815    /// not a shape difference, and it says so with the right code.
816    #[test]
817    fn an_unreadable_stored_shape_is_corruption() {
818        let e = check(
819            "orders",
820            &Desc::from_bytes(b"S\x05Ord".to_vec()),
821            &order(&[]),
822            None,
823        )
824        .expect_err("that is not a shape");
825        assert_eq!(e.code(), Code::Corrupt);
826        assert!(e.message().contains("cannot read"), "{e}");
827    }
828}