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.unused_files.push(UnusedFile {
246            path: PathBuf::from("a.ts"),
247        });
248        results.unused_exports.push(UnusedExport {
249            path: PathBuf::from("b.ts"),
250            export_name: "foo".into(),
251            is_type_only: false,
252            line: 1,
253            col: 0,
254            span_start: 0,
255            is_re_export: false,
256        });
257        let counts = CheckCounts::from_results(&results);
258        assert_eq!(counts.total_issues, 2);
259        assert_eq!(counts.unused_files, 1);
260        assert_eq!(counts.unused_exports, 1);
261        assert_eq!(counts.unused_types, 0);
262    }
263
264    // ── CheckCounts::deltas ────────────────────────────────────────
265
266    #[test]
267    fn deltas_reports_changes_only() {
268        let baseline = CheckCounts {
269            total_issues: 10,
270            unused_files: 5,
271            unused_exports: 3,
272            unused_types: 2,
273            unused_dependencies: 0,
274            unused_dev_dependencies: 0,
275            unused_optional_dependencies: 0,
276            unused_enum_members: 0,
277            unused_class_members: 0,
278            unresolved_imports: 0,
279            unlisted_dependencies: 0,
280            duplicate_exports: 0,
281            circular_dependencies: 0,
282            type_only_dependencies: 0,
283            test_only_dependencies: 0,
284            boundary_violations: 0,
285        };
286        let current = CheckCounts {
287            unused_files: 7,   // +2
288            unused_exports: 1, // -2
289            unused_types: 2,   // 0 (no change)
290            ..baseline
291        };
292        let deltas = baseline.deltas(&current);
293        assert_eq!(deltas.len(), 2);
294        assert!(deltas.contains(&("unused_files", 2)));
295        assert!(deltas.contains(&("unused_exports", -2)));
296    }
297
298    // ── Regression baseline serialization roundtrip ────────────────
299
300    #[test]
301    fn regression_baseline_roundtrip() {
302        let baseline = RegressionBaseline {
303            schema_version: 1,
304            fallow_version: "2.4.0".into(),
305            timestamp: "2026-03-27T10:00:00Z".into(),
306            git_sha: Some("abc123".into()),
307            check: Some(CheckCounts {
308                total_issues: 42,
309                unused_files: 5,
310                unused_exports: 20,
311                unused_types: 8,
312                unused_dependencies: 3,
313                unused_dev_dependencies: 2,
314                unused_optional_dependencies: 0,
315                unused_enum_members: 1,
316                unused_class_members: 1,
317                unresolved_imports: 0,
318                unlisted_dependencies: 1,
319                duplicate_exports: 0,
320                circular_dependencies: 1,
321                type_only_dependencies: 0,
322                test_only_dependencies: 0,
323                boundary_violations: 0,
324            }),
325            dupes: Some(DupesCounts {
326                clone_groups: 12,
327                duplication_percentage: 4.2,
328            }),
329        };
330        let json = serde_json::to_string_pretty(&baseline).unwrap();
331        let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
332        assert_eq!(loaded.schema_version, 1);
333        assert_eq!(loaded.check.as_ref().unwrap().total_issues, 42);
334        assert_eq!(loaded.dupes.as_ref().unwrap().clone_groups, 12);
335    }
336
337    // ── CheckCounts config baseline roundtrip ────────────────────────
338
339    #[test]
340    fn check_counts_config_roundtrip() {
341        let counts = CheckCounts {
342            total_issues: 42,
343            unused_files: 5,
344            unused_exports: 20,
345            unused_types: 8,
346            unused_dependencies: 3,
347            unused_dev_dependencies: 2,
348            unused_optional_dependencies: 1,
349            unused_enum_members: 1,
350            unused_class_members: 1,
351            unresolved_imports: 0,
352            unlisted_dependencies: 1,
353            duplicate_exports: 0,
354            circular_dependencies: 0,
355            type_only_dependencies: 0,
356            test_only_dependencies: 0,
357            boundary_violations: 0,
358        };
359        let config_baseline = counts.to_config_baseline();
360        let roundtripped = CheckCounts::from_config_baseline(&config_baseline);
361        assert_eq!(roundtripped.total_issues, 42);
362        assert_eq!(roundtripped.unused_files, 5);
363        assert_eq!(roundtripped.unused_exports, 20);
364        assert_eq!(roundtripped.unused_types, 8);
365        assert_eq!(roundtripped.unused_dependencies, 3);
366        assert_eq!(roundtripped.unused_dev_dependencies, 2);
367        assert_eq!(roundtripped.unused_optional_dependencies, 1);
368        assert_eq!(roundtripped.unused_enum_members, 1);
369        assert_eq!(roundtripped.unused_class_members, 1);
370        assert_eq!(roundtripped.unresolved_imports, 0);
371        assert_eq!(roundtripped.unlisted_dependencies, 1);
372        assert_eq!(roundtripped.duplicate_exports, 0);
373        assert_eq!(roundtripped.circular_dependencies, 0);
374        assert_eq!(roundtripped.type_only_dependencies, 0);
375        assert_eq!(roundtripped.test_only_dependencies, 0);
376    }
377
378    #[test]
379    fn check_counts_zero_config_roundtrip() {
380        let counts = CheckCounts {
381            total_issues: 0,
382            unused_files: 0,
383            unused_exports: 0,
384            unused_types: 0,
385            unused_dependencies: 0,
386            unused_dev_dependencies: 0,
387            unused_optional_dependencies: 0,
388            unused_enum_members: 0,
389            unused_class_members: 0,
390            unresolved_imports: 0,
391            unlisted_dependencies: 0,
392            duplicate_exports: 0,
393            circular_dependencies: 0,
394            type_only_dependencies: 0,
395            test_only_dependencies: 0,
396            boundary_violations: 0,
397        };
398        let config_baseline = counts.to_config_baseline();
399        let roundtripped = CheckCounts::from_config_baseline(&config_baseline);
400        assert_eq!(roundtripped.total_issues, 0);
401        assert_eq!(roundtripped.unused_files, 0);
402    }
403
404    // ── deltas edge cases ──────────────────────────────────────────
405
406    #[test]
407    fn deltas_empty_when_identical() {
408        let counts = CheckCounts {
409            total_issues: 10,
410            unused_files: 5,
411            unused_exports: 3,
412            unused_types: 2,
413            unused_dependencies: 0,
414            unused_dev_dependencies: 0,
415            unused_optional_dependencies: 0,
416            unused_enum_members: 0,
417            unused_class_members: 0,
418            unresolved_imports: 0,
419            unlisted_dependencies: 0,
420            duplicate_exports: 0,
421            circular_dependencies: 0,
422            type_only_dependencies: 0,
423            test_only_dependencies: 0,
424            boundary_violations: 0,
425        };
426        let deltas = counts.deltas(&counts);
427        assert!(deltas.is_empty());
428    }
429
430    #[test]
431    fn deltas_all_categories_changed() {
432        let baseline = CheckCounts {
433            total_issues: 0,
434            unused_files: 0,
435            unused_exports: 0,
436            unused_types: 0,
437            unused_dependencies: 0,
438            unused_dev_dependencies: 0,
439            unused_optional_dependencies: 0,
440            unused_enum_members: 0,
441            unused_class_members: 0,
442            unresolved_imports: 0,
443            unlisted_dependencies: 0,
444            duplicate_exports: 0,
445            circular_dependencies: 0,
446            type_only_dependencies: 0,
447            test_only_dependencies: 0,
448            boundary_violations: 0,
449        };
450        let current = CheckCounts {
451            total_issues: 14,
452            unused_files: 1,
453            unused_exports: 1,
454            unused_types: 1,
455            unused_dependencies: 1,
456            unused_dev_dependencies: 1,
457            unused_optional_dependencies: 1,
458            unused_enum_members: 1,
459            unused_class_members: 1,
460            unresolved_imports: 1,
461            unlisted_dependencies: 1,
462            duplicate_exports: 1,
463            circular_dependencies: 1,
464            type_only_dependencies: 1,
465            test_only_dependencies: 1,
466            boundary_violations: 1,
467        };
468        let deltas = baseline.deltas(&current);
469        // total_issues is not in deltas — only per-type fields
470        assert_eq!(deltas.len(), 15);
471        for (_, d) in &deltas {
472            assert_eq!(*d, 1);
473        }
474    }
475
476    #[test]
477    fn deltas_mixed_increase_decrease() {
478        let baseline = 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            unresolved_imports: 0,
489            unlisted_dependencies: 0,
490            duplicate_exports: 0,
491            circular_dependencies: 0,
492            type_only_dependencies: 0,
493            test_only_dependencies: 0,
494            boundary_violations: 0,
495        };
496        let current = CheckCounts {
497            unused_files: 3,       // -2
498            unused_exports: 5,     // +2
499            unused_types: 0,       // -2
500            unresolved_imports: 1, // +1
501            ..baseline
502        };
503        let deltas = baseline.deltas(&current);
504        assert_eq!(deltas.len(), 4);
505        assert!(deltas.contains(&("unused_files", -2)));
506        assert!(deltas.contains(&("unused_exports", 2)));
507        assert!(deltas.contains(&("unused_types", -2)));
508        assert!(deltas.contains(&("unresolved_imports", 1)));
509    }
510
511    // ── DupesCounts serialization ──────────────────────────────────
512
513    #[test]
514    fn dupes_counts_roundtrip() {
515        let dupes = DupesCounts {
516            clone_groups: 8,
517            duplication_percentage: 3.17,
518        };
519        let json = serde_json::to_string(&dupes).unwrap();
520        let loaded: DupesCounts = serde_json::from_str(&json).unwrap();
521        assert_eq!(loaded.clone_groups, 8);
522        assert!((loaded.duplication_percentage - 3.17).abs() < f64::EPSILON);
523    }
524
525    #[test]
526    fn dupes_counts_default_fields() {
527        // Deserializing with missing fields should default to zero
528        let json = "{}";
529        let loaded: DupesCounts = serde_json::from_str(json).unwrap();
530        assert_eq!(loaded.clone_groups, 0);
531        assert!((loaded.duplication_percentage).abs() < f64::EPSILON);
532    }
533
534    // ── RegressionBaseline with missing optional sections ──────────
535
536    #[test]
537    fn baseline_without_check_section() {
538        let baseline = RegressionBaseline {
539            schema_version: 1,
540            fallow_version: "2.4.0".into(),
541            timestamp: "2026-03-27T10:00:00Z".into(),
542            git_sha: None,
543            check: None,
544            dupes: Some(DupesCounts {
545                clone_groups: 3,
546                duplication_percentage: 1.0,
547            }),
548        };
549        let json = serde_json::to_string_pretty(&baseline).unwrap();
550        let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
551        assert!(loaded.check.is_none());
552        assert!(loaded.dupes.is_some());
553    }
554
555    #[test]
556    fn baseline_without_dupes_section() {
557        let baseline = RegressionBaseline {
558            schema_version: 1,
559            fallow_version: "2.4.0".into(),
560            timestamp: "2026-03-27T10:00:00Z".into(),
561            git_sha: Some("deadbeef".into()),
562            check: Some(CheckCounts {
563                total_issues: 1,
564                unused_files: 1,
565                ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
566            }),
567            dupes: None,
568        };
569        let json = serde_json::to_string_pretty(&baseline).unwrap();
570        let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
571        assert!(loaded.check.is_some());
572        assert!(loaded.dupes.is_none());
573        assert_eq!(loaded.git_sha.as_deref(), Some("deadbeef"));
574    }
575
576    #[test]
577    fn baseline_without_git_sha() {
578        let baseline = RegressionBaseline {
579            schema_version: 1,
580            fallow_version: "2.4.0".into(),
581            timestamp: "2026-03-27T10:00:00Z".into(),
582            git_sha: None,
583            check: None,
584            dupes: None,
585        };
586        let json = serde_json::to_string_pretty(&baseline).unwrap();
587        // git_sha should be skipped in serialization
588        assert!(!json.contains("git_sha"));
589        let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
590        assert!(loaded.git_sha.is_none());
591    }
592
593    // ── Forward compatibility: extra fields are ignored ──────────────
594
595    #[test]
596    fn baseline_json_with_unknown_check_fields_deserializes() {
597        let json = r#"{
598            "schema_version": 1,
599            "fallow_version": "3.0.0",
600            "timestamp": "2026-03-27T10:00:00Z",
601            "check": {
602                "total_issues": 10,
603                "unused_files": 2,
604                "some_future_field": 99
605            }
606        }"#;
607        // Should not fail — extra fields are ignored by serde default
608        let loaded: Result<RegressionBaseline, _> = serde_json::from_str(json);
609        // Note: serde doesn't deny unknown fields by default, so this should work
610        assert!(loaded.is_ok());
611        let loaded = loaded.unwrap();
612        assert_eq!(loaded.check.as_ref().unwrap().total_issues, 10);
613    }
614}