1use anyhow::{Context, Result};
27
28pub 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
69pub 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 #[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 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 #[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 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 #[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 #[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 #[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 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 #[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 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 let two = format!("{a}.{b}");
471 prop_assert!(parse_schema_version(&two).is_err());
472
473 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 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 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 #[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 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 #[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 #[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}