Skip to main content

fallow_cli/regression/
counts.rs

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