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