1use fallow_types::results::AnalysisResults;
2
3#[derive(Debug, serde::Serialize, serde::Deserialize)]
14pub struct RegressionBaseline {
15 pub schema_version: u32,
17 pub fallow_version: String,
19 pub timestamp: String,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub git_sha: Option<String>,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub check: Option<CheckCounts>,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub dupes: Option<DupesCounts>,
30}
31
32pub const REGRESSION_SCHEMA_VERSION: u32 = 1;
33
34#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
40pub struct CheckCounts {
41 #[serde(default)]
42 pub total_issues: usize,
43 #[serde(default)]
44 pub unused_files: usize,
45 #[serde(default)]
46 pub unused_exports: usize,
47 #[serde(default)]
48 pub unused_types: usize,
49 #[serde(default)]
50 pub unused_dependencies: usize,
51 #[serde(default)]
52 pub unused_dev_dependencies: usize,
53 #[serde(default)]
54 pub unused_optional_dependencies: usize,
55 #[serde(default)]
56 pub unused_enum_members: usize,
57 #[serde(default)]
58 pub unused_class_members: usize,
59 #[serde(default)]
60 pub unused_store_members: usize,
61 #[serde(default)]
62 pub unprovided_injects: usize,
63 #[serde(default)]
64 pub unrendered_components: usize,
65 #[serde(default)]
66 pub unused_component_props: usize,
67 #[serde(default)]
68 pub unused_component_emits: usize,
69 #[serde(default)]
70 pub unused_component_inputs: usize,
71 #[serde(default)]
72 pub unused_component_outputs: usize,
73 #[serde(default)]
74 pub unused_svelte_events: usize,
75 #[serde(default)]
76 pub unused_server_actions: usize,
77 #[serde(default)]
78 pub unused_load_data_keys: usize,
79 #[serde(default)]
80 pub unresolved_imports: usize,
81 #[serde(default)]
82 pub unlisted_dependencies: usize,
83 #[serde(default)]
84 pub duplicate_exports: usize,
85 #[serde(default)]
86 pub circular_dependencies: usize,
87 #[serde(default)]
88 pub re_export_cycles: usize,
89 #[serde(default)]
90 pub type_only_dependencies: usize,
91 #[serde(default)]
92 pub test_only_dependencies: usize,
93 #[serde(default)]
94 pub boundary_violations: usize,
95 #[serde(default)]
96 pub boundary_coverage_violations: usize,
97 #[serde(default)]
98 pub boundary_call_violations: usize,
99 #[serde(default)]
100 pub policy_violations: usize,
101}
102
103impl CheckCounts {
104 #[must_use]
105 pub const fn from_results(results: &AnalysisResults) -> Self {
106 Self {
107 total_issues: results.total_issues(),
108 unused_files: results.unused_files.len(),
109 unused_exports: results.unused_exports.len(),
110 unused_types: results.unused_types.len(),
111 unused_dependencies: results.unused_dependencies.len(),
112 unused_dev_dependencies: results.unused_dev_dependencies.len(),
113 unused_optional_dependencies: results.unused_optional_dependencies.len(),
114 unused_enum_members: results.unused_enum_members.len(),
115 unused_class_members: results.unused_class_members.len(),
116 unused_store_members: results.unused_store_members.len(),
117 unprovided_injects: results.unprovided_injects.len(),
118 unrendered_components: results.unrendered_components.len(),
119 unused_component_props: results.unused_component_props.len(),
120 unused_component_emits: results.unused_component_emits.len(),
121 unused_component_inputs: results.unused_component_inputs.len(),
122 unused_component_outputs: results.unused_component_outputs.len(),
123 unused_svelte_events: results.unused_svelte_events.len(),
124 unused_server_actions: results.unused_server_actions.len(),
125 unused_load_data_keys: results.unused_load_data_keys.len(),
126 unresolved_imports: results.unresolved_imports.len(),
127 unlisted_dependencies: results.unlisted_dependencies.len(),
128 duplicate_exports: results.duplicate_exports.len(),
129 circular_dependencies: results.circular_dependencies.len(),
130 re_export_cycles: results.re_export_cycles.len(),
131 type_only_dependencies: results.type_only_dependencies.len(),
132 test_only_dependencies: results.test_only_dependencies.len(),
133 boundary_violations: results.boundary_violations.len(),
134 boundary_coverage_violations: results.boundary_coverage_violations.len(),
135 boundary_call_violations: results.boundary_call_violations.len(),
136 policy_violations: results.policy_violations.len(),
137 }
138 }
139
140 #[must_use]
142 pub const fn from_config_baseline(b: &fallow_config::RegressionBaseline) -> Self {
143 Self {
144 total_issues: b.total_issues,
145 unused_files: b.unused_files,
146 unused_exports: b.unused_exports,
147 unused_types: b.unused_types,
148 unused_dependencies: b.unused_dependencies,
149 unused_dev_dependencies: b.unused_dev_dependencies,
150 unused_optional_dependencies: b.unused_optional_dependencies,
151 unused_enum_members: b.unused_enum_members,
152 unused_class_members: b.unused_class_members,
153 unused_store_members: 0,
156 unprovided_injects: 0,
159 unrendered_components: 0,
162 unused_component_props: 0,
165 unused_component_emits: 0,
168 unused_component_inputs: 0,
171 unused_component_outputs: 0,
174 unused_svelte_events: 0,
177 unused_server_actions: 0,
180 unused_load_data_keys: 0,
183 unresolved_imports: b.unresolved_imports,
184 unlisted_dependencies: b.unlisted_dependencies,
185 duplicate_exports: b.duplicate_exports,
186 circular_dependencies: b.circular_dependencies,
187 re_export_cycles: b.re_export_cycles,
188 type_only_dependencies: b.type_only_dependencies,
189 test_only_dependencies: b.test_only_dependencies,
190 boundary_violations: b.boundary_violations,
191 boundary_coverage_violations: b.boundary_coverage_violations,
192 boundary_call_violations: b.boundary_call_violations,
193 policy_violations: b.policy_violations,
194 }
195 }
196
197 #[must_use]
199 pub const fn to_config_baseline(&self) -> fallow_config::RegressionBaseline {
200 fallow_config::RegressionBaseline {
201 total_issues: self.total_issues,
202 unused_files: self.unused_files,
203 unused_exports: self.unused_exports,
204 unused_types: self.unused_types,
205 unused_dependencies: self.unused_dependencies,
206 unused_dev_dependencies: self.unused_dev_dependencies,
207 unused_optional_dependencies: self.unused_optional_dependencies,
208 unused_enum_members: self.unused_enum_members,
209 unused_class_members: self.unused_class_members,
210 unresolved_imports: self.unresolved_imports,
211 unlisted_dependencies: self.unlisted_dependencies,
212 duplicate_exports: self.duplicate_exports,
213 circular_dependencies: self.circular_dependencies,
214 re_export_cycles: self.re_export_cycles,
215 type_only_dependencies: self.type_only_dependencies,
216 test_only_dependencies: self.test_only_dependencies,
217 boundary_violations: self.boundary_violations,
218 boundary_coverage_violations: self.boundary_coverage_violations,
219 boundary_call_violations: self.boundary_call_violations,
220 policy_violations: self.policy_violations,
221 }
222 }
223
224 pub fn deltas(&self, current: &Self) -> Vec<(&'static str, isize)> {
226 let mut deltas = Vec::new();
227 macro_rules! push_delta {
228 ($field:ident) => {
229 push_count_delta(&mut deltas, stringify!($field), self.$field, current.$field);
230 };
231 }
232
233 push_delta!(unused_files);
234 push_delta!(unused_exports);
235 push_delta!(unused_types);
236 push_delta!(unused_dependencies);
237 push_delta!(unused_dev_dependencies);
238 push_delta!(unused_optional_dependencies);
239 push_delta!(unused_enum_members);
240 push_delta!(unused_class_members);
241 push_delta!(unused_store_members);
242 push_delta!(unprovided_injects);
243 push_delta!(unrendered_components);
244 push_delta!(unused_component_props);
245 push_delta!(unused_component_emits);
246 push_delta!(unused_component_inputs);
247 push_delta!(unused_component_outputs);
248 push_delta!(unused_svelte_events);
249 push_delta!(unused_server_actions);
250 push_delta!(unused_load_data_keys);
251 push_delta!(unresolved_imports);
252 push_delta!(unlisted_dependencies);
253 push_delta!(duplicate_exports);
254 push_delta!(circular_dependencies);
255 push_delta!(re_export_cycles);
256 push_delta!(type_only_dependencies);
257 push_delta!(test_only_dependencies);
258 push_delta!(boundary_violations);
259 push_delta!(boundary_coverage_violations);
260 push_delta!(boundary_call_violations);
261 push_delta!(policy_violations);
262 deltas
263 }
264}
265
266fn push_count_delta(
267 deltas: &mut Vec<(&'static str, isize)>,
268 name: &'static str,
269 baseline: usize,
270 current: usize,
271) {
272 let delta = current as isize - baseline as isize;
273 if delta != 0 {
274 deltas.push((name, delta));
275 }
276}
277
278#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
280pub struct DupesCounts {
281 #[serde(default)]
282 pub clone_groups: usize,
283 #[serde(default)]
284 pub duplication_percentage: f64,
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use fallow_types::output_dead_code::*;
291 use fallow_types::results::*;
292 use std::path::PathBuf;
293
294 #[test]
295 fn check_counts_from_results() {
296 let mut results = AnalysisResults::default();
297 results
298 .unused_files
299 .push(UnusedFileFinding::with_actions(UnusedFile {
300 path: PathBuf::from("a.ts"),
301 }));
302 results
303 .unused_exports
304 .push(UnusedExportFinding::with_actions(UnusedExport {
305 path: PathBuf::from("b.ts"),
306 export_name: "foo".into(),
307 is_type_only: false,
308 line: 1,
309 col: 0,
310 span_start: 0,
311 is_re_export: false,
312 }));
313 let counts = CheckCounts::from_results(&results);
314 assert_eq!(counts.total_issues, 2);
315 assert_eq!(counts.unused_files, 1);
316 assert_eq!(counts.unused_exports, 1);
317 assert_eq!(counts.unused_types, 0);
318 }
319
320 #[test]
321 fn deltas_reports_changes_only() {
322 let baseline = CheckCounts {
323 total_issues: 10,
324 unused_files: 5,
325 unused_exports: 3,
326 unused_types: 2,
327 unused_dependencies: 0,
328 unused_dev_dependencies: 0,
329 unused_optional_dependencies: 0,
330 unused_enum_members: 0,
331 unused_class_members: 0,
332 unused_store_members: 0,
333 unprovided_injects: 0,
334 unrendered_components: 0,
335 unused_component_props: 0,
336 unused_component_emits: 0,
337 unused_component_inputs: 0,
338 unused_component_outputs: 0,
339 unused_svelte_events: 0,
340 unused_server_actions: 0,
341 unused_load_data_keys: 0,
342 unresolved_imports: 0,
343 unlisted_dependencies: 0,
344 duplicate_exports: 0,
345 circular_dependencies: 0,
346 re_export_cycles: 0,
347 type_only_dependencies: 0,
348 test_only_dependencies: 0,
349 boundary_violations: 0,
350 boundary_coverage_violations: 0,
351 boundary_call_violations: 0,
352 policy_violations: 0,
353 };
354 let current = CheckCounts {
355 unused_files: 7, unused_exports: 1, unused_types: 2, ..baseline
359 };
360 let deltas = baseline.deltas(¤t);
361 assert_eq!(deltas.len(), 2);
362 assert!(deltas.contains(&("unused_files", 2)));
363 assert!(deltas.contains(&("unused_exports", -2)));
364 }
365
366 #[test]
367 fn regression_baseline_roundtrip() {
368 let baseline = RegressionBaseline {
369 schema_version: 1,
370 fallow_version: "2.4.0".into(),
371 timestamp: "2026-03-27T10:00:00Z".into(),
372 git_sha: Some("abc123".into()),
373 check: Some(CheckCounts {
374 total_issues: 42,
375 unused_files: 5,
376 unused_exports: 20,
377 unused_types: 8,
378 unused_dependencies: 3,
379 unused_dev_dependencies: 2,
380 unused_optional_dependencies: 0,
381 unused_enum_members: 1,
382 unused_class_members: 1,
383 unused_store_members: 0,
384 unprovided_injects: 0,
385 unrendered_components: 0,
386 unused_component_props: 0,
387 unused_component_emits: 0,
388 unused_component_inputs: 0,
389 unused_component_outputs: 0,
390 unused_svelte_events: 0,
391 unused_server_actions: 0,
392 unused_load_data_keys: 0,
393 unresolved_imports: 0,
394 unlisted_dependencies: 1,
395 duplicate_exports: 0,
396 circular_dependencies: 1,
397 re_export_cycles: 0,
398 type_only_dependencies: 0,
399 test_only_dependencies: 0,
400 boundary_violations: 0,
401 boundary_coverage_violations: 0,
402 boundary_call_violations: 0,
403 policy_violations: 0,
404 }),
405 dupes: Some(DupesCounts {
406 clone_groups: 12,
407 duplication_percentage: 4.2,
408 }),
409 };
410 let json = serde_json::to_string_pretty(&baseline).unwrap();
411 let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
412 assert_eq!(loaded.schema_version, 1);
413 assert_eq!(loaded.check.as_ref().unwrap().total_issues, 42);
414 assert_eq!(loaded.dupes.as_ref().unwrap().clone_groups, 12);
415 }
416
417 #[test]
418 fn check_counts_config_roundtrip() {
419 let counts = CheckCounts {
420 total_issues: 42,
421 unused_files: 5,
422 unused_exports: 20,
423 unused_types: 8,
424 unused_dependencies: 3,
425 unused_dev_dependencies: 2,
426 unused_optional_dependencies: 1,
427 unused_enum_members: 1,
428 unused_class_members: 1,
429 unused_store_members: 0,
430 unprovided_injects: 0,
431 unrendered_components: 0,
432 unused_component_props: 0,
433 unused_component_emits: 0,
434 unused_component_inputs: 0,
435 unused_component_outputs: 0,
436 unused_svelte_events: 0,
437 unused_server_actions: 0,
438 unused_load_data_keys: 0,
439 unresolved_imports: 0,
440 unlisted_dependencies: 1,
441 duplicate_exports: 0,
442 circular_dependencies: 0,
443 re_export_cycles: 0,
444 type_only_dependencies: 0,
445 test_only_dependencies: 0,
446 boundary_violations: 0,
447 boundary_coverage_violations: 0,
448 boundary_call_violations: 0,
449 policy_violations: 0,
450 };
451 let config_baseline = counts.to_config_baseline();
452 let roundtripped = CheckCounts::from_config_baseline(&config_baseline);
453 assert_eq!(roundtripped.total_issues, 42);
454 assert_eq!(roundtripped.unused_files, 5);
455 assert_eq!(roundtripped.unused_exports, 20);
456 assert_eq!(roundtripped.unused_types, 8);
457 assert_eq!(roundtripped.unused_dependencies, 3);
458 assert_eq!(roundtripped.unused_dev_dependencies, 2);
459 assert_eq!(roundtripped.unused_optional_dependencies, 1);
460 assert_eq!(roundtripped.unused_enum_members, 1);
461 assert_eq!(roundtripped.unused_class_members, 1);
462 assert_eq!(roundtripped.unresolved_imports, 0);
463 assert_eq!(roundtripped.unlisted_dependencies, 1);
464 assert_eq!(roundtripped.duplicate_exports, 0);
465 assert_eq!(roundtripped.circular_dependencies, 0);
466 assert_eq!(roundtripped.type_only_dependencies, 0);
467 assert_eq!(roundtripped.test_only_dependencies, 0);
468 }
469
470 #[test]
471 fn check_counts_zero_config_roundtrip() {
472 let counts = CheckCounts {
473 total_issues: 0,
474 unused_files: 0,
475 unused_exports: 0,
476 unused_types: 0,
477 unused_dependencies: 0,
478 unused_dev_dependencies: 0,
479 unused_optional_dependencies: 0,
480 unused_enum_members: 0,
481 unused_class_members: 0,
482 unused_store_members: 0,
483 unprovided_injects: 0,
484 unrendered_components: 0,
485 unused_component_props: 0,
486 unused_component_emits: 0,
487 unused_component_inputs: 0,
488 unused_component_outputs: 0,
489 unused_svelte_events: 0,
490 unused_server_actions: 0,
491 unused_load_data_keys: 0,
492 unresolved_imports: 0,
493 unlisted_dependencies: 0,
494 duplicate_exports: 0,
495 circular_dependencies: 0,
496 re_export_cycles: 0,
497 type_only_dependencies: 0,
498 test_only_dependencies: 0,
499 boundary_violations: 0,
500 boundary_coverage_violations: 0,
501 boundary_call_violations: 0,
502 policy_violations: 0,
503 };
504 let config_baseline = counts.to_config_baseline();
505 let roundtripped = CheckCounts::from_config_baseline(&config_baseline);
506 assert_eq!(roundtripped.total_issues, 0);
507 assert_eq!(roundtripped.unused_files, 0);
508 }
509
510 #[test]
511 fn deltas_empty_when_identical() {
512 let counts = CheckCounts {
513 total_issues: 10,
514 unused_files: 5,
515 unused_exports: 3,
516 unused_types: 2,
517 unused_dependencies: 0,
518 unused_dev_dependencies: 0,
519 unused_optional_dependencies: 0,
520 unused_enum_members: 0,
521 unused_class_members: 0,
522 unused_store_members: 0,
523 unprovided_injects: 0,
524 unrendered_components: 0,
525 unused_component_props: 0,
526 unused_component_emits: 0,
527 unused_component_inputs: 0,
528 unused_component_outputs: 0,
529 unused_svelte_events: 0,
530 unused_server_actions: 0,
531 unused_load_data_keys: 0,
532 unresolved_imports: 0,
533 unlisted_dependencies: 0,
534 duplicate_exports: 0,
535 circular_dependencies: 0,
536 re_export_cycles: 0,
537 type_only_dependencies: 0,
538 test_only_dependencies: 0,
539 boundary_violations: 0,
540 boundary_coverage_violations: 0,
541 boundary_call_violations: 0,
542 policy_violations: 0,
543 };
544 let deltas = counts.deltas(&counts);
545 assert!(deltas.is_empty());
546 }
547
548 #[test]
549 fn deltas_all_categories_changed() {
550 let baseline = CheckCounts {
551 total_issues: 0,
552 unused_files: 0,
553 unused_exports: 0,
554 unused_types: 0,
555 unused_dependencies: 0,
556 unused_dev_dependencies: 0,
557 unused_optional_dependencies: 0,
558 unused_enum_members: 0,
559 unused_class_members: 0,
560 unused_store_members: 0,
561 unprovided_injects: 0,
562 unrendered_components: 0,
563 unused_component_props: 0,
564 unused_component_emits: 0,
565 unused_component_inputs: 0,
566 unused_component_outputs: 0,
567 unused_svelte_events: 0,
568 unused_server_actions: 0,
569 unused_load_data_keys: 0,
570 unresolved_imports: 0,
571 unlisted_dependencies: 0,
572 duplicate_exports: 0,
573 circular_dependencies: 0,
574 re_export_cycles: 0,
575 type_only_dependencies: 0,
576 test_only_dependencies: 0,
577 boundary_violations: 0,
578 boundary_coverage_violations: 0,
579 boundary_call_violations: 0,
580 policy_violations: 0,
581 };
582 let current = CheckCounts {
583 total_issues: 15,
584 unused_files: 1,
585 unused_exports: 1,
586 unused_types: 1,
587 unused_dependencies: 1,
588 unused_dev_dependencies: 1,
589 unused_optional_dependencies: 1,
590 unused_enum_members: 1,
591 unused_class_members: 1,
592 unused_store_members: 1,
593 unprovided_injects: 1,
594 unrendered_components: 1,
595 unused_component_props: 0,
596 unused_component_emits: 0,
597 unused_component_inputs: 0,
598 unused_component_outputs: 0,
599 unused_svelte_events: 0,
600 unused_server_actions: 0,
601 unused_load_data_keys: 0,
602 unresolved_imports: 1,
603 unlisted_dependencies: 1,
604 duplicate_exports: 1,
605 circular_dependencies: 1,
606 re_export_cycles: 0,
607 type_only_dependencies: 1,
608 test_only_dependencies: 1,
609 boundary_violations: 1,
610 boundary_coverage_violations: 0,
611 boundary_call_violations: 0,
612 policy_violations: 0,
613 };
614 let deltas = baseline.deltas(¤t);
615 assert_eq!(deltas.len(), 18);
616 for (_, d) in &deltas {
617 assert_eq!(*d, 1);
618 }
619 }
620
621 #[test]
622 fn deltas_mixed_increase_decrease() {
623 let baseline = CheckCounts {
624 total_issues: 10,
625 unused_files: 5,
626 unused_exports: 3,
627 unused_types: 2,
628 unused_dependencies: 0,
629 unused_dev_dependencies: 0,
630 unused_optional_dependencies: 0,
631 unused_enum_members: 0,
632 unused_class_members: 0,
633 unused_store_members: 0,
634 unprovided_injects: 0,
635 unrendered_components: 0,
636 unused_component_props: 0,
637 unused_component_emits: 0,
638 unused_component_inputs: 0,
639 unused_component_outputs: 0,
640 unused_svelte_events: 0,
641 unused_server_actions: 0,
642 unused_load_data_keys: 0,
643 unresolved_imports: 0,
644 unlisted_dependencies: 0,
645 duplicate_exports: 0,
646 circular_dependencies: 0,
647 re_export_cycles: 0,
648 type_only_dependencies: 0,
649 test_only_dependencies: 0,
650 boundary_violations: 0,
651 boundary_coverage_violations: 0,
652 boundary_call_violations: 0,
653 policy_violations: 0,
654 };
655 let current = CheckCounts {
656 unused_files: 3,
657 unused_exports: 5,
658 unused_types: 0,
659 unresolved_imports: 1,
660 ..baseline
661 };
662 let deltas = baseline.deltas(¤t);
663 assert_eq!(deltas.len(), 4);
664 assert!(deltas.contains(&("unused_files", -2)));
665 assert!(deltas.contains(&("unused_exports", 2)));
666 assert!(deltas.contains(&("unused_types", -2)));
667 assert!(deltas.contains(&("unresolved_imports", 1)));
668 }
669
670 #[test]
671 fn dupes_counts_roundtrip() {
672 let dupes = DupesCounts {
673 clone_groups: 8,
674 duplication_percentage: 3.17,
675 };
676 let json = serde_json::to_string(&dupes).unwrap();
677 let loaded: DupesCounts = serde_json::from_str(&json).unwrap();
678 assert_eq!(loaded.clone_groups, 8);
679 assert!((loaded.duplication_percentage - 3.17).abs() < f64::EPSILON);
680 }
681
682 #[test]
683 fn dupes_counts_default_fields() {
684 let json = "{}";
685 let loaded: DupesCounts = serde_json::from_str(json).unwrap();
686 assert_eq!(loaded.clone_groups, 0);
687 assert!((loaded.duplication_percentage).abs() < f64::EPSILON);
688 }
689
690 #[test]
691 fn baseline_without_check_section() {
692 let baseline = RegressionBaseline {
693 schema_version: 1,
694 fallow_version: "2.4.0".into(),
695 timestamp: "2026-03-27T10:00:00Z".into(),
696 git_sha: None,
697 check: None,
698 dupes: Some(DupesCounts {
699 clone_groups: 3,
700 duplication_percentage: 1.0,
701 }),
702 };
703 let json = serde_json::to_string_pretty(&baseline).unwrap();
704 let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
705 assert!(loaded.check.is_none());
706 assert!(loaded.dupes.is_some());
707 }
708
709 #[test]
710 fn baseline_without_dupes_section() {
711 let baseline = RegressionBaseline {
712 schema_version: 1,
713 fallow_version: "2.4.0".into(),
714 timestamp: "2026-03-27T10:00:00Z".into(),
715 git_sha: Some("deadbeef".into()),
716 check: Some(CheckCounts {
717 total_issues: 1,
718 unused_files: 1,
719 ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
720 }),
721 dupes: None,
722 };
723 let json = serde_json::to_string_pretty(&baseline).unwrap();
724 let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
725 assert!(loaded.check.is_some());
726 assert!(loaded.dupes.is_none());
727 assert_eq!(loaded.git_sha.as_deref(), Some("deadbeef"));
728 }
729
730 #[test]
731 fn baseline_without_git_sha() {
732 let baseline = RegressionBaseline {
733 schema_version: 1,
734 fallow_version: "2.4.0".into(),
735 timestamp: "2026-03-27T10:00:00Z".into(),
736 git_sha: None,
737 check: None,
738 dupes: None,
739 };
740 let json = serde_json::to_string_pretty(&baseline).unwrap();
741 assert!(!json.contains("git_sha"));
742 let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
743 assert!(loaded.git_sha.is_none());
744 }
745
746 #[test]
747 fn baseline_json_with_unknown_check_fields_deserializes() {
748 let json = r#"{
749 "schema_version": 1,
750 "fallow_version": "3.0.0",
751 "timestamp": "2026-03-27T10:00:00Z",
752 "check": {
753 "total_issues": 10,
754 "unused_files": 2,
755 "some_future_field": 99
756 }
757 }"#;
758 let loaded: Result<RegressionBaseline, _> = serde_json::from_str(json);
759 assert!(loaded.is_ok());
760 let loaded = loaded.unwrap();
761 assert_eq!(loaded.check.as_ref().unwrap().total_issues, 10);
762 }
763}