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 check_schema_compat(kind: SchemaKind, writer_version: &str) -> CompatCheckResult {
244 let reader_version = kind.current_version();
245
246 let compatibility = if writer_version == reader_version {
247 Compatibility::Exact
248 } else {
249 match (
250 parse_version_number(kind, reader_version),
251 parse_version_number(kind, writer_version),
252 ) {
253 (Some(rv), Some(wv)) if rv > wv => Compatibility::Forward {
254 reader_version: rv,
255 writer_version: wv,
256 },
257 (Some(rv), Some(wv)) if rv == wv => Compatibility::Exact,
258 (Some(rv), Some(wv)) => Compatibility::Backward {
259 reader_version: rv,
260 writer_version: wv,
261 },
262 _ => Compatibility::Unknown {
263 writer_version: writer_version.to_string(),
264 },
265 }
266 };
267
268 let compatible = compatibility.is_compatible();
269
270 #[cfg(feature = "tracing")]
272 {
273 use tracing::{error, info_span};
274
275 let span = info_span!(
276 "trace.compat_check",
277 schema_version = kind.current_version(),
278 reader_version = reader_version,
279 writer_version = writer_version,
280 compatible = compatible,
281 );
282 let _guard = span.enter();
283
284 if !compatible {
285 error!(
286 schema_kind = kind.as_str(),
287 reader_version = reader_version,
288 writer_version = writer_version,
289 "trace schema version incompatible"
290 );
291 }
292 }
293
294 if !compatible {
296 METRICS
297 .counter(BuiltinCounter::TraceCompatFailuresTotal)
298 .inc();
299 }
300
301 CompatCheckResult {
302 kind,
303 reader_version,
304 writer_version: writer_version.to_string(),
305 compatibility,
306 }
307}
308
309pub fn check_evidence_compat(writer_version: &str) -> CompatCheckResult {
311 check_schema_compat(SchemaKind::Evidence, writer_version)
312}
313
314pub fn check_render_trace_compat(writer_version: &str) -> CompatCheckResult {
316 check_schema_compat(SchemaKind::RenderTrace, writer_version)
317}
318
319pub fn check_event_trace_compat(writer_version: &str) -> CompatCheckResult {
321 check_schema_compat(SchemaKind::EventTrace, writer_version)
322}
323
324pub fn check_golden_trace_compat(writer_version: &str) -> CompatCheckResult {
326 check_schema_compat(SchemaKind::GoldenTrace, writer_version)
327}
328
329#[derive(Debug, Clone)]
336pub struct MatrixEntry {
337 pub kind: SchemaKind,
338 pub writer_version: String,
339 pub expected_compatible: bool,
340}
341
342pub fn run_compatibility_matrix(entries: &[MatrixEntry]) -> Vec<(MatrixEntry, CompatCheckResult)> {
346 entries
347 .iter()
348 .map(|entry| {
349 let result = check_schema_compat(entry.kind, &entry.writer_version);
350 (entry.clone(), result)
351 })
352 .collect()
353}
354
355pub fn default_compatibility_matrix() -> Vec<MatrixEntry> {
363 let mut entries = Vec::new();
364
365 for kind in SchemaKind::ALL {
366 let current = kind.current_version();
367
368 entries.push(MatrixEntry {
370 kind,
371 writer_version: current.to_string(),
372 expected_compatible: true,
373 });
374
375 entries.push(MatrixEntry {
377 kind,
378 writer_version: "not-a-version".to_string(),
379 expected_compatible: false,
380 });
381
382 if kind == SchemaKind::Telemetry {
383 entries.push(MatrixEntry {
385 kind,
386 writer_version: "0.9.0".to_string(),
387 expected_compatible: true,
388 });
389 entries.push(MatrixEntry {
391 kind,
392 writer_version: "2.0.0".to_string(),
393 expected_compatible: false,
394 });
395 } else {
396 let prefix = kind.version_prefix();
398 if let Some(current_num) = parse_version_number(kind, current) {
399 if current_num > 0 {
400 entries.push(MatrixEntry {
401 kind,
402 writer_version: format!("{prefix}{}", current_num - 1),
403 expected_compatible: true,
404 });
405 }
406 entries.push(MatrixEntry {
407 kind,
408 writer_version: format!("{prefix}{}", current_num + 1),
409 expected_compatible: false,
410 });
411 }
412 }
413 }
414
415 entries
416}
417
418#[cfg(test)]
423mod tests {
424 use super::*;
425
426 #[test]
427 fn exact_match_all_kinds() {
428 for kind in SchemaKind::ALL {
429 let result = check_schema_compat(kind, kind.current_version());
430 assert_eq!(result.compatibility, Compatibility::Exact, "{kind}");
431 assert!(result.is_compatible(), "{kind}");
432 }
433 }
434
435 #[test]
436 fn forward_compat_evidence() {
437 let result = check_schema_compat(SchemaKind::Evidence, "ftui-evidence-v1");
438 assert!(
439 matches!(
440 result.compatibility,
441 Compatibility::Forward {
442 reader_version: 2,
443 writer_version: 1
444 }
445 ),
446 "got {:?}",
447 result.compatibility
448 );
449 assert!(result.is_compatible());
450 }
451
452 #[test]
453 fn backward_incompat_evidence() {
454 let result = check_schema_compat(SchemaKind::Evidence, "ftui-evidence-v3");
455 assert!(
456 matches!(
457 result.compatibility,
458 Compatibility::Backward {
459 reader_version: 2,
460 writer_version: 3
461 }
462 ),
463 "got {:?}",
464 result.compatibility
465 );
466 assert!(!result.is_compatible());
467 }
468
469 #[test]
470 fn unknown_version_format() {
471 let result = check_schema_compat(SchemaKind::Evidence, "garbage-string");
472 assert!(
473 matches!(result.compatibility, Compatibility::Unknown { .. }),
474 "got {:?}",
475 result.compatibility
476 );
477 assert!(!result.is_compatible());
478 }
479
480 #[test]
481 fn forward_compat_telemetry_semver() {
482 let result = check_schema_compat(SchemaKind::Telemetry, "0.9.0");
483 assert!(
484 matches!(
485 result.compatibility,
486 Compatibility::Forward {
487 reader_version: 1,
488 writer_version: 0
489 }
490 ),
491 "got {:?}",
492 result.compatibility
493 );
494 assert!(result.is_compatible());
495 }
496
497 #[test]
498 fn backward_incompat_telemetry_semver() {
499 let result = check_schema_compat(SchemaKind::Telemetry, "2.0.0");
500 assert!(
501 matches!(
502 result.compatibility,
503 Compatibility::Backward {
504 reader_version: 1,
505 writer_version: 2
506 }
507 ),
508 "got {:?}",
509 result.compatibility
510 );
511 assert!(!result.is_compatible());
512 }
513
514 #[test]
515 fn all_kinds_have_current_version() {
516 for kind in SchemaKind::ALL {
517 let v = kind.current_version();
518 assert!(!v.is_empty(), "{kind} has empty version");
519 }
520 }
521
522 #[test]
523 fn all_kinds_have_unique_versions() {
524 let mut versions = std::collections::HashSet::new();
525 for kind in SchemaKind::ALL {
526 assert!(
527 versions.insert(kind.current_version()),
528 "duplicate version: {}",
529 kind.current_version()
530 );
531 }
532 }
533
534 #[test]
535 fn default_matrix_covers_all_kinds() {
536 let matrix = default_compatibility_matrix();
537 for kind in SchemaKind::ALL {
538 let count = matrix.iter().filter(|e| e.kind == kind).count();
539 assert!(
540 count >= 3,
541 "{kind} has only {count} matrix entries, expected >=3"
542 );
543 }
544 }
545
546 #[test]
547 fn default_matrix_all_pass() {
548 let matrix = default_compatibility_matrix();
549 let results = run_compatibility_matrix(&matrix);
550 for (entry, result) in &results {
551 assert_eq!(
552 result.is_compatible(),
553 entry.expected_compatible,
554 "{}: writer={}, expected_compatible={}, got {:?}",
555 entry.kind,
556 entry.writer_version,
557 entry.expected_compatible,
558 result.compatibility,
559 );
560 }
561 }
562
563 #[test]
564 fn compat_failures_counter_increments() {
565 let before = METRICS
566 .counter(BuiltinCounter::TraceCompatFailuresTotal)
567 .get();
568 let _ = check_schema_compat(SchemaKind::Evidence, "ftui-evidence-v99");
569 let after = METRICS
570 .counter(BuiltinCounter::TraceCompatFailuresTotal)
571 .get();
572 assert!(
573 after > before,
574 "counter should increment on incompatibility"
575 );
576 }
577
578 #[test]
579 fn exact_match_does_not_increment_counter() {
580 let before = METRICS
581 .counter(BuiltinCounter::TraceCompatFailuresTotal)
582 .get();
583 let _ = check_schema_compat(SchemaKind::Evidence, "ftui-evidence-v2");
584 let after = METRICS
585 .counter(BuiltinCounter::TraceCompatFailuresTotal)
586 .get();
587 assert_eq!(after, before, "counter should not increment on exact match");
588 }
589
590 #[test]
591 fn display_impls() {
592 let result = check_schema_compat(SchemaKind::Evidence, "ftui-evidence-v1");
593 let s = result.to_string();
594 assert!(s.contains("evidence"), "{s}");
595 assert!(s.contains("forward compatible"), "{s}");
596
597 let result2 = check_schema_compat(SchemaKind::RenderTrace, "render-trace-v99");
598 let s2 = result2.to_string();
599 assert!(s2.contains("incompatible"), "{s2}");
600 }
601
602 #[test]
603 fn schema_kind_display() {
604 assert_eq!(SchemaKind::Evidence.to_string(), "evidence");
605 assert_eq!(SchemaKind::RenderTrace.to_string(), "render_trace");
606 assert_eq!(SchemaKind::Telemetry.to_string(), "telemetry");
607 }
608
609 #[test]
610 fn render_trace_forward_compat() {
611 let result = check_schema_compat(SchemaKind::RenderTrace, "render-trace-v0");
612 assert!(result.is_compatible());
613 assert!(matches!(
614 result.compatibility,
615 Compatibility::Forward { .. }
616 ));
617 }
618
619 #[test]
620 fn event_trace_exact() {
621 let result = check_schema_compat(SchemaKind::EventTrace, "event-trace-v1");
622 assert_eq!(result.compatibility, Compatibility::Exact);
623 }
624
625 #[test]
626 fn golden_trace_backward_incompat() {
627 let result = check_schema_compat(SchemaKind::GoldenTrace, "golden-trace-v2");
628 assert!(!result.is_compatible());
629 }
630
631 #[test]
632 fn migration_ir_exact() {
633 let result = check_schema_compat(SchemaKind::MigrationIr, "migration-ir-v1");
634 assert_eq!(result.compatibility, Compatibility::Exact);
635 }
636
637 #[test]
638 fn evidence_v0_forward() {
639 let result = check_schema_compat(SchemaKind::Evidence, "ftui-evidence-v0");
641 assert!(result.is_compatible());
642 assert!(matches!(
643 result.compatibility,
644 Compatibility::Forward {
645 reader_version: 2,
646 writer_version: 0
647 }
648 ));
649 }
650}