1use fallow_core::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_core::results::*;
291 use std::path::PathBuf;
292
293 #[test]
294 fn check_counts_from_results() {
295 let mut results = AnalysisResults::default();
296 results
297 .unused_files
298 .push(UnusedFileFinding::with_actions(UnusedFile {
299 path: PathBuf::from("a.ts"),
300 }));
301 results
302 .unused_exports
303 .push(UnusedExportFinding::with_actions(UnusedExport {
304 path: PathBuf::from("b.ts"),
305 export_name: "foo".into(),
306 is_type_only: false,
307 line: 1,
308 col: 0,
309 span_start: 0,
310 is_re_export: false,
311 }));
312 let counts = CheckCounts::from_results(&results);
313 assert_eq!(counts.total_issues, 2);
314 assert_eq!(counts.unused_files, 1);
315 assert_eq!(counts.unused_exports, 1);
316 assert_eq!(counts.unused_types, 0);
317 }
318
319 #[test]
320 fn deltas_reports_changes_only() {
321 let baseline = CheckCounts {
322 total_issues: 10,
323 unused_files: 5,
324 unused_exports: 3,
325 unused_types: 2,
326 unused_dependencies: 0,
327 unused_dev_dependencies: 0,
328 unused_optional_dependencies: 0,
329 unused_enum_members: 0,
330 unused_class_members: 0,
331 unused_store_members: 0,
332 unprovided_injects: 0,
333 unrendered_components: 0,
334 unused_component_props: 0,
335 unused_component_emits: 0,
336 unused_component_inputs: 0,
337 unused_component_outputs: 0,
338 unused_svelte_events: 0,
339 unused_server_actions: 0,
340 unused_load_data_keys: 0,
341 unresolved_imports: 0,
342 unlisted_dependencies: 0,
343 duplicate_exports: 0,
344 circular_dependencies: 0,
345 re_export_cycles: 0,
346 type_only_dependencies: 0,
347 test_only_dependencies: 0,
348 boundary_violations: 0,
349 boundary_coverage_violations: 0,
350 boundary_call_violations: 0,
351 policy_violations: 0,
352 };
353 let current = CheckCounts {
354 unused_files: 7, unused_exports: 1, unused_types: 2, ..baseline
358 };
359 let deltas = baseline.deltas(¤t);
360 assert_eq!(deltas.len(), 2);
361 assert!(deltas.contains(&("unused_files", 2)));
362 assert!(deltas.contains(&("unused_exports", -2)));
363 }
364
365 #[test]
366 fn regression_baseline_roundtrip() {
367 let baseline = RegressionBaseline {
368 schema_version: 1,
369 fallow_version: "2.4.0".into(),
370 timestamp: "2026-03-27T10:00:00Z".into(),
371 git_sha: Some("abc123".into()),
372 check: Some(CheckCounts {
373 total_issues: 42,
374 unused_files: 5,
375 unused_exports: 20,
376 unused_types: 8,
377 unused_dependencies: 3,
378 unused_dev_dependencies: 2,
379 unused_optional_dependencies: 0,
380 unused_enum_members: 1,
381 unused_class_members: 1,
382 unused_store_members: 0,
383 unprovided_injects: 0,
384 unrendered_components: 0,
385 unused_component_props: 0,
386 unused_component_emits: 0,
387 unused_component_inputs: 0,
388 unused_component_outputs: 0,
389 unused_svelte_events: 0,
390 unused_server_actions: 0,
391 unused_load_data_keys: 0,
392 unresolved_imports: 0,
393 unlisted_dependencies: 1,
394 duplicate_exports: 0,
395 circular_dependencies: 1,
396 re_export_cycles: 0,
397 type_only_dependencies: 0,
398 test_only_dependencies: 0,
399 boundary_violations: 0,
400 boundary_coverage_violations: 0,
401 boundary_call_violations: 0,
402 policy_violations: 0,
403 }),
404 dupes: Some(DupesCounts {
405 clone_groups: 12,
406 duplication_percentage: 4.2,
407 }),
408 };
409 let json = serde_json::to_string_pretty(&baseline).unwrap();
410 let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
411 assert_eq!(loaded.schema_version, 1);
412 assert_eq!(loaded.check.as_ref().unwrap().total_issues, 42);
413 assert_eq!(loaded.dupes.as_ref().unwrap().clone_groups, 12);
414 }
415
416 #[test]
417 fn check_counts_config_roundtrip() {
418 let counts = CheckCounts {
419 total_issues: 42,
420 unused_files: 5,
421 unused_exports: 20,
422 unused_types: 8,
423 unused_dependencies: 3,
424 unused_dev_dependencies: 2,
425 unused_optional_dependencies: 1,
426 unused_enum_members: 1,
427 unused_class_members: 1,
428 unused_store_members: 0,
429 unprovided_injects: 0,
430 unrendered_components: 0,
431 unused_component_props: 0,
432 unused_component_emits: 0,
433 unused_component_inputs: 0,
434 unused_component_outputs: 0,
435 unused_svelte_events: 0,
436 unused_server_actions: 0,
437 unused_load_data_keys: 0,
438 unresolved_imports: 0,
439 unlisted_dependencies: 1,
440 duplicate_exports: 0,
441 circular_dependencies: 0,
442 re_export_cycles: 0,
443 type_only_dependencies: 0,
444 test_only_dependencies: 0,
445 boundary_violations: 0,
446 boundary_coverage_violations: 0,
447 boundary_call_violations: 0,
448 policy_violations: 0,
449 };
450 let config_baseline = counts.to_config_baseline();
451 let roundtripped = CheckCounts::from_config_baseline(&config_baseline);
452 assert_eq!(roundtripped.total_issues, 42);
453 assert_eq!(roundtripped.unused_files, 5);
454 assert_eq!(roundtripped.unused_exports, 20);
455 assert_eq!(roundtripped.unused_types, 8);
456 assert_eq!(roundtripped.unused_dependencies, 3);
457 assert_eq!(roundtripped.unused_dev_dependencies, 2);
458 assert_eq!(roundtripped.unused_optional_dependencies, 1);
459 assert_eq!(roundtripped.unused_enum_members, 1);
460 assert_eq!(roundtripped.unused_class_members, 1);
461 assert_eq!(roundtripped.unresolved_imports, 0);
462 assert_eq!(roundtripped.unlisted_dependencies, 1);
463 assert_eq!(roundtripped.duplicate_exports, 0);
464 assert_eq!(roundtripped.circular_dependencies, 0);
465 assert_eq!(roundtripped.type_only_dependencies, 0);
466 assert_eq!(roundtripped.test_only_dependencies, 0);
467 }
468
469 #[test]
470 fn check_counts_zero_config_roundtrip() {
471 let counts = CheckCounts {
472 total_issues: 0,
473 unused_files: 0,
474 unused_exports: 0,
475 unused_types: 0,
476 unused_dependencies: 0,
477 unused_dev_dependencies: 0,
478 unused_optional_dependencies: 0,
479 unused_enum_members: 0,
480 unused_class_members: 0,
481 unused_store_members: 0,
482 unprovided_injects: 0,
483 unrendered_components: 0,
484 unused_component_props: 0,
485 unused_component_emits: 0,
486 unused_component_inputs: 0,
487 unused_component_outputs: 0,
488 unused_svelte_events: 0,
489 unused_server_actions: 0,
490 unused_load_data_keys: 0,
491 unresolved_imports: 0,
492 unlisted_dependencies: 0,
493 duplicate_exports: 0,
494 circular_dependencies: 0,
495 re_export_cycles: 0,
496 type_only_dependencies: 0,
497 test_only_dependencies: 0,
498 boundary_violations: 0,
499 boundary_coverage_violations: 0,
500 boundary_call_violations: 0,
501 policy_violations: 0,
502 };
503 let config_baseline = counts.to_config_baseline();
504 let roundtripped = CheckCounts::from_config_baseline(&config_baseline);
505 assert_eq!(roundtripped.total_issues, 0);
506 assert_eq!(roundtripped.unused_files, 0);
507 }
508
509 #[test]
510 fn deltas_empty_when_identical() {
511 let counts = CheckCounts {
512 total_issues: 10,
513 unused_files: 5,
514 unused_exports: 3,
515 unused_types: 2,
516 unused_dependencies: 0,
517 unused_dev_dependencies: 0,
518 unused_optional_dependencies: 0,
519 unused_enum_members: 0,
520 unused_class_members: 0,
521 unused_store_members: 0,
522 unprovided_injects: 0,
523 unrendered_components: 0,
524 unused_component_props: 0,
525 unused_component_emits: 0,
526 unused_component_inputs: 0,
527 unused_component_outputs: 0,
528 unused_svelte_events: 0,
529 unused_server_actions: 0,
530 unused_load_data_keys: 0,
531 unresolved_imports: 0,
532 unlisted_dependencies: 0,
533 duplicate_exports: 0,
534 circular_dependencies: 0,
535 re_export_cycles: 0,
536 type_only_dependencies: 0,
537 test_only_dependencies: 0,
538 boundary_violations: 0,
539 boundary_coverage_violations: 0,
540 boundary_call_violations: 0,
541 policy_violations: 0,
542 };
543 let deltas = counts.deltas(&counts);
544 assert!(deltas.is_empty());
545 }
546
547 #[test]
548 fn deltas_all_categories_changed() {
549 let baseline = CheckCounts {
550 total_issues: 0,
551 unused_files: 0,
552 unused_exports: 0,
553 unused_types: 0,
554 unused_dependencies: 0,
555 unused_dev_dependencies: 0,
556 unused_optional_dependencies: 0,
557 unused_enum_members: 0,
558 unused_class_members: 0,
559 unused_store_members: 0,
560 unprovided_injects: 0,
561 unrendered_components: 0,
562 unused_component_props: 0,
563 unused_component_emits: 0,
564 unused_component_inputs: 0,
565 unused_component_outputs: 0,
566 unused_svelte_events: 0,
567 unused_server_actions: 0,
568 unused_load_data_keys: 0,
569 unresolved_imports: 0,
570 unlisted_dependencies: 0,
571 duplicate_exports: 0,
572 circular_dependencies: 0,
573 re_export_cycles: 0,
574 type_only_dependencies: 0,
575 test_only_dependencies: 0,
576 boundary_violations: 0,
577 boundary_coverage_violations: 0,
578 boundary_call_violations: 0,
579 policy_violations: 0,
580 };
581 let current = CheckCounts {
582 total_issues: 15,
583 unused_files: 1,
584 unused_exports: 1,
585 unused_types: 1,
586 unused_dependencies: 1,
587 unused_dev_dependencies: 1,
588 unused_optional_dependencies: 1,
589 unused_enum_members: 1,
590 unused_class_members: 1,
591 unused_store_members: 1,
592 unprovided_injects: 1,
593 unrendered_components: 1,
594 unused_component_props: 0,
595 unused_component_emits: 0,
596 unused_component_inputs: 0,
597 unused_component_outputs: 0,
598 unused_svelte_events: 0,
599 unused_server_actions: 0,
600 unused_load_data_keys: 0,
601 unresolved_imports: 1,
602 unlisted_dependencies: 1,
603 duplicate_exports: 1,
604 circular_dependencies: 1,
605 re_export_cycles: 0,
606 type_only_dependencies: 1,
607 test_only_dependencies: 1,
608 boundary_violations: 1,
609 boundary_coverage_violations: 0,
610 boundary_call_violations: 0,
611 policy_violations: 0,
612 };
613 let deltas = baseline.deltas(¤t);
614 assert_eq!(deltas.len(), 18);
615 for (_, d) in &deltas {
616 assert_eq!(*d, 1);
617 }
618 }
619
620 #[test]
621 fn deltas_mixed_increase_decrease() {
622 let baseline = CheckCounts {
623 total_issues: 10,
624 unused_files: 5,
625 unused_exports: 3,
626 unused_types: 2,
627 unused_dependencies: 0,
628 unused_dev_dependencies: 0,
629 unused_optional_dependencies: 0,
630 unused_enum_members: 0,
631 unused_class_members: 0,
632 unused_store_members: 0,
633 unprovided_injects: 0,
634 unrendered_components: 0,
635 unused_component_props: 0,
636 unused_component_emits: 0,
637 unused_component_inputs: 0,
638 unused_component_outputs: 0,
639 unused_svelte_events: 0,
640 unused_server_actions: 0,
641 unused_load_data_keys: 0,
642 unresolved_imports: 0,
643 unlisted_dependencies: 0,
644 duplicate_exports: 0,
645 circular_dependencies: 0,
646 re_export_cycles: 0,
647 type_only_dependencies: 0,
648 test_only_dependencies: 0,
649 boundary_violations: 0,
650 boundary_coverage_violations: 0,
651 boundary_call_violations: 0,
652 policy_violations: 0,
653 };
654 let current = CheckCounts {
655 unused_files: 3,
656 unused_exports: 5,
657 unused_types: 0,
658 unresolved_imports: 1,
659 ..baseline
660 };
661 let deltas = baseline.deltas(¤t);
662 assert_eq!(deltas.len(), 4);
663 assert!(deltas.contains(&("unused_files", -2)));
664 assert!(deltas.contains(&("unused_exports", 2)));
665 assert!(deltas.contains(&("unused_types", -2)));
666 assert!(deltas.contains(&("unresolved_imports", 1)));
667 }
668
669 #[test]
670 fn dupes_counts_roundtrip() {
671 let dupes = DupesCounts {
672 clone_groups: 8,
673 duplication_percentage: 3.17,
674 };
675 let json = serde_json::to_string(&dupes).unwrap();
676 let loaded: DupesCounts = serde_json::from_str(&json).unwrap();
677 assert_eq!(loaded.clone_groups, 8);
678 assert!((loaded.duplication_percentage - 3.17).abs() < f64::EPSILON);
679 }
680
681 #[test]
682 fn dupes_counts_default_fields() {
683 let json = "{}";
684 let loaded: DupesCounts = serde_json::from_str(json).unwrap();
685 assert_eq!(loaded.clone_groups, 0);
686 assert!((loaded.duplication_percentage).abs() < f64::EPSILON);
687 }
688
689 #[test]
690 fn baseline_without_check_section() {
691 let baseline = RegressionBaseline {
692 schema_version: 1,
693 fallow_version: "2.4.0".into(),
694 timestamp: "2026-03-27T10:00:00Z".into(),
695 git_sha: None,
696 check: None,
697 dupes: Some(DupesCounts {
698 clone_groups: 3,
699 duplication_percentage: 1.0,
700 }),
701 };
702 let json = serde_json::to_string_pretty(&baseline).unwrap();
703 let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
704 assert!(loaded.check.is_none());
705 assert!(loaded.dupes.is_some());
706 }
707
708 #[test]
709 fn baseline_without_dupes_section() {
710 let baseline = RegressionBaseline {
711 schema_version: 1,
712 fallow_version: "2.4.0".into(),
713 timestamp: "2026-03-27T10:00:00Z".into(),
714 git_sha: Some("deadbeef".into()),
715 check: Some(CheckCounts {
716 total_issues: 1,
717 unused_files: 1,
718 ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
719 }),
720 dupes: None,
721 };
722 let json = serde_json::to_string_pretty(&baseline).unwrap();
723 let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
724 assert!(loaded.check.is_some());
725 assert!(loaded.dupes.is_none());
726 assert_eq!(loaded.git_sha.as_deref(), Some("deadbeef"));
727 }
728
729 #[test]
730 fn baseline_without_git_sha() {
731 let baseline = RegressionBaseline {
732 schema_version: 1,
733 fallow_version: "2.4.0".into(),
734 timestamp: "2026-03-27T10:00:00Z".into(),
735 git_sha: None,
736 check: None,
737 dupes: None,
738 };
739 let json = serde_json::to_string_pretty(&baseline).unwrap();
740 assert!(!json.contains("git_sha"));
741 let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
742 assert!(loaded.git_sha.is_none());
743 }
744
745 #[test]
746 fn baseline_json_with_unknown_check_fields_deserializes() {
747 let json = r#"{
748 "schema_version": 1,
749 "fallow_version": "3.0.0",
750 "timestamp": "2026-03-27T10:00:00Z",
751 "check": {
752 "total_issues": 10,
753 "unused_files": 2,
754 "some_future_field": 99
755 }
756 }"#;
757 let loaded: Result<RegressionBaseline, _> = serde_json::from_str(json);
758 assert!(loaded.is_ok());
759 let loaded = loaded.unwrap();
760 assert_eq!(loaded.check.as_ref().unwrap().total_issues, 10);
761 }
762}