Skip to main content

fallow_cli/regression/
counts.rs

1use fallow_core::results::AnalysisResults;
2
3/// Regression baseline: stores issue counts per type for comparison.
4///
5/// Unlike `BaselineData` which stores individual issue identities for suppression,
6/// this stores counts for "did the total go up?" regression detection.
7///
8/// `schema_version` is the forward-compatibility gate; unknown fields are tolerated
9/// intentionally (see `CheckCounts` `#[serde(default)]`) so adding a new issue type
10/// stays backwards-compatible with existing baselines. Bumping `schema_version`
11/// signals "this baseline cannot be safely loaded by older fallow builds" and
12/// triggers a hard-fail with a regenerate hint in `load_regression_baseline`.
13#[derive(Debug, serde::Serialize, serde::Deserialize)]
14pub struct RegressionBaseline {
15    /// Schema version for forward compatibility.
16    pub schema_version: u32,
17    /// Fallow version that produced this baseline.
18    pub fallow_version: String,
19    /// ISO 8601 timestamp.
20    pub timestamp: String,
21    /// Git SHA at baseline time, if available.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub git_sha: Option<String>,
24    /// Dead code issue counts.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub check: Option<CheckCounts>,
27    /// Duplication counts.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub dupes: Option<DupesCounts>,
30}
31
32pub const REGRESSION_SCHEMA_VERSION: u32 = 1;
33
34/// Per-type issue counts for dead code analysis.
35///
36/// All fields use `#[serde(default)]` for forward compatibility: when fallow adds a new
37/// issue type, old baselines will deserialize with the new field defaulting to zero
38/// instead of failing.
39#[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_server_actions: usize,
71    #[serde(default)]
72    pub unused_load_data_keys: usize,
73    #[serde(default)]
74    pub unresolved_imports: usize,
75    #[serde(default)]
76    pub unlisted_dependencies: usize,
77    #[serde(default)]
78    pub duplicate_exports: usize,
79    #[serde(default)]
80    pub circular_dependencies: usize,
81    #[serde(default)]
82    pub re_export_cycles: usize,
83    #[serde(default)]
84    pub type_only_dependencies: usize,
85    #[serde(default)]
86    pub test_only_dependencies: usize,
87    #[serde(default)]
88    pub boundary_violations: usize,
89    #[serde(default)]
90    pub boundary_coverage_violations: usize,
91    #[serde(default)]
92    pub boundary_call_violations: usize,
93    #[serde(default)]
94    pub policy_violations: usize,
95}
96
97impl CheckCounts {
98    #[must_use]
99    pub const fn from_results(results: &AnalysisResults) -> Self {
100        Self {
101            total_issues: results.total_issues(),
102            unused_files: results.unused_files.len(),
103            unused_exports: results.unused_exports.len(),
104            unused_types: results.unused_types.len(),
105            unused_dependencies: results.unused_dependencies.len(),
106            unused_dev_dependencies: results.unused_dev_dependencies.len(),
107            unused_optional_dependencies: results.unused_optional_dependencies.len(),
108            unused_enum_members: results.unused_enum_members.len(),
109            unused_class_members: results.unused_class_members.len(),
110            unused_store_members: results.unused_store_members.len(),
111            unprovided_injects: results.unprovided_injects.len(),
112            unrendered_components: results.unrendered_components.len(),
113            unused_component_props: results.unused_component_props.len(),
114            unused_component_emits: results.unused_component_emits.len(),
115            unused_server_actions: results.unused_server_actions.len(),
116            unused_load_data_keys: results.unused_load_data_keys.len(),
117            unresolved_imports: results.unresolved_imports.len(),
118            unlisted_dependencies: results.unlisted_dependencies.len(),
119            duplicate_exports: results.duplicate_exports.len(),
120            circular_dependencies: results.circular_dependencies.len(),
121            re_export_cycles: results.re_export_cycles.len(),
122            type_only_dependencies: results.type_only_dependencies.len(),
123            test_only_dependencies: results.test_only_dependencies.len(),
124            boundary_violations: results.boundary_violations.len(),
125            boundary_coverage_violations: results.boundary_coverage_violations.len(),
126            boundary_call_violations: results.boundary_call_violations.len(),
127            policy_violations: results.policy_violations.len(),
128        }
129    }
130
131    /// Convert from config-embedded baseline.
132    #[must_use]
133    pub const fn from_config_baseline(b: &fallow_config::RegressionBaseline) -> Self {
134        Self {
135            total_issues: b.total_issues,
136            unused_files: b.unused_files,
137            unused_exports: b.unused_exports,
138            unused_types: b.unused_types,
139            unused_dependencies: b.unused_dependencies,
140            unused_dev_dependencies: b.unused_dev_dependencies,
141            unused_optional_dependencies: b.unused_optional_dependencies,
142            unused_enum_members: b.unused_enum_members,
143            unused_class_members: b.unused_class_members,
144            // `fallow_config::RegressionBaseline` has no `unused_store_members`
145            // field; default to 0 until the config baseline schema gains one.
146            unused_store_members: 0,
147            // `fallow_config::RegressionBaseline` has no `unprovided_injects`
148            // field; default to 0 until the config baseline schema gains one.
149            unprovided_injects: 0,
150            // `fallow_config::RegressionBaseline` has no `unrendered_components`
151            // field; default to 0 until the config baseline schema gains one.
152            unrendered_components: 0,
153            // `fallow_config::RegressionBaseline` has no `unused_component_props`
154            // field; default to 0 until the config baseline schema gains one.
155            unused_component_props: 0,
156            // `fallow_config::RegressionBaseline` has no `unused_component_emits`
157            // field; default to 0 until the config baseline schema gains one.
158            unused_component_emits: 0,
159            // `fallow_config::RegressionBaseline` has no `unused_server_actions`
160            // field; default to 0 until the config baseline schema gains one.
161            unused_server_actions: 0,
162            // `fallow_config::RegressionBaseline` has no `unused_load_data_keys`
163            // field; default to 0 until the config baseline schema gains one.
164            unused_load_data_keys: 0,
165            unresolved_imports: b.unresolved_imports,
166            unlisted_dependencies: b.unlisted_dependencies,
167            duplicate_exports: b.duplicate_exports,
168            circular_dependencies: b.circular_dependencies,
169            re_export_cycles: b.re_export_cycles,
170            type_only_dependencies: b.type_only_dependencies,
171            test_only_dependencies: b.test_only_dependencies,
172            boundary_violations: b.boundary_violations,
173            boundary_coverage_violations: b.boundary_coverage_violations,
174            boundary_call_violations: b.boundary_call_violations,
175            policy_violations: b.policy_violations,
176        }
177    }
178
179    /// Convert to config-embeddable baseline.
180    #[must_use]
181    pub const fn to_config_baseline(&self) -> fallow_config::RegressionBaseline {
182        fallow_config::RegressionBaseline {
183            total_issues: self.total_issues,
184            unused_files: self.unused_files,
185            unused_exports: self.unused_exports,
186            unused_types: self.unused_types,
187            unused_dependencies: self.unused_dependencies,
188            unused_dev_dependencies: self.unused_dev_dependencies,
189            unused_optional_dependencies: self.unused_optional_dependencies,
190            unused_enum_members: self.unused_enum_members,
191            unused_class_members: self.unused_class_members,
192            unresolved_imports: self.unresolved_imports,
193            unlisted_dependencies: self.unlisted_dependencies,
194            duplicate_exports: self.duplicate_exports,
195            circular_dependencies: self.circular_dependencies,
196            re_export_cycles: self.re_export_cycles,
197            type_only_dependencies: self.type_only_dependencies,
198            test_only_dependencies: self.test_only_dependencies,
199            boundary_violations: self.boundary_violations,
200            boundary_coverage_violations: self.boundary_coverage_violations,
201            boundary_call_violations: self.boundary_call_violations,
202            policy_violations: self.policy_violations,
203        }
204    }
205
206    /// Per-type deltas (current - baseline) for display. Only includes types with changes.
207    pub fn deltas(&self, current: &Self) -> Vec<(&'static str, isize)> {
208        let mut deltas = Vec::new();
209        macro_rules! push_delta {
210            ($field:ident) => {
211                push_count_delta(&mut deltas, stringify!($field), self.$field, current.$field);
212            };
213        }
214
215        push_delta!(unused_files);
216        push_delta!(unused_exports);
217        push_delta!(unused_types);
218        push_delta!(unused_dependencies);
219        push_delta!(unused_dev_dependencies);
220        push_delta!(unused_optional_dependencies);
221        push_delta!(unused_enum_members);
222        push_delta!(unused_class_members);
223        push_delta!(unused_store_members);
224        push_delta!(unprovided_injects);
225        push_delta!(unrendered_components);
226        push_delta!(unused_component_props);
227        push_delta!(unused_component_emits);
228        push_delta!(unused_server_actions);
229        push_delta!(unused_load_data_keys);
230        push_delta!(unresolved_imports);
231        push_delta!(unlisted_dependencies);
232        push_delta!(duplicate_exports);
233        push_delta!(circular_dependencies);
234        push_delta!(re_export_cycles);
235        push_delta!(type_only_dependencies);
236        push_delta!(test_only_dependencies);
237        push_delta!(boundary_violations);
238        push_delta!(boundary_coverage_violations);
239        push_delta!(boundary_call_violations);
240        push_delta!(policy_violations);
241        deltas
242    }
243}
244
245fn push_count_delta(
246    deltas: &mut Vec<(&'static str, isize)>,
247    name: &'static str,
248    baseline: usize,
249    current: usize,
250) {
251    let delta = current as isize - baseline as isize;
252    if delta != 0 {
253        deltas.push((name, delta));
254    }
255}
256
257/// Duplication counts for regression baseline.
258#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
259pub struct DupesCounts {
260    #[serde(default)]
261    pub clone_groups: usize,
262    #[serde(default)]
263    pub duplication_percentage: f64,
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use fallow_core::results::*;
270    use std::path::PathBuf;
271
272    #[test]
273    fn check_counts_from_results() {
274        let mut results = AnalysisResults::default();
275        results
276            .unused_files
277            .push(UnusedFileFinding::with_actions(UnusedFile {
278                path: PathBuf::from("a.ts"),
279            }));
280        results
281            .unused_exports
282            .push(UnusedExportFinding::with_actions(UnusedExport {
283                path: PathBuf::from("b.ts"),
284                export_name: "foo".into(),
285                is_type_only: false,
286                line: 1,
287                col: 0,
288                span_start: 0,
289                is_re_export: false,
290            }));
291        let counts = CheckCounts::from_results(&results);
292        assert_eq!(counts.total_issues, 2);
293        assert_eq!(counts.unused_files, 1);
294        assert_eq!(counts.unused_exports, 1);
295        assert_eq!(counts.unused_types, 0);
296    }
297
298    #[test]
299    fn deltas_reports_changes_only() {
300        let baseline = CheckCounts {
301            total_issues: 10,
302            unused_files: 5,
303            unused_exports: 3,
304            unused_types: 2,
305            unused_dependencies: 0,
306            unused_dev_dependencies: 0,
307            unused_optional_dependencies: 0,
308            unused_enum_members: 0,
309            unused_class_members: 0,
310            unused_store_members: 0,
311            unprovided_injects: 0,
312            unrendered_components: 0,
313            unused_component_props: 0,
314            unused_component_emits: 0,
315            unused_server_actions: 0,
316            unused_load_data_keys: 0,
317            unresolved_imports: 0,
318            unlisted_dependencies: 0,
319            duplicate_exports: 0,
320            circular_dependencies: 0,
321            re_export_cycles: 0,
322            type_only_dependencies: 0,
323            test_only_dependencies: 0,
324            boundary_violations: 0,
325            boundary_coverage_violations: 0,
326            boundary_call_violations: 0,
327            policy_violations: 0,
328        };
329        let current = CheckCounts {
330            unused_files: 7,   // +2
331            unused_exports: 1, // -2
332            unused_types: 2,   // 0 (no change)
333            ..baseline
334        };
335        let deltas = baseline.deltas(&current);
336        assert_eq!(deltas.len(), 2);
337        assert!(deltas.contains(&("unused_files", 2)));
338        assert!(deltas.contains(&("unused_exports", -2)));
339    }
340
341    #[test]
342    fn regression_baseline_roundtrip() {
343        let baseline = RegressionBaseline {
344            schema_version: 1,
345            fallow_version: "2.4.0".into(),
346            timestamp: "2026-03-27T10:00:00Z".into(),
347            git_sha: Some("abc123".into()),
348            check: Some(CheckCounts {
349                total_issues: 42,
350                unused_files: 5,
351                unused_exports: 20,
352                unused_types: 8,
353                unused_dependencies: 3,
354                unused_dev_dependencies: 2,
355                unused_optional_dependencies: 0,
356                unused_enum_members: 1,
357                unused_class_members: 1,
358                unused_store_members: 0,
359                unprovided_injects: 0,
360                unrendered_components: 0,
361                unused_component_props: 0,
362                unused_component_emits: 0,
363                unused_server_actions: 0,
364                unused_load_data_keys: 0,
365                unresolved_imports: 0,
366                unlisted_dependencies: 1,
367                duplicate_exports: 0,
368                circular_dependencies: 1,
369                re_export_cycles: 0,
370                type_only_dependencies: 0,
371                test_only_dependencies: 0,
372                boundary_violations: 0,
373                boundary_coverage_violations: 0,
374                boundary_call_violations: 0,
375                policy_violations: 0,
376            }),
377            dupes: Some(DupesCounts {
378                clone_groups: 12,
379                duplication_percentage: 4.2,
380            }),
381        };
382        let json = serde_json::to_string_pretty(&baseline).unwrap();
383        let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
384        assert_eq!(loaded.schema_version, 1);
385        assert_eq!(loaded.check.as_ref().unwrap().total_issues, 42);
386        assert_eq!(loaded.dupes.as_ref().unwrap().clone_groups, 12);
387    }
388
389    #[test]
390    fn check_counts_config_roundtrip() {
391        let counts = CheckCounts {
392            total_issues: 42,
393            unused_files: 5,
394            unused_exports: 20,
395            unused_types: 8,
396            unused_dependencies: 3,
397            unused_dev_dependencies: 2,
398            unused_optional_dependencies: 1,
399            unused_enum_members: 1,
400            unused_class_members: 1,
401            unused_store_members: 0,
402            unprovided_injects: 0,
403            unrendered_components: 0,
404            unused_component_props: 0,
405            unused_component_emits: 0,
406            unused_server_actions: 0,
407            unused_load_data_keys: 0,
408            unresolved_imports: 0,
409            unlisted_dependencies: 1,
410            duplicate_exports: 0,
411            circular_dependencies: 0,
412            re_export_cycles: 0,
413            type_only_dependencies: 0,
414            test_only_dependencies: 0,
415            boundary_violations: 0,
416            boundary_coverage_violations: 0,
417            boundary_call_violations: 0,
418            policy_violations: 0,
419        };
420        let config_baseline = counts.to_config_baseline();
421        let roundtripped = CheckCounts::from_config_baseline(&config_baseline);
422        assert_eq!(roundtripped.total_issues, 42);
423        assert_eq!(roundtripped.unused_files, 5);
424        assert_eq!(roundtripped.unused_exports, 20);
425        assert_eq!(roundtripped.unused_types, 8);
426        assert_eq!(roundtripped.unused_dependencies, 3);
427        assert_eq!(roundtripped.unused_dev_dependencies, 2);
428        assert_eq!(roundtripped.unused_optional_dependencies, 1);
429        assert_eq!(roundtripped.unused_enum_members, 1);
430        assert_eq!(roundtripped.unused_class_members, 1);
431        assert_eq!(roundtripped.unresolved_imports, 0);
432        assert_eq!(roundtripped.unlisted_dependencies, 1);
433        assert_eq!(roundtripped.duplicate_exports, 0);
434        assert_eq!(roundtripped.circular_dependencies, 0);
435        assert_eq!(roundtripped.type_only_dependencies, 0);
436        assert_eq!(roundtripped.test_only_dependencies, 0);
437    }
438
439    #[test]
440    fn check_counts_zero_config_roundtrip() {
441        let counts = CheckCounts {
442            total_issues: 0,
443            unused_files: 0,
444            unused_exports: 0,
445            unused_types: 0,
446            unused_dependencies: 0,
447            unused_dev_dependencies: 0,
448            unused_optional_dependencies: 0,
449            unused_enum_members: 0,
450            unused_class_members: 0,
451            unused_store_members: 0,
452            unprovided_injects: 0,
453            unrendered_components: 0,
454            unused_component_props: 0,
455            unused_component_emits: 0,
456            unused_server_actions: 0,
457            unused_load_data_keys: 0,
458            unresolved_imports: 0,
459            unlisted_dependencies: 0,
460            duplicate_exports: 0,
461            circular_dependencies: 0,
462            re_export_cycles: 0,
463            type_only_dependencies: 0,
464            test_only_dependencies: 0,
465            boundary_violations: 0,
466            boundary_coverage_violations: 0,
467            boundary_call_violations: 0,
468            policy_violations: 0,
469        };
470        let config_baseline = counts.to_config_baseline();
471        let roundtripped = CheckCounts::from_config_baseline(&config_baseline);
472        assert_eq!(roundtripped.total_issues, 0);
473        assert_eq!(roundtripped.unused_files, 0);
474    }
475
476    #[test]
477    fn deltas_empty_when_identical() {
478        let counts = CheckCounts {
479            total_issues: 10,
480            unused_files: 5,
481            unused_exports: 3,
482            unused_types: 2,
483            unused_dependencies: 0,
484            unused_dev_dependencies: 0,
485            unused_optional_dependencies: 0,
486            unused_enum_members: 0,
487            unused_class_members: 0,
488            unused_store_members: 0,
489            unprovided_injects: 0,
490            unrendered_components: 0,
491            unused_component_props: 0,
492            unused_component_emits: 0,
493            unused_server_actions: 0,
494            unused_load_data_keys: 0,
495            unresolved_imports: 0,
496            unlisted_dependencies: 0,
497            duplicate_exports: 0,
498            circular_dependencies: 0,
499            re_export_cycles: 0,
500            type_only_dependencies: 0,
501            test_only_dependencies: 0,
502            boundary_violations: 0,
503            boundary_coverage_violations: 0,
504            boundary_call_violations: 0,
505            policy_violations: 0,
506        };
507        let deltas = counts.deltas(&counts);
508        assert!(deltas.is_empty());
509    }
510
511    #[test]
512    fn deltas_all_categories_changed() {
513        let baseline = CheckCounts {
514            total_issues: 0,
515            unused_files: 0,
516            unused_exports: 0,
517            unused_types: 0,
518            unused_dependencies: 0,
519            unused_dev_dependencies: 0,
520            unused_optional_dependencies: 0,
521            unused_enum_members: 0,
522            unused_class_members: 0,
523            unused_store_members: 0,
524            unprovided_injects: 0,
525            unrendered_components: 0,
526            unused_component_props: 0,
527            unused_component_emits: 0,
528            unused_server_actions: 0,
529            unused_load_data_keys: 0,
530            unresolved_imports: 0,
531            unlisted_dependencies: 0,
532            duplicate_exports: 0,
533            circular_dependencies: 0,
534            re_export_cycles: 0,
535            type_only_dependencies: 0,
536            test_only_dependencies: 0,
537            boundary_violations: 0,
538            boundary_coverage_violations: 0,
539            boundary_call_violations: 0,
540            policy_violations: 0,
541        };
542        let current = CheckCounts {
543            total_issues: 15,
544            unused_files: 1,
545            unused_exports: 1,
546            unused_types: 1,
547            unused_dependencies: 1,
548            unused_dev_dependencies: 1,
549            unused_optional_dependencies: 1,
550            unused_enum_members: 1,
551            unused_class_members: 1,
552            unused_store_members: 1,
553            unprovided_injects: 1,
554            unrendered_components: 1,
555            unused_component_props: 0,
556            unused_component_emits: 0,
557            unused_server_actions: 0,
558            unused_load_data_keys: 0,
559            unresolved_imports: 1,
560            unlisted_dependencies: 1,
561            duplicate_exports: 1,
562            circular_dependencies: 1,
563            re_export_cycles: 0,
564            type_only_dependencies: 1,
565            test_only_dependencies: 1,
566            boundary_violations: 1,
567            boundary_coverage_violations: 0,
568            boundary_call_violations: 0,
569            policy_violations: 0,
570        };
571        let deltas = baseline.deltas(&current);
572        assert_eq!(deltas.len(), 18);
573        for (_, d) in &deltas {
574            assert_eq!(*d, 1);
575        }
576    }
577
578    #[test]
579    fn deltas_mixed_increase_decrease() {
580        let baseline = CheckCounts {
581            total_issues: 10,
582            unused_files: 5,
583            unused_exports: 3,
584            unused_types: 2,
585            unused_dependencies: 0,
586            unused_dev_dependencies: 0,
587            unused_optional_dependencies: 0,
588            unused_enum_members: 0,
589            unused_class_members: 0,
590            unused_store_members: 0,
591            unprovided_injects: 0,
592            unrendered_components: 0,
593            unused_component_props: 0,
594            unused_component_emits: 0,
595            unused_server_actions: 0,
596            unused_load_data_keys: 0,
597            unresolved_imports: 0,
598            unlisted_dependencies: 0,
599            duplicate_exports: 0,
600            circular_dependencies: 0,
601            re_export_cycles: 0,
602            type_only_dependencies: 0,
603            test_only_dependencies: 0,
604            boundary_violations: 0,
605            boundary_coverage_violations: 0,
606            boundary_call_violations: 0,
607            policy_violations: 0,
608        };
609        let current = CheckCounts {
610            unused_files: 3,
611            unused_exports: 5,
612            unused_types: 0,
613            unresolved_imports: 1,
614            ..baseline
615        };
616        let deltas = baseline.deltas(&current);
617        assert_eq!(deltas.len(), 4);
618        assert!(deltas.contains(&("unused_files", -2)));
619        assert!(deltas.contains(&("unused_exports", 2)));
620        assert!(deltas.contains(&("unused_types", -2)));
621        assert!(deltas.contains(&("unresolved_imports", 1)));
622    }
623
624    #[test]
625    fn dupes_counts_roundtrip() {
626        let dupes = DupesCounts {
627            clone_groups: 8,
628            duplication_percentage: 3.17,
629        };
630        let json = serde_json::to_string(&dupes).unwrap();
631        let loaded: DupesCounts = serde_json::from_str(&json).unwrap();
632        assert_eq!(loaded.clone_groups, 8);
633        assert!((loaded.duplication_percentage - 3.17).abs() < f64::EPSILON);
634    }
635
636    #[test]
637    fn dupes_counts_default_fields() {
638        let json = "{}";
639        let loaded: DupesCounts = serde_json::from_str(json).unwrap();
640        assert_eq!(loaded.clone_groups, 0);
641        assert!((loaded.duplication_percentage).abs() < f64::EPSILON);
642    }
643
644    #[test]
645    fn baseline_without_check_section() {
646        let baseline = RegressionBaseline {
647            schema_version: 1,
648            fallow_version: "2.4.0".into(),
649            timestamp: "2026-03-27T10:00:00Z".into(),
650            git_sha: None,
651            check: None,
652            dupes: Some(DupesCounts {
653                clone_groups: 3,
654                duplication_percentage: 1.0,
655            }),
656        };
657        let json = serde_json::to_string_pretty(&baseline).unwrap();
658        let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
659        assert!(loaded.check.is_none());
660        assert!(loaded.dupes.is_some());
661    }
662
663    #[test]
664    fn baseline_without_dupes_section() {
665        let baseline = RegressionBaseline {
666            schema_version: 1,
667            fallow_version: "2.4.0".into(),
668            timestamp: "2026-03-27T10:00:00Z".into(),
669            git_sha: Some("deadbeef".into()),
670            check: Some(CheckCounts {
671                total_issues: 1,
672                unused_files: 1,
673                ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
674            }),
675            dupes: None,
676        };
677        let json = serde_json::to_string_pretty(&baseline).unwrap();
678        let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
679        assert!(loaded.check.is_some());
680        assert!(loaded.dupes.is_none());
681        assert_eq!(loaded.git_sha.as_deref(), Some("deadbeef"));
682    }
683
684    #[test]
685    fn baseline_without_git_sha() {
686        let baseline = RegressionBaseline {
687            schema_version: 1,
688            fallow_version: "2.4.0".into(),
689            timestamp: "2026-03-27T10:00:00Z".into(),
690            git_sha: None,
691            check: None,
692            dupes: None,
693        };
694        let json = serde_json::to_string_pretty(&baseline).unwrap();
695        assert!(!json.contains("git_sha"));
696        let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
697        assert!(loaded.git_sha.is_none());
698    }
699
700    #[test]
701    fn baseline_json_with_unknown_check_fields_deserializes() {
702        let json = r#"{
703            "schema_version": 1,
704            "fallow_version": "3.0.0",
705            "timestamp": "2026-03-27T10:00:00Z",
706            "check": {
707                "total_issues": 10,
708                "unused_files": 2,
709                "some_future_field": 99
710            }
711        }"#;
712        let loaded: Result<RegressionBaseline, _> = serde_json::from_str(json);
713        assert!(loaded.is_ok());
714        let loaded = loaded.unwrap();
715        assert_eq!(loaded.check.as_ref().unwrap().total_issues, 10);
716    }
717}