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