1#![forbid(unsafe_code)]
2
3use std::fmt;
42
43use crate::metrics_registry::{BuiltinCounter, METRICS};
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
51pub enum SchemaKind {
52 Evidence,
54 RenderTrace,
56 EventTrace,
58 GoldenTrace,
60 Telemetry,
62 MigrationIr,
64}
65
66impl SchemaKind {
67 pub const ALL: [Self; 6] = [
69 Self::Evidence,
70 Self::RenderTrace,
71 Self::EventTrace,
72 Self::GoldenTrace,
73 Self::Telemetry,
74 Self::MigrationIr,
75 ];
76
77 pub const fn current_version(self) -> &'static str {
79 match self {
80 Self::Evidence => "ftui-evidence-v2",
81 Self::RenderTrace => "render-trace-v1",
82 Self::EventTrace => "event-trace-v1",
83 Self::GoldenTrace => "golden-trace-v1",
84 Self::Telemetry => "1.0.0",
85 Self::MigrationIr => "migration-ir-v1",
86 }
87 }
88
89 const fn version_prefix(self) -> &'static str {
91 match self {
92 Self::Evidence => "ftui-evidence-v",
93 Self::RenderTrace => "render-trace-v",
94 Self::EventTrace => "event-trace-v",
95 Self::GoldenTrace => "golden-trace-v",
96 Self::Telemetry => "", Self::MigrationIr => "migration-ir-v",
98 }
99 }
100
101 pub const fn as_str(self) -> &'static str {
103 match self {
104 Self::Evidence => "evidence",
105 Self::RenderTrace => "render_trace",
106 Self::EventTrace => "event_trace",
107 Self::GoldenTrace => "golden_trace",
108 Self::Telemetry => "telemetry",
109 Self::MigrationIr => "migration_ir",
110 }
111 }
112}
113
114impl fmt::Display for SchemaKind {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 f.write_str(self.as_str())
117 }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
126pub enum Compatibility {
127 Exact,
129 Forward {
131 reader_version: u32,
132 writer_version: u32,
133 },
134 Backward {
136 reader_version: u32,
137 writer_version: u32,
138 },
139 Unknown { writer_version: String },
141}
142
143impl Compatibility {
144 pub fn is_compatible(&self) -> bool {
146 matches!(self, Self::Exact | Self::Forward { .. })
147 }
148}
149
150impl fmt::Display for Compatibility {
151 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152 match self {
153 Self::Exact => write!(f, "exact match"),
154 Self::Forward {
155 reader_version,
156 writer_version,
157 } => write!(
158 f,
159 "forward compatible (reader=v{reader_version}, writer=v{writer_version})"
160 ),
161 Self::Backward {
162 reader_version,
163 writer_version,
164 } => write!(
165 f,
166 "incompatible: writer newer (reader=v{reader_version}, writer=v{writer_version})"
167 ),
168 Self::Unknown { writer_version } => {
169 write!(f, "unknown version format: {writer_version}")
170 }
171 }
172 }
173}
174
175#[derive(Debug, Clone)]
181pub struct CompatCheckResult {
182 pub kind: SchemaKind,
184 pub reader_version: &'static str,
186 pub writer_version: String,
188 pub compatibility: Compatibility,
190}
191
192impl CompatCheckResult {
193 pub fn is_compatible(&self) -> bool {
195 self.compatibility.is_compatible()
196 }
197}
198
199impl fmt::Display for CompatCheckResult {
200 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201 write!(
202 f,
203 "{}: {} (reader={}, writer={})",
204 self.kind, self.compatibility, self.reader_version, self.writer_version,
205 )
206 }
207}
208
209fn parse_prefixed_version(version: &str, prefix: &str) -> Option<u32> {
215 version.strip_prefix(prefix)?.parse().ok()
216}
217
218fn parse_semver_major(version: &str) -> Option<u32> {
220 version.split('.').next()?.parse().ok()
221}
222
223fn parse_version_number(kind: SchemaKind, version: &str) -> Option<u32> {
225 if kind == SchemaKind::Telemetry {
226 parse_semver_major(version)
227 } else {
228 parse_prefixed_version(version, kind.version_prefix())
229 }
230}
231
232pub fn classify_schema_compat(kind: SchemaKind, writer_version: &str) -> CompatCheckResult {
242 let reader_version = kind.current_version();
243
244 let compatibility = if writer_version == reader_version {
245 Compatibility::Exact
246 } else {
247 match (
248 parse_version_number(kind, reader_version),
249 parse_version_number(kind, writer_version),
250 ) {
251 (Some(rv), Some(wv)) if rv > wv => Compatibility::Forward {
252 reader_version: rv,
253 writer_version: wv,
254 },
255 (Some(rv), Some(wv)) if rv == wv => Compatibility::Exact,
256 (Some(rv), Some(wv)) => Compatibility::Backward {
257 reader_version: rv,
258 writer_version: wv,
259 },
260 _ => Compatibility::Unknown {
261 writer_version: writer_version.to_string(),
262 },
263 }
264 };
265
266 CompatCheckResult {
267 kind,
268 reader_version,
269 writer_version: writer_version.to_string(),
270 compatibility,
271 }
272}
273
274pub fn check_schema_compat(kind: SchemaKind, writer_version: &str) -> CompatCheckResult {
282 let result = classify_schema_compat(kind, writer_version);
283 let compatible = result.is_compatible();
284
285 #[cfg(feature = "tracing")]
287 {
288 use tracing::{error, info_span};
289
290 let span = info_span!(
291 "trace.compat_check",
292 schema_version = kind.current_version(),
293 reader_version = result.reader_version,
294 writer_version = writer_version,
295 compatible = compatible,
296 );
297 let _guard = span.enter();
298
299 if !compatible {
300 error!(
301 schema_kind = kind.as_str(),
302 reader_version = result.reader_version,
303 writer_version = writer_version,
304 "trace schema version incompatible"
305 );
306 }
307 }
308
309 if !compatible {
311 METRICS
312 .counter(BuiltinCounter::TraceCompatFailuresTotal)
313 .inc();
314 }
315
316 result
317}
318
319pub fn check_evidence_compat(writer_version: &str) -> CompatCheckResult {
321 check_schema_compat(SchemaKind::Evidence, writer_version)
322}
323
324pub fn check_render_trace_compat(writer_version: &str) -> CompatCheckResult {
326 check_schema_compat(SchemaKind::RenderTrace, writer_version)
327}
328
329pub fn check_event_trace_compat(writer_version: &str) -> CompatCheckResult {
331 check_schema_compat(SchemaKind::EventTrace, writer_version)
332}
333
334pub fn check_golden_trace_compat(writer_version: &str) -> CompatCheckResult {
336 check_schema_compat(SchemaKind::GoldenTrace, writer_version)
337}
338
339#[derive(Debug, Clone)]
346pub struct MatrixEntry {
347 pub kind: SchemaKind,
348 pub writer_version: String,
349 pub expected_compatible: bool,
350}
351
352pub fn run_compatibility_matrix(entries: &[MatrixEntry]) -> Vec<(MatrixEntry, CompatCheckResult)> {
356 entries
357 .iter()
358 .map(|entry| {
359 let result = classify_schema_compat(entry.kind, &entry.writer_version);
360 (entry.clone(), result)
361 })
362 .collect()
363}
364
365pub fn default_compatibility_matrix() -> Vec<MatrixEntry> {
373 let mut entries = Vec::new();
374
375 for kind in SchemaKind::ALL {
376 let current = kind.current_version();
377
378 entries.push(MatrixEntry {
380 kind,
381 writer_version: current.to_string(),
382 expected_compatible: true,
383 });
384
385 entries.push(MatrixEntry {
387 kind,
388 writer_version: "not-a-version".to_string(),
389 expected_compatible: false,
390 });
391
392 if kind == SchemaKind::Telemetry {
393 entries.push(MatrixEntry {
395 kind,
396 writer_version: "0.9.0".to_string(),
397 expected_compatible: true,
398 });
399 entries.push(MatrixEntry {
401 kind,
402 writer_version: "2.0.0".to_string(),
403 expected_compatible: false,
404 });
405 } else {
406 let prefix = kind.version_prefix();
408 if let Some(current_num) = parse_version_number(kind, current) {
409 if current_num > 0 {
410 entries.push(MatrixEntry {
411 kind,
412 writer_version: format!("{prefix}{}", current_num - 1),
413 expected_compatible: true,
414 });
415 }
416 entries.push(MatrixEntry {
417 kind,
418 writer_version: format!("{prefix}{}", current_num + 1),
419 expected_compatible: false,
420 });
421 }
422 }
423 }
424
425 entries
426}
427
428#[cfg(test)]
433mod tests {
434 use super::*;
435
436 static COMPAT_METRICS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
437
438 fn compat_metrics_guard() -> std::sync::MutexGuard<'static, ()> {
439 COMPAT_METRICS_LOCK
440 .lock()
441 .unwrap_or_else(std::sync::PoisonError::into_inner)
442 }
443
444 #[test]
445 fn exact_match_all_kinds() {
446 for kind in SchemaKind::ALL {
447 let result = classify_schema_compat(kind, kind.current_version());
448 assert_eq!(result.compatibility, Compatibility::Exact, "{kind}");
449 assert!(result.is_compatible(), "{kind}");
450 }
451 }
452
453 #[test]
454 fn forward_compat_evidence() {
455 let result = classify_schema_compat(SchemaKind::Evidence, "ftui-evidence-v1");
456 assert!(
457 matches!(
458 result.compatibility,
459 Compatibility::Forward {
460 reader_version: 2,
461 writer_version: 1
462 }
463 ),
464 "got {:?}",
465 result.compatibility
466 );
467 assert!(result.is_compatible());
468 }
469
470 #[test]
471 fn backward_incompat_evidence() {
472 let result = classify_schema_compat(SchemaKind::Evidence, "ftui-evidence-v3");
473 assert!(
474 matches!(
475 result.compatibility,
476 Compatibility::Backward {
477 reader_version: 2,
478 writer_version: 3
479 }
480 ),
481 "got {:?}",
482 result.compatibility
483 );
484 assert!(!result.is_compatible());
485 }
486
487 #[test]
488 fn unknown_version_format() {
489 let result = classify_schema_compat(SchemaKind::Evidence, "garbage-string");
490 assert!(
491 matches!(result.compatibility, Compatibility::Unknown { .. }),
492 "got {:?}",
493 result.compatibility
494 );
495 assert!(!result.is_compatible());
496 }
497
498 #[test]
499 fn forward_compat_telemetry_semver() {
500 let result = classify_schema_compat(SchemaKind::Telemetry, "0.9.0");
501 assert!(
502 matches!(
503 result.compatibility,
504 Compatibility::Forward {
505 reader_version: 1,
506 writer_version: 0
507 }
508 ),
509 "got {:?}",
510 result.compatibility
511 );
512 assert!(result.is_compatible());
513 }
514
515 #[test]
516 fn backward_incompat_telemetry_semver() {
517 let result = classify_schema_compat(SchemaKind::Telemetry, "2.0.0");
518 assert!(
519 matches!(
520 result.compatibility,
521 Compatibility::Backward {
522 reader_version: 1,
523 writer_version: 2
524 }
525 ),
526 "got {:?}",
527 result.compatibility
528 );
529 assert!(!result.is_compatible());
530 }
531
532 #[test]
533 fn all_kinds_have_current_version() {
534 for kind in SchemaKind::ALL {
535 let v = kind.current_version();
536 assert!(!v.is_empty(), "{kind} has empty version");
537 }
538 }
539
540 #[test]
541 fn all_kinds_have_unique_versions() {
542 let mut versions = std::collections::HashSet::new();
543 for kind in SchemaKind::ALL {
544 assert!(
545 versions.insert(kind.current_version()),
546 "duplicate version: {}",
547 kind.current_version()
548 );
549 }
550 }
551
552 #[test]
553 fn default_matrix_covers_all_kinds() {
554 let matrix = default_compatibility_matrix();
555 for kind in SchemaKind::ALL {
556 let count = matrix.iter().filter(|e| e.kind == kind).count();
557 assert!(
558 count >= 3,
559 "{kind} has only {count} matrix entries, expected >=3"
560 );
561 }
562 }
563
564 #[test]
565 fn default_matrix_all_pass() {
566 let matrix = default_compatibility_matrix();
567 let results = run_compatibility_matrix(&matrix);
568 for (entry, result) in &results {
569 assert_eq!(
570 result.is_compatible(),
571 entry.expected_compatible,
572 "{}: writer={}, expected_compatible={}, got {:?}",
573 entry.kind,
574 entry.writer_version,
575 entry.expected_compatible,
576 result.compatibility,
577 );
578 }
579 }
580
581 #[test]
582 fn compat_failures_counter_increments() {
583 let _guard = compat_metrics_guard();
584
585 let before = METRICS
586 .counter(BuiltinCounter::TraceCompatFailuresTotal)
587 .get();
588 let _ = check_schema_compat(SchemaKind::Evidence, "ftui-evidence-v99");
589 let after = METRICS
590 .counter(BuiltinCounter::TraceCompatFailuresTotal)
591 .get();
592 assert!(
593 after > before,
594 "counter should increment on incompatibility"
595 );
596 }
597
598 #[test]
599 fn exact_match_does_not_increment_counter() {
600 let _guard = compat_metrics_guard();
601
602 let before = METRICS
603 .counter(BuiltinCounter::TraceCompatFailuresTotal)
604 .get();
605 let _ = check_schema_compat(SchemaKind::Evidence, "ftui-evidence-v2");
606 let after = METRICS
607 .counter(BuiltinCounter::TraceCompatFailuresTotal)
608 .get();
609 assert_eq!(after, before, "counter should not increment on exact match");
610 }
611
612 #[test]
613 fn display_impls() {
614 let result = classify_schema_compat(SchemaKind::Evidence, "ftui-evidence-v1");
615 let s = result.to_string();
616 assert!(s.contains("evidence"), "{s}");
617 assert!(s.contains("forward compatible"), "{s}");
618
619 let result2 = classify_schema_compat(SchemaKind::RenderTrace, "render-trace-v99");
620 let s2 = result2.to_string();
621 assert!(s2.contains("incompatible"), "{s2}");
622 }
623
624 #[test]
625 fn schema_kind_display() {
626 assert_eq!(SchemaKind::Evidence.to_string(), "evidence");
627 assert_eq!(SchemaKind::RenderTrace.to_string(), "render_trace");
628 assert_eq!(SchemaKind::Telemetry.to_string(), "telemetry");
629 }
630
631 #[test]
632 fn render_trace_forward_compat() {
633 let result = classify_schema_compat(SchemaKind::RenderTrace, "render-trace-v0");
634 assert!(result.is_compatible());
635 assert!(matches!(
636 result.compatibility,
637 Compatibility::Forward { .. }
638 ));
639 }
640
641 #[test]
642 fn event_trace_exact() {
643 let result = classify_schema_compat(SchemaKind::EventTrace, "event-trace-v1");
644 assert_eq!(result.compatibility, Compatibility::Exact);
645 }
646
647 #[test]
648 fn golden_trace_backward_incompat() {
649 let result = classify_schema_compat(SchemaKind::GoldenTrace, "golden-trace-v2");
650 assert!(!result.is_compatible());
651 }
652
653 #[test]
654 fn migration_ir_exact() {
655 let result = classify_schema_compat(SchemaKind::MigrationIr, "migration-ir-v1");
656 assert_eq!(result.compatibility, Compatibility::Exact);
657 }
658
659 #[test]
660 fn evidence_v0_forward() {
661 let result = classify_schema_compat(SchemaKind::Evidence, "ftui-evidence-v0");
663 assert!(result.is_compatible());
664 assert!(matches!(
665 result.compatibility,
666 Compatibility::Forward {
667 reader_version: 2,
668 writer_version: 0
669 }
670 ));
671 }
672}