1use anyhow::{Context, Result};
27
28pub 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
51pub 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 #[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 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 assert_eq!(parse_schema_version("shipper.anything.v5").unwrap(), 5);
159 assert_eq!(parse_schema_version("shipper..v5").unwrap(), 5);
160 }
161
162 #[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 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 #[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 #[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 #[test]
337 fn parse_schema_version_accepts_shipper_prefix_superstring() {
338 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 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 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 #[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 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 let two = format!("{a}.{b}");
452 prop_assert!(parse_schema_version(&two).is_err());
453
454 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 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 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 #[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 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 #[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 #[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}