1use fallow_core::results::AnalysisResults;
2
3#[derive(Debug, serde::Serialize, serde::Deserialize)]
10pub struct RegressionBaseline {
11 pub schema_version: u32,
13 pub fallow_version: String,
15 pub timestamp: String,
17 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub git_sha: Option<String>,
20 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub check: Option<CheckCounts>,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub dupes: Option<DupesCounts>,
26}
27
28pub const REGRESSION_SCHEMA_VERSION: u32 = 1;
29
30#[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 #[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 #[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 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#[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 #[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 #[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, unused_exports: 1, unused_types: 2, ..baseline
295 };
296 let deltas = baseline.deltas(¤t);
297 assert_eq!(deltas.len(), 2);
298 assert!(deltas.contains(&("unused_files", 2)));
299 assert!(deltas.contains(&("unused_exports", -2)));
300 }
301
302 #[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 #[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 #[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(¤t);
473 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, unused_exports: 5, unused_types: 0, unresolved_imports: 1, ..baseline
506 };
507 let deltas = baseline.deltas(¤t);
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 #[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 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 #[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 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 #[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 let loaded: Result<RegressionBaseline, _> = serde_json::from_str(json);
613 assert!(loaded.is_ok());
615 let loaded = loaded.unwrap();
616 assert_eq!(loaded.check.as_ref().unwrap().total_issues, 10);
617 }
618}