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_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    /// Convert from config-embedded baseline.
141    #[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            // `fallow_config::RegressionBaseline` has no `unused_store_members`
154            // field; default to 0 until the config baseline schema gains one.
155            unused_store_members: 0,
156            // `fallow_config::RegressionBaseline` has no `unprovided_injects`
157            // field; default to 0 until the config baseline schema gains one.
158            unprovided_injects: 0,
159            // `fallow_config::RegressionBaseline` has no `unrendered_components`
160            // field; default to 0 until the config baseline schema gains one.
161            unrendered_components: 0,
162            // `fallow_config::RegressionBaseline` has no `unused_component_props`
163            // field; default to 0 until the config baseline schema gains one.
164            unused_component_props: 0,
165            // `fallow_config::RegressionBaseline` has no `unused_component_emits`
166            // field; default to 0 until the config baseline schema gains one.
167            unused_component_emits: 0,
168            // `fallow_config::RegressionBaseline` has no `unused_component_inputs`
169            // field; default to 0 until the config baseline schema gains one.
170            unused_component_inputs: 0,
171            // `fallow_config::RegressionBaseline` has no `unused_component_outputs`
172            // field; default to 0 until the config baseline schema gains one.
173            unused_component_outputs: 0,
174            // `fallow_config::RegressionBaseline` has no `unused_svelte_events`
175            // field; default to 0 until the config baseline schema gains one.
176            unused_svelte_events: 0,
177            // `fallow_config::RegressionBaseline` has no `unused_server_actions`
178            // field; default to 0 until the config baseline schema gains one.
179            unused_server_actions: 0,
180            // `fallow_config::RegressionBaseline` has no `unused_load_data_keys`
181            // field; default to 0 until the config baseline schema gains one.
182            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    /// Convert to config-embeddable baseline.
198    #[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    /// Per-type deltas (current - baseline) for display. Only includes types with changes.
225    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/// Duplication counts for regression baseline.
279#[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,   // +2
355            unused_exports: 1, // -2
356            unused_types: 2,   // 0 (no change)
357            ..baseline
358        };
359        let deltas = baseline.deltas(&current);
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(&current);
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(&current);
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}