Skip to main content

xabi_assert/
lib.rs

1use std::collections::BTreeMap;
2use std::fmt::Write as _;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7use xabi::{
8    XabiContractLayout, XabiLayout, XabiLayoutItem, XabiLayoutStability, XabiTypeLayout,
9    XabiVTableLayout,
10};
11
12/// Default directory used by [`assert_abi!`].
13pub const DEFAULT_SNAPSHOT_DIR: &str = "xabi/snapshots";
14
15/// Assert that a generated xabi contract layout matches its committed snapshot.
16///
17/// The first argument is a generated ABI type from `#[xabi::xabi]`. The optional
18/// second argument overrides the snapshot directory, which defaults to
19/// `xabi/snapshots`.
20///
21/// ```no_run
22/// # struct XabiV1AbiTraitDemo;
23/// # fn collect(_: &mut dyn xabi::XabiLayoutCollector) {}
24/// # impl XabiV1AbiTraitDemo {
25/// #     pub const XABI_LAYOUT: xabi::XabiLayout = xabi::XabiLayout {
26/// #         package: "demo",
27/// #         module: "demo",
28/// #         contract: xabi::XabiContractLayout::new("demo.Demo", 1, "demo::Demo"),
29/// #         collect,
30/// #     };
31/// # }
32/// xabi_assert::assert_abi!(XabiV1AbiTraitDemo, "xabi/snapshots");
33/// ```
34#[macro_export]
35macro_rules! assert_abi {
36    ($abi:path $(,)?) => {{
37        use $abi as __xabi_assert_abi;
38        $crate::assert_layout_in(
39            &__xabi_assert_abi::XABI_LAYOUT,
40            env!("CARGO_MANIFEST_DIR"),
41            $crate::DEFAULT_SNAPSHOT_DIR,
42        );
43    }};
44    ($abi:path, $snapshot_dir:expr $(,)?) => {{
45        use $abi as __xabi_assert_abi;
46        $crate::assert_layout_in(
47            &__xabi_assert_abi::XABI_LAYOUT,
48            env!("CARGO_MANIFEST_DIR"),
49            $snapshot_dir,
50        );
51    }};
52}
53
54/// Assert that a layout matches a snapshot directory.
55///
56/// `manifest_dir` is the Cargo manifest directory used as the base for relative
57/// snapshot paths. Snapshots are stored under
58/// `<snapshot-dir>/<contract-id>/<target>.txt`. Use [`assert_abi!`] in tests
59/// unless a caller needs custom path resolution.
60pub fn assert_layout_in(
61    layout: &XabiLayout,
62    manifest_dir: impl AsRef<Path>,
63    snapshot_dir: impl AsRef<Path>,
64) {
65    let manifest_dir = manifest_dir.as_ref();
66    let snapshot_dir = snapshot_dir.as_ref();
67    let target = target_triple();
68    let snapshot = collect_snapshot(layout, &target);
69    let snapshot_path = contract_snapshot_path(manifest_dir, snapshot_dir, &target, layout);
70    let actual = snapshot.render();
71
72    if std::env::var_os("XABI_UPDATE").is_some() {
73        if let Some(parent) = snapshot_path.parent() {
74            fs::create_dir_all(parent).unwrap_or_else(|err| {
75                panic!("failed to create {}: {err}", parent.display());
76            });
77        }
78        fs::write(&snapshot_path, actual).unwrap_or_else(|err| {
79            panic!("failed to write {}: {err}", snapshot_path.display());
80        });
81        return;
82    }
83
84    let expected = fs::read_to_string(&snapshot_path).unwrap_or_else(|err| {
85        panic!(
86            "failed to read ABI snapshot {}: {err}\nrun `XABI_UPDATE=1 cargo test` to create it",
87            snapshot_path.display()
88        );
89    });
90    let expected = normalize_line_endings(&expected);
91    let actual = normalize_line_endings(&actual);
92    if expected == actual {
93        return;
94    }
95
96    panic!("{}", mismatch_message(&snapshot_path, &expected, &actual));
97}
98
99fn collect_snapshot(layout: &XabiLayout, target: &str) -> Snapshot {
100    let mut items = Vec::new();
101    (layout.collect)(&mut items);
102    Snapshot::from_layout(layout.package, layout.contract, target, items)
103}
104
105fn contract_snapshot_path(
106    manifest_dir: &Path,
107    snapshot_dir: &Path,
108    target: &str,
109    layout: &XabiLayout,
110) -> PathBuf {
111    manifest_dir
112        .join(snapshot_dir)
113        .join(snapshot_component(layout.contract.abi_id))
114        .join(format!("{target}.txt"))
115}
116
117fn snapshot_component(value: &str) -> String {
118    let out = value
119        .chars()
120        .map(|ch| {
121            if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
122                ch
123            } else {
124                '_'
125            }
126        })
127        .collect::<String>();
128    if out.is_empty() {
129        "contract".to_string()
130    } else {
131        out
132    }
133}
134
135fn normalize_line_endings(value: &str) -> String {
136    value.replace("\r\n", "\n")
137}
138
139fn target_triple() -> String {
140    if let Ok(target) = std::env::var("XABI_TARGET") {
141        return target;
142    }
143
144    let output = Command::new("rustc")
145        .arg("-vV")
146        .output()
147        .unwrap_or_else(|err| panic!("failed to run rustc -vV: {err}"));
148    if !output.status.success() {
149        panic!(
150            "rustc -vV failed: {}",
151            String::from_utf8_lossy(&output.stderr)
152        );
153    }
154    let stdout = String::from_utf8(output.stdout)
155        .unwrap_or_else(|err| panic!("rustc -vV output is not UTF-8: {err}"));
156    stdout
157        .lines()
158        .find_map(|line| line.strip_prefix("host: ").map(str::to_string))
159        .unwrap_or_else(|| "rustc -vV did not report host triple".to_string())
160}
161
162fn mismatch_message(path: &Path, expected: &str, actual: &str) -> String {
163    let expected_lines = expected.lines().collect::<Vec<_>>();
164    let actual_lines = actual.lines().collect::<Vec<_>>();
165    let index = (0..expected_lines.len().max(actual_lines.len()))
166        .find(|index| expected_lines.get(*index) != actual_lines.get(*index));
167
168    let Some(index) = index else {
169        return format!("ABI snapshot mismatch: {}", path.display());
170    };
171    let compatibility = match compare_compatibility(expected, actual) {
172        Ok(()) => "append-only compatible; update the snapshot if this ABI change is intentional"
173            .to_string(),
174        Err(err) => format!("breaking or unparsable ABI change: {err}"),
175    };
176    format!(
177        "ABI snapshot mismatch at line {}\nexpected: {}\nactual:   {}\ncompatibility: {}\nrun `XABI_UPDATE=1 cargo test` only after intentionally changing the ABI",
178        index + 1,
179        expected_lines.get(index).copied().unwrap_or("<missing>"),
180        actual_lines.get(index).copied().unwrap_or("<missing>"),
181        compatibility,
182    )
183}
184
185fn compare_compatibility(expected: &str, actual: &str) -> Result<(), String> {
186    let expected = Snapshot::parse(expected)?;
187    let actual = Snapshot::parse(actual)?;
188
189    if expected.format != actual.format {
190        return Err(format!(
191            "snapshot format changed from {} to {}",
192            expected.format, actual.format
193        ));
194    }
195    if expected.package != actual.package {
196        return Err(format!(
197            "package changed from {} to {}",
198            expected.package, actual.package
199        ));
200    }
201    if expected.target != actual.target {
202        return Err(format!(
203            "target changed from {} to {}",
204            expected.target, actual.target
205        ));
206    }
207
208    let expected_contract = expected
209        .contract
210        .as_ref()
211        .ok_or_else(|| "expected snapshot contract is missing".to_string())?;
212    let actual_contract = actual
213        .contract
214        .as_ref()
215        .ok_or_else(|| "actual snapshot contract is missing".to_string())?;
216    if actual_contract.abi_id != expected_contract.abi_id {
217        return Err(format!(
218            "contract abi_id changed from {} to {}",
219            expected_contract.abi_id, actual_contract.abi_id,
220        ));
221    }
222    if actual_contract.abi_version != expected_contract.abi_version {
223        return Err(format!(
224            "contract {} abi_version changed from {} to {}",
225            expected_contract.abi_id, expected_contract.abi_version, actual_contract.abi_version,
226        ));
227    }
228    if actual_contract.rust_trait != expected_contract.rust_trait {
229        return Err(format!(
230            "contract {} rust trait changed from {} to {}",
231            expected_contract.abi_id, expected_contract.rust_trait, actual_contract.rust_trait,
232        ));
233    }
234
235    for (name, expected_ty) in &expected.types {
236        let actual_ty = actual
237            .types
238            .get(name)
239            .ok_or_else(|| format!("type {name} was removed"))?;
240        if actual_ty.stability != expected_ty.stability {
241            return Err(format!(
242                "type {name} stability changed from {} to {}",
243                expected_ty.stability.as_str(),
244                actual_ty.stability.as_str(),
245            ));
246        }
247        if actual_ty.align != expected_ty.align {
248            return Err(format!(
249                "type {name} alignment changed from {} to {}",
250                expected_ty.align, actual_ty.align
251            ));
252        }
253        match expected_ty.stability {
254            XabiLayoutStability::Fixed => {
255                if actual_ty.size != expected_ty.size {
256                    return Err(format!(
257                        "fixed type {name} size changed from {} to {}",
258                        expected_ty.size, actual_ty.size
259                    ));
260                }
261            }
262            XabiLayoutStability::Prefix => {
263                if actual_ty.size < expected_ty.size {
264                    return Err(format!(
265                        "prefix type {name} shrank from {} to {}",
266                        expected_ty.size, actual_ty.size
267                    ));
268                }
269            }
270        }
271        let actual_fields = actual_ty.field_map();
272        for expected_field in &expected_ty.fields {
273            let field_name = &expected_field.name;
274            let actual_field = actual_ty
275                .field_by_name(&actual_fields, field_name)
276                .ok_or_else(|| format!("type {name} field {field_name} was removed"))?;
277            if actual_field.offset != expected_field.offset || actual_field.ty != expected_field.ty
278            {
279                return Err(format!(
280                    "type {name} field {field_name} changed from offset={} type={} to offset={} type={}",
281                    expected_field.offset, expected_field.ty, actual_field.offset, actual_field.ty,
282                ));
283            }
284        }
285        if expected_ty.stability == XabiLayoutStability::Fixed
286            && actual_ty.fields.len() != expected_ty.fields.len()
287        {
288            return Err(format!("fixed type {name} field set changed"));
289        }
290        if expected_ty.stability == XabiLayoutStability::Prefix {
291            let expected_fields = expected_ty.field_map();
292            for field in &actual_ty.fields {
293                if !expected_fields.contains_key(field.name.as_str())
294                    && field.offset < expected_ty.size
295                {
296                    return Err(format!(
297                        "type {name} appended field {} at offset {} before old size {}",
298                        field.name, field.offset, expected_ty.size
299                    ));
300                }
301            }
302        }
303    }
304
305    for (name, expected_vtable) in &expected.vtables {
306        let actual_vtable = actual
307            .vtables
308            .get(name)
309            .ok_or_else(|| format!("vtable {name} was removed"))?;
310        if actual_vtable.full_size < expected_vtable.full_size {
311            return Err(format!(
312                "vtable {name} shrank from {} to {}",
313                expected_vtable.full_size, actual_vtable.full_size
314            ));
315        }
316        if actual_vtable.min_size > expected_vtable.min_size {
317            return Err(format!(
318                "vtable {name} minimum prefix grew from {} to {}",
319                expected_vtable.min_size, actual_vtable.min_size
320            ));
321        }
322    }
323
324    Ok(())
325}
326
327#[derive(Default)]
328struct Snapshot {
329    format: String,
330    package: String,
331    target: String,
332    contract: Option<ContractEntry>,
333    types: BTreeMap<String, TypeEntry>,
334    vtables: BTreeMap<String, VTableEntry>,
335}
336
337#[derive(Clone)]
338struct ContractEntry {
339    abi_id: String,
340    abi_version: u32,
341    rust_trait: String,
342}
343
344#[derive(Clone)]
345struct TypeEntry {
346    stability: XabiLayoutStability,
347    size: usize,
348    align: usize,
349    fields: Vec<FieldEntry>,
350}
351
352#[derive(Clone)]
353struct FieldEntry {
354    name: String,
355    offset: usize,
356    ty: String,
357}
358
359impl TypeEntry {
360    fn field_map(&self) -> BTreeMap<&str, &FieldEntry> {
361        self.fields
362            .iter()
363            .map(|field| (field.name.as_str(), field))
364            .collect()
365    }
366
367    fn field_by_name<'a>(
368        &'a self,
369        fields: &'a BTreeMap<&str, &FieldEntry>,
370        name: &str,
371    ) -> Option<&'a FieldEntry> {
372        fields.get(name).copied()
373    }
374}
375
376#[derive(Clone, Default)]
377struct VTableEntry {
378    full_size: usize,
379    min_size: usize,
380}
381
382enum SnapshotEntry {
383    Contract,
384    Type(String),
385    VTable(String),
386}
387
388impl Snapshot {
389    fn from_layout(
390        package: &str,
391        contract: XabiContractLayout,
392        target: &str,
393        items: Vec<XabiLayoutItem>,
394    ) -> Self {
395        let mut snapshot = Self {
396            format: "xabi-contract-snapshot-v1".to_string(),
397            package: package.to_string(),
398            contract: Some(ContractEntry {
399                abi_id: contract.abi_id.to_string(),
400                abi_version: contract.abi_version,
401                rust_trait: contract.rust_trait.to_string(),
402            }),
403            target: target.to_string(),
404            ..Self::default()
405        };
406
407        for item in items {
408            match item {
409                XabiLayoutItem::Type(ty) => snapshot.insert_type(ty),
410                XabiLayoutItem::VTable(vtable) => snapshot.insert_vtable(vtable),
411            }
412        }
413
414        snapshot
415    }
416
417    fn insert_type(&mut self, ty: XabiTypeLayout) {
418        let entry = TypeEntry {
419            stability: ty.stability,
420            size: ty.size,
421            align: ty.align,
422            fields: ty
423                .fields
424                .iter()
425                .map(|field| FieldEntry {
426                    name: field.name.to_string(),
427                    offset: field.offset,
428                    ty: field.ty.to_string(),
429                })
430                .collect(),
431        };
432        if let Some(existing) = self.types.insert(ty.name.to_string(), entry.clone()) {
433            assert_type_equal(ty.name, &existing, &entry);
434        }
435    }
436
437    fn insert_vtable(&mut self, vtable: XabiVTableLayout) {
438        let entry = VTableEntry {
439            full_size: vtable.full_size,
440            min_size: vtable.min_size,
441        };
442        if let Some(existing) = self.vtables.insert(vtable.name.to_string(), entry.clone()) {
443            assert_vtable_equal(vtable.name, &existing, &entry);
444        }
445    }
446
447    fn render(&self) -> String {
448        let mut out = String::new();
449        writeln!(out, "format={}", self.format).unwrap();
450        writeln!(out, "package={}", self.package).unwrap();
451        writeln!(out, "target={}", self.target).unwrap();
452        writeln!(out).unwrap();
453
454        if let Some(contract) = &self.contract {
455            writeln!(out, "contract {}", contract.abi_id).unwrap();
456            writeln!(out, "  abi_version={}", contract.abi_version).unwrap();
457            writeln!(out, "  rust_trait={}", contract.rust_trait).unwrap();
458            writeln!(out).unwrap();
459        }
460
461        for (name, ty) in &self.types {
462            writeln!(out, "type {name}").unwrap();
463            writeln!(out, "  stability={}", ty.stability.as_str()).unwrap();
464            writeln!(out, "  size={}", ty.size).unwrap();
465            writeln!(out, "  align={}", ty.align).unwrap();
466            for field in &ty.fields {
467                writeln!(
468                    out,
469                    "  field.{} offset={} type={}",
470                    field.name, field.offset, field.ty
471                )
472                .unwrap();
473            }
474            writeln!(out).unwrap();
475        }
476
477        for (name, vtable) in &self.vtables {
478            writeln!(out, "vtable {name}").unwrap();
479            writeln!(out, "  full_size={}", vtable.full_size).unwrap();
480            writeln!(out, "  min_size={}", vtable.min_size).unwrap();
481            writeln!(out).unwrap();
482        }
483
484        if out.ends_with("\n\n") {
485            out.pop();
486        }
487
488        out
489    }
490
491    fn parse(input: &str) -> Result<Self, String> {
492        let mut snapshot = Snapshot::default();
493        let mut entry = None;
494
495        for line in input.lines() {
496            if line.is_empty() {
497                entry = None;
498                continue;
499            }
500            if let Some(format) = line.strip_prefix("format=") {
501                snapshot.format = format.to_string();
502                continue;
503            }
504            if let Some(package) = line.strip_prefix("package=") {
505                snapshot.package = package.to_string();
506                continue;
507            }
508            if let Some(target) = line.strip_prefix("target=") {
509                snapshot.target = target.to_string();
510                continue;
511            }
512            if let Some(abi_id) = line.strip_prefix("contract ") {
513                if snapshot.contract.is_some() {
514                    return Err("snapshot contains multiple contract entries".to_string());
515                }
516                snapshot.contract = Some(ContractEntry {
517                    abi_id: abi_id.to_string(),
518                    abi_version: 0,
519                    rust_trait: String::new(),
520                });
521                entry = Some(SnapshotEntry::Contract);
522                continue;
523            }
524            if let Some(name) = line.strip_prefix("type ") {
525                snapshot.types.insert(
526                    name.to_string(),
527                    TypeEntry {
528                        stability: XabiLayoutStability::Prefix,
529                        size: 0,
530                        align: 0,
531                        fields: Vec::new(),
532                    },
533                );
534                entry = Some(SnapshotEntry::Type(name.to_string()));
535                continue;
536            }
537            if let Some(name) = line.strip_prefix("vtable ") {
538                snapshot
539                    .vtables
540                    .insert(name.to_string(), VTableEntry::default());
541                entry = Some(SnapshotEntry::VTable(name.to_string()));
542                continue;
543            }
544
545            let Some(entry) = &entry else {
546                return Err(format!("line outside snapshot entry: {line}"));
547            };
548            let trimmed = line.trim_start();
549            match entry {
550                SnapshotEntry::Contract => {
551                    parse_contract_line(
552                        snapshot
553                            .contract
554                            .as_mut()
555                            .expect("contract entry exists while parsing"),
556                        trimmed,
557                    )?;
558                }
559                SnapshotEntry::Type(name) => {
560                    parse_type_line(
561                        snapshot
562                            .types
563                            .get_mut(name)
564                            .expect("type entry exists while parsing"),
565                        trimmed,
566                    )?;
567                }
568                SnapshotEntry::VTable(name) => {
569                    parse_vtable_line(
570                        snapshot
571                            .vtables
572                            .get_mut(name)
573                            .expect("vtable entry exists while parsing"),
574                        trimmed,
575                    )?;
576                }
577            }
578        }
579
580        if snapshot.format.is_empty() {
581            return Err("snapshot format is missing".to_string());
582        }
583        if snapshot.package.is_empty() {
584            return Err("snapshot package is missing".to_string());
585        }
586        if snapshot.target.is_empty() {
587            return Err("snapshot target is missing".to_string());
588        }
589        let Some(contract) = snapshot.contract.as_ref() else {
590            return Err("snapshot contract is missing".to_string());
591        };
592        if contract.rust_trait.is_empty() {
593            return Err("snapshot contract rust_trait is missing".to_string());
594        }
595        Ok(snapshot)
596    }
597}
598
599fn parse_contract_line(entry: &mut ContractEntry, line: &str) -> Result<(), String> {
600    if let Some(version) = line.strip_prefix("abi_version=") {
601        entry.abi_version = parse_u32(version, "contract ABI version")?;
602        return Ok(());
603    }
604    if let Some(rust_trait) = line.strip_prefix("rust_trait=") {
605        entry.rust_trait = rust_trait.to_string();
606        return Ok(());
607    }
608    Err(format!("unsupported contract line: {line}"))
609}
610
611fn parse_type_line(layout: &mut TypeEntry, line: &str) -> Result<(), String> {
612    if let Some(value) = line.strip_prefix("stability=") {
613        layout.stability = parse_stability(value)?;
614        return Ok(());
615    }
616    if let Some(value) = line.strip_prefix("size=") {
617        layout.size = parse_usize(value, "type size")?;
618        return Ok(());
619    }
620    if let Some(value) = line.strip_prefix("align=") {
621        layout.align = parse_usize(value, "type align")?;
622        return Ok(());
623    }
624    let Some(rest) = line.strip_prefix("field.") else {
625        return Err(format!("unsupported type line: {line}"));
626    };
627    let Some((name, rest)) = rest.split_once(" offset=") else {
628        return Err(format!("field line is missing offset: {line}"));
629    };
630    let Some((offset, ty)) = rest.split_once(" type=") else {
631        return Err(format!("field line is missing type: {line}"));
632    };
633    layout.fields.push(FieldEntry {
634        name: name.to_string(),
635        offset: parse_usize(offset, "field offset")?,
636        ty: ty.to_string(),
637    });
638    Ok(())
639}
640
641fn parse_vtable_line(layout: &mut VTableEntry, line: &str) -> Result<(), String> {
642    if let Some(value) = line.strip_prefix("full_size=") {
643        layout.full_size = parse_usize(value, "vtable full_size")?;
644        return Ok(());
645    }
646    if let Some(value) = line.strip_prefix("min_size=") {
647        layout.min_size = parse_usize(value, "vtable min_size")?;
648        return Ok(());
649    }
650    Err(format!("unsupported vtable line: {line}"))
651}
652
653fn parse_stability(value: &str) -> Result<XabiLayoutStability, String> {
654    match value {
655        "fixed" => Ok(XabiLayoutStability::Fixed),
656        "prefix" => Ok(XabiLayoutStability::Prefix),
657        other => Err(format!("unsupported type stability: {other}")),
658    }
659}
660
661fn parse_usize(value: &str, context: &str) -> Result<usize, String> {
662    value
663        .parse()
664        .map_err(|err| format!("invalid {context} `{value}`: {err}"))
665}
666
667fn parse_u32(value: &str, context: &str) -> Result<u32, String> {
668    value
669        .parse()
670        .map_err(|err| format!("invalid {context} `{value}`: {err}"))
671}
672
673fn assert_type_equal(name: &str, left: &TypeEntry, right: &TypeEntry) {
674    assert!(
675        left.stability == right.stability
676            && left.size == right.size
677            && left.align == right.align
678            && left.fields.len() == right.fields.len()
679            && left
680                .fields
681                .iter()
682                .zip(&right.fields)
683                .all(|(left, right)| left.name == right.name
684                    && left.offset == right.offset
685                    && left.ty == right.ty),
686        "conflicting xabi type layout for {name}",
687    );
688}
689
690fn assert_vtable_equal(name: &str, left: &VTableEntry, right: &VTableEntry) {
691    assert!(
692        left.full_size == right.full_size && left.min_size == right.min_size,
693        "conflicting xabi vtable layout for {name}",
694    );
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700
701    #[test]
702    fn append_only_prefix_change_is_compatible() {
703        let expected = "\
704format=xabi-contract-snapshot-v1
705package=demo
706target=test-target
707
708contract demo.Contract
709  abi_version=1
710  rust_trait=demo::Contract
711
712type demo::Wire
713  stability=prefix
714  size=16
715  align=8
716  field.size offset=0 type=usize
717
718";
719        let actual = "\
720format=xabi-contract-snapshot-v1
721package=demo
722target=test-target
723
724contract demo.Contract
725  abi_version=1
726  rust_trait=demo::Contract
727
728type demo::Wire
729  stability=prefix
730  size=24
731  align=8
732  field.size offset=0 type=usize
733  field.tail offset=16 type=u64
734
735";
736
737        compare_compatibility(expected, actual).expect("append-only change is compatible");
738    }
739
740    #[test]
741    fn appended_field_is_incompatible_for_fixed_type() {
742        let expected = "\
743format=xabi-contract-snapshot-v1
744package=demo
745target=test-target
746
747contract demo.Contract
748  abi_version=1
749  rust_trait=demo::Contract
750
751type demo::DataWire
752  stability=fixed
753  size=16
754  align=8
755  field.size offset=0 type=usize
756
757";
758        let actual = "\
759format=xabi-contract-snapshot-v1
760package=demo
761target=test-target
762
763contract demo.Contract
764  abi_version=1
765  rust_trait=demo::Contract
766
767type demo::DataWire
768  stability=fixed
769  size=24
770  align=8
771  field.size offset=0 type=usize
772  field.tail offset=16 type=u64
773
774";
775
776        let err = compare_compatibility(expected, actual)
777            .expect_err("fixed data layout must reject an appended field");
778        assert_eq!(err, "fixed type demo::DataWire size changed from 16 to 24");
779    }
780
781    #[test]
782    fn snapshot_component_keeps_contract_ids_path_safe() {
783        assert_eq!(
784            snapshot_component("lance.ScalarIndex/Plugin:v1"),
785            "lance.ScalarIndex_Plugin_v1"
786        );
787    }
788}