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