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.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 #[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, unused_exports: 1, unused_types: 2, ..baseline
291 };
292 let deltas = baseline.deltas(¤t);
293 assert_eq!(deltas.len(), 2);
294 assert!(deltas.contains(&("unused_files", 2)));
295 assert!(deltas.contains(&("unused_exports", -2)));
296 }
297
298 #[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 #[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 #[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(¤t);
469 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, unused_exports: 5, unused_types: 0, unresolved_imports: 1, ..baseline
502 };
503 let deltas = baseline.deltas(¤t);
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 #[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 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 #[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 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 #[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);
609 assert!(loaded.is_ok());
611 let loaded = loaded.unwrap();
612 assert_eq!(loaded.check.as_ref().unwrap().total_issues, 10);
613 }
614}