Skip to main content

shipper_types/
schema.rs

1//! Schema version parsing and compatibility validation for shipper state files.
2//!
3//! Shipper persists state and receipt files on disk with a version tag in the
4//! form `shipper.<doctype>.v<N>` (for example, `shipper.receipt.v2`). This
5//! module provides the parsing and compatibility helpers used when loading
6//! those files.
7//!
8//! Historically these helpers lived in a dedicated `shipper-schema` crate.
9//! Phase 6 of the decrating effort folded that crate in here because the
10//! public surface was only two functions with no independent consumers.
11//!
12//! # Examples
13//!
14//! ```
15//! use shipper_types::schema::{parse_schema_version, validate_schema_version};
16//!
17//! assert_eq!(parse_schema_version("shipper.receipt.v2").unwrap(), 2);
18//! assert!(validate_schema_version(
19//!     "shipper.receipt.v2",
20//!     "shipper.receipt.v1",
21//!     "receipt",
22//! )
23//! .is_ok());
24//! ```
25
26use anyhow::{Context, Result};
27
28/// Parse schema version number from a string like `shipper.receipt.v2`.
29///
30/// # Examples
31///
32/// ```
33/// use shipper_types::schema::parse_schema_version;
34///
35/// assert_eq!(parse_schema_version("shipper.receipt.v2").unwrap(), 2);
36/// assert_eq!(parse_schema_version("shipper.state.v1").unwrap(), 1);
37/// assert!(parse_schema_version("invalid").is_err());
38/// ```
39pub fn parse_schema_version(version: &str) -> Result<u32> {
40    let mut parts = version.split('.');
41    let Some(prefix) = parts.next() else {
42        anyhow::bail!("invalid schema version format: {version}");
43    };
44    let Some(document_type) = parts.next() else {
45        anyhow::bail!("invalid schema version format: {version}");
46    };
47    let Some(raw_version) = parts.next() else {
48        anyhow::bail!("invalid schema version format: {version}");
49    };
50
51    if parts.next().is_some()
52        || prefix != "shipper"
53        || document_type.is_empty()
54        || !raw_version.starts_with('v')
55    {
56        anyhow::bail!("invalid schema version format: {version}");
57    }
58
59    let version_part = &raw_version[1..];
60    if version_part.is_empty() || !version_part.bytes().all(|byte| byte.is_ascii_digit()) {
61        anyhow::bail!("invalid version number in schema version: {version}");
62    }
63
64    version_part
65        .parse::<u32>()
66        .with_context(|| format!("invalid version number in schema version: {version}"))
67}
68
69/// Validate that `version` is at least the minimum supported schema version.
70///
71/// The `label` value is used in error messages (for example: `receipt`, `schema`).
72///
73/// # Examples
74///
75/// ```
76/// use shipper_types::schema::validate_schema_version;
77///
78/// // Accepted: version meets minimum
79/// assert!(validate_schema_version("shipper.receipt.v2", "shipper.receipt.v1", "receipt").is_ok());
80///
81/// // Rejected: version is too old
82/// assert!(validate_schema_version("shipper.receipt.v0", "shipper.receipt.v1", "receipt").is_err());
83/// ```
84pub fn validate_schema_version(version: &str, minimum_supported: &str, label: &str) -> Result<()> {
85    let version_num = parse_schema_version(version)
86        .with_context(|| format!("invalid {label} version format: {version}"))?;
87
88    let minimum_num = parse_schema_version(minimum_supported)
89        .with_context(|| format!("invalid minimum version format: {minimum_supported}"))?;
90
91    if version_num < minimum_num {
92        anyhow::bail!(
93            "{label} version {version} is too old. Minimum supported version is {minimum_supported}"
94        );
95    }
96
97    Ok(())
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use insta::assert_debug_snapshot;
104    use proptest::prelude::*;
105
106    #[test]
107    fn parse_schema_version_extracts_numeric_suffix() {
108        let parsed = parse_schema_version("shipper.receipt.v42").expect("parse");
109        assert_eq!(parsed, 42);
110    }
111
112    // --- Additional parse edge-case tests ---
113
114    #[test]
115    fn parse_schema_version_accepts_v0() {
116        assert_eq!(parse_schema_version("shipper.receipt.v0").unwrap(), 0);
117    }
118
119    #[test]
120    fn parse_schema_version_accepts_leading_zeros() {
121        // Rust's u32 parse treats "007" as 7
122        assert_eq!(parse_schema_version("shipper.receipt.v007").unwrap(), 7);
123    }
124
125    #[test]
126    fn parse_schema_version_rejects_empty_string() {
127        assert!(parse_schema_version("").is_err());
128    }
129
130    #[test]
131    fn parse_schema_version_rejects_empty_version_after_v() {
132        assert!(parse_schema_version("shipper.receipt.v").is_err());
133    }
134
135    #[test]
136    fn parse_schema_version_rejects_negative_version() {
137        assert!(parse_schema_version("shipper.receipt.v-1").is_err());
138    }
139
140    #[test]
141    fn parse_schema_version_rejects_float_version() {
142        assert!(parse_schema_version("shipper.receipt.v1.5").is_err());
143    }
144
145    #[test]
146    fn parse_schema_version_rejects_whitespace_around_input() {
147        assert!(parse_schema_version(" shipper.receipt.v1 ").is_err());
148    }
149
150    #[test]
151    fn parse_schema_version_rejects_single_segment() {
152        assert!(parse_schema_version("shipper").is_err());
153    }
154
155    #[test]
156    fn parse_schema_version_rejects_only_dots() {
157        assert!(parse_schema_version("..").is_err());
158    }
159
160    #[test]
161    fn parse_schema_version_accepts_u32_max() {
162        let input = format!("shipper.receipt.v{}", u32::MAX);
163        assert_eq!(parse_schema_version(&input).unwrap(), u32::MAX);
164    }
165
166    #[test]
167    fn parse_schema_version_rejects_overflow_u32() {
168        let overflow = u64::from(u32::MAX) + 1;
169        let input = format!("shipper.receipt.v{overflow}");
170        assert!(parse_schema_version(&input).is_err());
171    }
172
173    #[test]
174    fn parse_schema_version_accepts_non_empty_document_type() {
175        assert_eq!(parse_schema_version("shipper.anything.v5").unwrap(), 5);
176    }
177
178    #[test]
179    fn parse_schema_version_rejects_empty_document_type() {
180        assert!(parse_schema_version("shipper..v5").is_err());
181    }
182
183    // --- Additional validate edge-case tests ---
184
185    #[test]
186    fn validate_schema_version_accepts_both_zero() {
187        validate_schema_version("shipper.receipt.v0", "shipper.receipt.v0", "receipt")
188            .expect("v0 >= v0 should succeed");
189    }
190
191    #[test]
192    fn validate_schema_version_does_not_compare_middle_segments() {
193        // Middle segments differ (receipt vs state) — function only compares version numbers
194        validate_schema_version("shipper.receipt.v3", "shipper.state.v2", "mixed")
195            .expect("cross-segment comparison should still work");
196    }
197
198    #[test]
199    fn validate_schema_version_fails_when_version_is_invalid() {
200        let err = validate_schema_version("garbage", "shipper.receipt.v1", "receipt")
201            .expect_err("must fail");
202        assert!(err.to_string().contains("invalid receipt version format"));
203    }
204
205    #[test]
206    fn validate_schema_version_fails_when_minimum_is_invalid() {
207        let err = validate_schema_version("shipper.receipt.v1", "garbage", "receipt")
208            .expect_err("must fail");
209        assert!(err.to_string().contains("invalid minimum version format"));
210    }
211
212    #[test]
213    fn validate_schema_version_label_appears_in_error_message() {
214        let err = validate_schema_version("shipper.x.v0", "shipper.x.v5", "my_custom_label")
215            .expect_err("must fail");
216        let msg = err.to_string();
217        assert!(msg.contains("my_custom_label"), "label missing from: {msg}");
218    }
219
220    // --- Snapshot tests using assert_debug_snapshot! ---
221
222    #[test]
223    fn snapshot_parse_ok_result() {
224        assert_debug_snapshot!(parse_schema_version("shipper.receipt.v42"));
225    }
226
227    #[test]
228    fn snapshot_parse_err_invalid_format() {
229        assert_debug_snapshot!(parse_schema_version("invalid").map_err(|e| e.to_string()));
230    }
231
232    #[test]
233    fn snapshot_parse_err_non_numeric() {
234        assert_debug_snapshot!(
235            parse_schema_version("shipper.receipt.vx").map_err(|e| e.to_string())
236        );
237    }
238
239    #[test]
240    fn snapshot_validate_ok() {
241        assert_debug_snapshot!(validate_schema_version(
242            "shipper.state.v3",
243            "shipper.state.v1",
244            "state"
245        ));
246    }
247
248    #[test]
249    fn snapshot_validate_err_too_old() {
250        assert_debug_snapshot!(
251            validate_schema_version("shipper.state.v0", "shipper.state.v5", "state")
252                .map_err(|e| e.to_string())
253        );
254    }
255
256    #[test]
257    fn snapshot_parse_boundary_values() {
258        let results: Vec<_> = [
259            "shipper.x.v0",
260            "shipper.x.v1",
261            &format!("shipper.x.v{}", u32::MAX),
262        ]
263        .iter()
264        .map(|s| (s.to_string(), parse_schema_version(s).ok()))
265        .collect();
266        assert_debug_snapshot!(results);
267    }
268
269    #[test]
270    fn parse_schema_version_rejects_invalid_prefix() {
271        let err = parse_schema_version("other.receipt.v2").expect_err("must fail");
272        assert!(err.to_string().contains("invalid schema version format"));
273    }
274
275    #[test]
276    fn parse_schema_version_rejects_missing_v_prefix() {
277        let err = parse_schema_version("shipper.receipt.2").expect_err("must fail");
278        assert!(err.to_string().contains("invalid schema version format"));
279    }
280
281    #[test]
282    fn parse_schema_version_rejects_non_numeric_suffix() {
283        let err = parse_schema_version("shipper.receipt.vx").expect_err("must fail");
284        assert!(err.to_string().contains("invalid version number"));
285    }
286
287    #[test]
288    fn validate_schema_version_accepts_supported_versions() {
289        validate_schema_version("shipper.receipt.v1", "shipper.receipt.v1", "receipt")
290            .expect("minimum supported");
291        validate_schema_version("shipper.receipt.v9", "shipper.receipt.v1", "receipt")
292            .expect("newer versions");
293    }
294
295    #[test]
296    fn validate_schema_version_rejects_older_versions() {
297        let err = validate_schema_version("shipper.receipt.v0", "shipper.receipt.v1", "receipt")
298            .expect_err("must fail");
299        assert!(err.to_string().contains("too old"));
300    }
301
302    // --- Version compatibility: sequential upgrade chain ---
303
304    #[test]
305    fn validate_upgrade_chain_v1_through_v5() {
306        for version in 1u32..=5 {
307            let v = format!("shipper.state.v{version}");
308            let min = "shipper.state.v1";
309            validate_schema_version(&v, min, "state")
310                .unwrap_or_else(|_| panic!("v{version} should satisfy minimum v1"));
311        }
312    }
313
314    #[test]
315    fn validate_downgrade_always_rejected() {
316        for (newer, older) in [(5, 4), (4, 3), (3, 2), (2, 1)] {
317            let v = format!("shipper.state.v{older}");
318            let min = format!("shipper.state.v{newer}");
319            assert!(
320                validate_schema_version(&v, &min, "state").is_err(),
321                "v{older} should not satisfy minimum v{newer}"
322            );
323        }
324    }
325
326    #[test]
327    fn validate_error_message_includes_both_versions() {
328        let err = validate_schema_version("shipper.receipt.v1", "shipper.receipt.v5", "receipt")
329            .expect_err("must fail");
330        let msg = err.to_string();
331        assert!(
332            msg.contains("v1"),
333            "error should mention actual version: {msg}"
334        );
335        assert!(
336            msg.contains("v5"),
337            "error should mention minimum version: {msg}"
338        );
339    }
340
341    #[test]
342    fn validate_at_u32_max_boundary() {
343        let max_ver = format!("shipper.receipt.v{}", u32::MAX);
344        let min_ver = format!("shipper.receipt.v{}", u32::MAX);
345        validate_schema_version(&max_ver, &min_ver, "receipt")
346            .expect("u32::MAX should satisfy itself");
347    }
348
349    #[test]
350    fn validate_both_arguments_invalid_returns_error() {
351        let result = validate_schema_version("garbage", "also_garbage", "test");
352        assert!(result.is_err());
353    }
354
355    // --- Edge cases: unusual/adversarial inputs ---
356
357    #[test]
358    fn parse_schema_version_rejects_shipper_prefix_superstring() {
359        assert!(parse_schema_version("shippers.receipt.v3").is_err());
360    }
361
362    #[test]
363    fn parse_schema_version_rejects_uppercase_v_prefix() {
364        assert!(parse_schema_version("shipper.receipt.V2").is_err());
365    }
366
367    #[test]
368    fn parse_schema_version_rejects_tab_separated() {
369        assert!(parse_schema_version("shipper\treceipt\tv1").is_err());
370    }
371
372    #[test]
373    fn parse_schema_version_rejects_unicode_digit() {
374        // U+0661 is Arabic-Indic digit one — not valid for u32::parse
375        assert!(parse_schema_version("shipper.receipt.v\u{0661}").is_err());
376    }
377
378    #[test]
379    fn parse_schema_version_rejects_version_with_trailing_text() {
380        assert!(parse_schema_version("shipper.receipt.v2beta").is_err());
381    }
382
383    #[test]
384    fn parse_schema_version_rejects_version_with_plus_sign() {
385        assert!(parse_schema_version("shipper.receipt.v+1").is_err());
386    }
387
388    #[test]
389    fn parse_schema_version_handles_very_long_middle_segment() {
390        let long_middle = "a".repeat(10_000);
391        let input = format!("shipper.{long_middle}.v7");
392        assert_eq!(parse_schema_version(&input).unwrap(), 7);
393    }
394
395    #[test]
396    fn parse_schema_version_deterministic_across_calls() {
397        let input = "shipper.receipt.v42";
398        let a = parse_schema_version(input).unwrap();
399        let b = parse_schema_version(input).unwrap();
400        assert_eq!(a, b);
401    }
402
403    // --- Snapshot tests ---
404
405    #[test]
406    fn snapshot_parse_multiple_document_types() {
407        let types = ["receipt", "state", "events", "lock"];
408        let results: Vec<_> = types
409            .iter()
410            .map(|t| {
411                let input = format!("shipper.{t}.v1");
412                (t.to_string(), parse_schema_version(&input).ok())
413            })
414            .collect();
415        assert_debug_snapshot!(results);
416    }
417
418    #[test]
419    fn snapshot_validate_upgrade_compatibility_matrix() {
420        let versions: Vec<u32> = vec![0, 1, 2, 3, 5];
421        let mut matrix: Vec<String> = Vec::new();
422        for &v in &versions {
423            for &min in &versions {
424                let ver = format!("shipper.state.v{v}");
425                let minimum = format!("shipper.state.v{min}");
426                let ok = validate_schema_version(&ver, &minimum, "state").is_ok();
427                matrix.push(format!("v{v} >= v{min}: {ok}"));
428            }
429        }
430        assert_debug_snapshot!(matrix);
431    }
432
433    proptest! {
434        #[test]
435        fn parse_schema_version_roundtrips_number(version in 1u32..10_000) {
436            let raw = format!("shipper.receipt.v{version}");
437            prop_assert_eq!(parse_schema_version(&raw).expect("parse"), version);
438        }
439
440        #[test]
441        fn validate_schema_version_accepts_equal_or_newer_versions(min in 1u32..5_000, offset in 0u32..5_000) {
442            let actual = min.saturating_add(offset);
443            let version = format!("shipper.receipt.v{actual}");
444            let minimum = format!("shipper.receipt.v{min}");
445
446            prop_assert!(validate_schema_version(&version, &minimum, "receipt").is_ok());
447        }
448
449        #[test]
450        fn parse_schema_version_never_panics_on_arbitrary_input(s in "\\PC*") {
451            // Must not panic regardless of input; Ok or Err are both fine.
452            let _ = parse_schema_version(&s);
453        }
454
455        #[test]
456        fn validate_schema_version_never_panics_on_arbitrary_inputs(
457            v in "\\PC*",
458            m in "\\PC*",
459            label in "[a-z]{1,10}",
460        ) {
461            let _ = validate_schema_version(&v, &m, &label);
462        }
463
464        #[test]
465        fn parse_rejects_wrong_segment_count(
466            a in "[a-z]{1,8}",
467            b in "[a-z]{0,8}",
468        ) {
469            // Two segments: "a.b" should always be rejected.
470            let two = format!("{a}.{b}");
471            prop_assert!(parse_schema_version(&two).is_err());
472
473            // Four segments: "a.b.c.d" should always be rejected.
474            let four = format!("{a}.{b}.v1.extra");
475            prop_assert!(parse_schema_version(&four).is_err());
476        }
477
478        #[test]
479        fn parse_rejects_non_shipper_prefix(
480            prefix in "[a-z]{1,8}".prop_filter("not shipper", |p| !p.starts_with("shipper")),
481            middle in "[a-z]{1,8}",
482            ver in 0u32..1_000,
483        ) {
484            let raw = format!("{prefix}.{middle}.v{ver}");
485            prop_assert!(parse_schema_version(&raw).is_err());
486        }
487
488        #[test]
489        fn parse_roundtrips_with_arbitrary_middle_segment(
490            middle in "[a-z]{1,12}",
491            ver in 0u32..100_000,
492        ) {
493            let raw = format!("shipper.{middle}.v{ver}");
494            prop_assert_eq!(parse_schema_version(&raw).expect("parse"), ver);
495        }
496
497        #[test]
498        fn validate_rejects_older_versions(
499            min in 1u32..5_000,
500            gap in 1u32..5_000,
501        ) {
502            let older = min.saturating_sub(gap);
503            // Only meaningful when older < min (skip when saturated to 0 and min is 0).
504            prop_assume!(older < min);
505            let version = format!("shipper.state.v{older}");
506            let minimum = format!("shipper.state.v{min}");
507            prop_assert!(validate_schema_version(&version, &minimum, "state").is_err());
508        }
509
510        #[test]
511        fn version_comparison_is_consistent(
512            a in 0u32..10_000,
513            b in 0u32..10_000,
514        ) {
515            let va = format!("shipper.receipt.v{a}");
516            let vb = format!("shipper.receipt.v{b}");
517            let a_ge_b = validate_schema_version(&va, &vb, "t").is_ok();
518            let b_ge_a = validate_schema_version(&vb, &va, "t").is_ok();
519            if a == b {
520                prop_assert!(a_ge_b && b_ge_a);
521            } else if a > b {
522                prop_assert!(a_ge_b && !b_ge_a);
523            } else {
524                prop_assert!(!a_ge_b && b_ge_a);
525            }
526        }
527
528        #[test]
529        fn validate_is_transitive(
530            a in 0u32..3_000,
531            b in 0u32..3_000,
532            c in 0u32..3_000,
533        ) {
534            let va = format!("shipper.state.v{a}");
535            let vb = format!("shipper.state.v{b}");
536            let vc = format!("shipper.state.v{c}");
537            let a_ge_b = validate_schema_version(&va, &vb, "t").is_ok();
538            let b_ge_c = validate_schema_version(&vb, &vc, "t").is_ok();
539            let a_ge_c = validate_schema_version(&va, &vc, "t").is_ok();
540            // Transitivity: if a >= b and b >= c then a >= c
541            if a_ge_b && b_ge_c {
542                prop_assert!(a_ge_c, "transitivity violated: v{a} >= v{b} and v{b} >= v{c} but not v{a} >= v{c}");
543            }
544        }
545
546        #[test]
547        fn parse_version_ordering_matches_numeric_ordering(
548            a in 0u32..10_000,
549            b in 0u32..10_000,
550        ) {
551            let pa = parse_schema_version(&format!("shipper.receipt.v{a}")).unwrap();
552            let pb = parse_schema_version(&format!("shipper.receipt.v{b}")).unwrap();
553            prop_assert_eq!(a.cmp(&b), pa.cmp(&pb));
554        }
555
556        /// Total ordering: for any two versions, exactly one of a>=b or b>a holds.
557        #[test]
558        fn version_total_ordering(a in 0u32..10_000, b in 0u32..10_000) {
559            let va = format!("shipper.state.v{a}");
560            let vb = format!("shipper.state.v{b}");
561            let a_ge_b = validate_schema_version(&va, &vb, "t").is_ok();
562            let b_ge_a = validate_schema_version(&vb, &va, "t").is_ok();
563            // At least one must hold (totality), and both hold iff equal (antisymmetry)
564            prop_assert!(a_ge_b || b_ge_a, "no ordering between v{a} and v{b}");
565            if a == b {
566                prop_assert!(a_ge_b && b_ge_a);
567            }
568        }
569
570        /// Upgrade path: any version can be "upgraded" to u32::MAX (latest possible).
571        #[test]
572        fn any_version_upgradable_to_max(v in 0u32..=u32::MAX) {
573            let version = format!("shipper.receipt.v{}", u32::MAX);
574            let minimum = format!("shipper.receipt.v{v}");
575            prop_assert!(validate_schema_version(&version, &minimum, "receipt").is_ok(),
576                "u32::MAX should satisfy any minimum v{v}");
577        }
578
579        /// Self-validation: parsing a version and validating it against itself always succeeds.
580        #[test]
581        fn parse_then_validate_self_always_succeeds(v in 0u32..100_000) {
582            let vs = format!("shipper.state.v{v}");
583            let parsed = parse_schema_version(&vs).expect("parse");
584            prop_assert_eq!(parsed, v);
585            prop_assert!(validate_schema_version(&vs, &vs, "self").is_ok());
586        }
587    }
588}