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