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