1use core::fmt;
10
11use yo_common::{Code, Error, Result};
12
13use crate::desc::Desc;
14use crate::parse::{Type, parse};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum ChangeKind {
19 TypeChanged,
21 TypeRenamed,
23 FieldAdded,
25 FieldRemoved,
27 FieldRenamed,
29 FieldsReordered,
31 FieldTypeChanged,
33 VariantAdded,
35 VariantRemoved,
37 VariantRenamed,
39 VectorChanged,
41}
42
43impl ChangeKind {
44 #[must_use]
54 pub const fn is_additive(self) -> bool {
55 matches!(self, ChangeKind::FieldAdded | ChangeKind::VariantAdded)
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Change {
65 kind: ChangeKind,
66 path: String,
68 owner: String,
70 subject: String,
72 stored: String,
73 opening: String,
74 position: Option<usize>,
75}
76
77impl Change {
78 #[must_use]
80 pub const fn kind(&self) -> ChangeKind {
81 self.kind
82 }
83
84 #[must_use]
86 pub fn path(&self) -> &str {
87 &self.path
88 }
89
90 #[must_use]
93 pub fn owner(&self) -> &str {
94 &self.owner
95 }
96
97 #[must_use]
100 pub fn subject(&self) -> &str {
101 &self.subject
102 }
103
104 #[must_use]
106 pub fn stored(&self) -> &str {
107 &self.stored
108 }
109
110 #[must_use]
112 pub fn opening(&self) -> &str {
113 &self.opening
114 }
115
116 #[must_use]
118 pub const fn position(&self) -> Option<usize> {
119 self.position
120 }
121
122 #[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 if self.owner.is_empty() && !self.path.is_empty() {
194 write!(f, " (at {})", self.path)?;
195 }
196 Ok(())
197 }
198}
199
200#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct Provenance {
207 pub created: String,
209 pub sdk: String,
211 pub version: String,
213}
214
215#[must_use]
217pub fn compare(stored: &Type, opening: &Type) -> Option<Change> {
218 walk(stored, opening, "")
219}
220
221pub 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#[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 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
296fn 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 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
426fn 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 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#[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 (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 #[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 #[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 #[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 #[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 #[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}