1use std::path::Path;
2use std::process::ExitCode;
3
4use fallow_core::results::AnalysisResults;
5
6#[derive(Debug, Clone, Copy)]
10pub enum Tolerance {
11 Percentage(f64),
13 Absolute(usize),
15}
16
17impl Tolerance {
18 pub fn parse(s: &str) -> Result<Self, String> {
26 let s = s.trim();
27 if s.is_empty() {
28 return Ok(Self::Absolute(0));
29 }
30 if let Some(pct_str) = s.strip_suffix('%') {
31 let pct: f64 = pct_str
32 .trim()
33 .parse()
34 .map_err(|_| format!("invalid tolerance percentage: {s}"))?;
35 if pct < 0.0 {
36 return Err(format!("tolerance percentage must be non-negative: {s}"));
37 }
38 Ok(Self::Percentage(pct))
39 } else {
40 let abs: usize = s
41 .parse()
42 .map_err(|_| format!("invalid tolerance value: {s} (use a number or N%)"))?;
43 Ok(Self::Absolute(abs))
44 }
45 }
46
47 #[expect(
49 clippy::cast_possible_truncation,
50 reason = "percentage of a count is bounded by the count itself"
51 )]
52 fn exceeded(&self, baseline_total: usize, current_total: usize) -> bool {
53 if current_total <= baseline_total {
54 return false;
55 }
56 let delta = current_total - baseline_total;
57 match *self {
58 Self::Percentage(pct) => {
59 if baseline_total == 0 {
60 return delta > 0;
62 }
63 let allowed = (baseline_total as f64 * pct / 100.0).floor() as usize;
64 delta > allowed
65 }
66 Self::Absolute(abs) => delta > abs,
67 }
68 }
69}
70
71#[derive(Debug, serde::Serialize, serde::Deserialize)]
78pub struct RegressionBaseline {
79 pub schema_version: u32,
81 pub fallow_version: String,
83 pub timestamp: String,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub git_sha: Option<String>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub check: Option<CheckCounts>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub dupes: Option<DupesCounts>,
94}
95
96const REGRESSION_SCHEMA_VERSION: u32 = 1;
97
98#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
104pub struct CheckCounts {
105 #[serde(default)]
106 pub total_issues: usize,
107 #[serde(default)]
108 pub unused_files: usize,
109 #[serde(default)]
110 pub unused_exports: usize,
111 #[serde(default)]
112 pub unused_types: usize,
113 #[serde(default)]
114 pub unused_dependencies: usize,
115 #[serde(default)]
116 pub unused_dev_dependencies: usize,
117 #[serde(default)]
118 pub unused_optional_dependencies: usize,
119 #[serde(default)]
120 pub unused_enum_members: usize,
121 #[serde(default)]
122 pub unused_class_members: usize,
123 #[serde(default)]
124 pub unresolved_imports: usize,
125 #[serde(default)]
126 pub unlisted_dependencies: usize,
127 #[serde(default)]
128 pub duplicate_exports: usize,
129 #[serde(default)]
130 pub circular_dependencies: usize,
131 #[serde(default)]
132 pub type_only_dependencies: usize,
133 #[serde(default)]
134 pub test_only_dependencies: usize,
135 #[serde(default)]
136 pub boundary_violations: usize,
137}
138
139impl CheckCounts {
140 #[must_use]
141 pub const fn from_results(results: &AnalysisResults) -> Self {
142 Self {
143 total_issues: results.total_issues(),
144 unused_files: results.unused_files.len(),
145 unused_exports: results.unused_exports.len(),
146 unused_types: results.unused_types.len(),
147 unused_dependencies: results.unused_dependencies.len(),
148 unused_dev_dependencies: results.unused_dev_dependencies.len(),
149 unused_optional_dependencies: results.unused_optional_dependencies.len(),
150 unused_enum_members: results.unused_enum_members.len(),
151 unused_class_members: results.unused_class_members.len(),
152 unresolved_imports: results.unresolved_imports.len(),
153 unlisted_dependencies: results.unlisted_dependencies.len(),
154 duplicate_exports: results.duplicate_exports.len(),
155 circular_dependencies: results.circular_dependencies.len(),
156 type_only_dependencies: results.type_only_dependencies.len(),
157 test_only_dependencies: results.test_only_dependencies.len(),
158 boundary_violations: results.boundary_violations.len(),
159 }
160 }
161
162 #[must_use]
164 pub const fn from_config_baseline(b: &fallow_config::RegressionBaseline) -> Self {
165 Self {
166 total_issues: b.total_issues,
167 unused_files: b.unused_files,
168 unused_exports: b.unused_exports,
169 unused_types: b.unused_types,
170 unused_dependencies: b.unused_dependencies,
171 unused_dev_dependencies: b.unused_dev_dependencies,
172 unused_optional_dependencies: b.unused_optional_dependencies,
173 unused_enum_members: b.unused_enum_members,
174 unused_class_members: b.unused_class_members,
175 unresolved_imports: b.unresolved_imports,
176 unlisted_dependencies: b.unlisted_dependencies,
177 duplicate_exports: b.duplicate_exports,
178 circular_dependencies: b.circular_dependencies,
179 type_only_dependencies: b.type_only_dependencies,
180 test_only_dependencies: b.test_only_dependencies,
181 boundary_violations: b.boundary_violations,
182 }
183 }
184
185 #[must_use]
187 pub const fn to_config_baseline(&self) -> fallow_config::RegressionBaseline {
188 fallow_config::RegressionBaseline {
189 total_issues: self.total_issues,
190 unused_files: self.unused_files,
191 unused_exports: self.unused_exports,
192 unused_types: self.unused_types,
193 unused_dependencies: self.unused_dependencies,
194 unused_dev_dependencies: self.unused_dev_dependencies,
195 unused_optional_dependencies: self.unused_optional_dependencies,
196 unused_enum_members: self.unused_enum_members,
197 unused_class_members: self.unused_class_members,
198 unresolved_imports: self.unresolved_imports,
199 unlisted_dependencies: self.unlisted_dependencies,
200 duplicate_exports: self.duplicate_exports,
201 circular_dependencies: self.circular_dependencies,
202 type_only_dependencies: self.type_only_dependencies,
203 test_only_dependencies: self.test_only_dependencies,
204 boundary_violations: self.boundary_violations,
205 }
206 }
207
208 fn deltas(&self, current: &Self) -> Vec<(&'static str, isize)> {
210 let pairs: Vec<(&str, usize, usize)> = vec![
211 ("unused_files", self.unused_files, current.unused_files),
212 (
213 "unused_exports",
214 self.unused_exports,
215 current.unused_exports,
216 ),
217 ("unused_types", self.unused_types, current.unused_types),
218 (
219 "unused_dependencies",
220 self.unused_dependencies,
221 current.unused_dependencies,
222 ),
223 (
224 "unused_dev_dependencies",
225 self.unused_dev_dependencies,
226 current.unused_dev_dependencies,
227 ),
228 (
229 "unused_optional_dependencies",
230 self.unused_optional_dependencies,
231 current.unused_optional_dependencies,
232 ),
233 (
234 "unused_enum_members",
235 self.unused_enum_members,
236 current.unused_enum_members,
237 ),
238 (
239 "unused_class_members",
240 self.unused_class_members,
241 current.unused_class_members,
242 ),
243 (
244 "unresolved_imports",
245 self.unresolved_imports,
246 current.unresolved_imports,
247 ),
248 (
249 "unlisted_dependencies",
250 self.unlisted_dependencies,
251 current.unlisted_dependencies,
252 ),
253 (
254 "duplicate_exports",
255 self.duplicate_exports,
256 current.duplicate_exports,
257 ),
258 (
259 "circular_dependencies",
260 self.circular_dependencies,
261 current.circular_dependencies,
262 ),
263 (
264 "type_only_dependencies",
265 self.type_only_dependencies,
266 current.type_only_dependencies,
267 ),
268 (
269 "test_only_dependencies",
270 self.test_only_dependencies,
271 current.test_only_dependencies,
272 ),
273 (
274 "boundary_violations",
275 self.boundary_violations,
276 current.boundary_violations,
277 ),
278 ];
279 pairs
280 .into_iter()
281 .filter_map(|(name, baseline, current)| {
282 let delta = current as isize - baseline as isize;
283 if delta != 0 {
284 Some((name, delta))
285 } else {
286 None
287 }
288 })
289 .collect()
290 }
291}
292
293#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
295pub struct DupesCounts {
296 #[serde(default)]
297 pub clone_groups: usize,
298 #[serde(default)]
299 pub duplication_percentage: f64,
300}
301
302#[derive(Debug)]
306pub enum RegressionOutcome {
307 Pass {
309 baseline_total: usize,
310 current_total: usize,
311 },
312 Exceeded {
314 baseline_total: usize,
315 current_total: usize,
316 tolerance: Tolerance,
317 type_deltas: Vec<(&'static str, isize)>,
319 },
320 Skipped { reason: &'static str },
322}
323
324impl RegressionOutcome {
325 #[must_use]
327 pub const fn is_failure(&self) -> bool {
328 matches!(self, Self::Exceeded { .. })
329 }
330
331 #[must_use]
333 pub fn to_json(&self) -> serde_json::Value {
334 match self {
335 Self::Pass {
336 baseline_total,
337 current_total,
338 } => serde_json::json!({
339 "status": "pass",
340 "baseline_total": baseline_total,
341 "current_total": current_total,
342 "delta": *current_total as isize - *baseline_total as isize,
343 "exceeded": false,
344 }),
345 Self::Exceeded {
346 baseline_total,
347 current_total,
348 tolerance,
349 ..
350 } => {
351 let (tolerance_value, tolerance_kind) = match tolerance {
352 Tolerance::Percentage(pct) => (*pct, "percentage"),
353 Tolerance::Absolute(abs) => (*abs as f64, "absolute"),
354 };
355 serde_json::json!({
356 "status": "exceeded",
357 "baseline_total": baseline_total,
358 "current_total": current_total,
359 "delta": *current_total as isize - *baseline_total as isize,
360 "tolerance": tolerance_value,
361 "tolerance_kind": tolerance_kind,
362 "exceeded": true,
363 })
364 }
365 Self::Skipped { reason } => serde_json::json!({
366 "status": "skipped",
367 "reason": reason,
368 "exceeded": false,
369 }),
370 }
371 }
372}
373
374#[derive(Clone, Copy)]
378pub enum SaveRegressionTarget<'a> {
379 None,
381 Config,
383 File(&'a Path),
385}
386
387#[derive(Clone, Copy)]
389pub struct RegressionOpts<'a> {
390 pub fail_on_regression: bool,
391 pub tolerance: Tolerance,
392 pub regression_baseline_file: Option<&'a Path>,
394 pub save_target: SaveRegressionTarget<'a>,
396 pub scoped: bool,
398 pub quiet: bool,
399}
400
401fn is_likely_gitignored(path: &Path, root: &Path) -> bool {
404 std::process::Command::new("git")
405 .args(["check-ignore", "-q"])
406 .arg(path)
407 .current_dir(root)
408 .output()
409 .ok()
410 .is_some_and(|o| o.status.success())
411}
412
413fn current_git_sha(root: &Path) -> Option<String> {
415 std::process::Command::new("git")
416 .args(["rev-parse", "HEAD"])
417 .current_dir(root)
418 .output()
419 .ok()
420 .filter(|o| o.status.success())
421 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
422}
423
424pub fn save_regression_baseline(
430 path: &Path,
431 root: &Path,
432 check_counts: Option<&CheckCounts>,
433 dupes_counts: Option<&DupesCounts>,
434) -> Result<(), ExitCode> {
435 let baseline = RegressionBaseline {
436 schema_version: REGRESSION_SCHEMA_VERSION,
437 fallow_version: env!("CARGO_PKG_VERSION").to_string(),
438 timestamp: chrono_now(),
439 git_sha: current_git_sha(root),
440 check: check_counts.cloned(),
441 dupes: dupes_counts.cloned(),
442 };
443 let json = serde_json::to_string_pretty(&baseline).map_err(|e| {
444 eprintln!("Error: failed to serialize regression baseline: {e}");
445 ExitCode::from(2)
446 })?;
447 if let Some(parent) = path.parent() {
449 let _ = std::fs::create_dir_all(parent);
450 }
451 std::fs::write(path, json).map_err(|e| {
452 eprintln!("Error: failed to save regression baseline: {e}");
453 ExitCode::from(2)
454 })?;
455 eprintln!("Regression baseline saved to {}", path.display());
458 if is_likely_gitignored(path, root) {
460 eprintln!(
461 "Warning: '{}' may be gitignored. Commit this file so CI can compare against it.",
462 path.display()
463 );
464 }
465 Ok(())
466}
467
468pub fn save_baseline_to_config(config_path: &Path, counts: &CheckCounts) -> Result<(), ExitCode> {
478 let content = match std::fs::read_to_string(config_path) {
480 Ok(c) => c,
481 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
482 let is_toml = config_path.extension().is_some_and(|ext| ext == "toml");
483 if is_toml {
484 String::new()
485 } else {
486 "{}".to_string()
487 }
488 }
489 Err(e) => {
490 eprintln!(
491 "Error: failed to read config file '{}': {e}",
492 config_path.display()
493 );
494 return Err(ExitCode::from(2));
495 }
496 };
497
498 let baseline = counts.to_config_baseline();
499 let is_toml = config_path.extension().is_some_and(|ext| ext == "toml");
500
501 let updated = if is_toml {
502 Ok(update_toml_regression(&content, &baseline))
503 } else {
504 update_json_regression(&content, &baseline)
505 }
506 .map_err(|e| {
507 eprintln!(
508 "Error: failed to update config file '{}': {e}",
509 config_path.display()
510 );
511 ExitCode::from(2)
512 })?;
513
514 std::fs::write(config_path, updated).map_err(|e| {
515 eprintln!(
516 "Error: failed to write config file '{}': {e}",
517 config_path.display()
518 );
519 ExitCode::from(2)
520 })?;
521
522 eprintln!(
523 "Regression baseline saved to {} (regression.baseline section)",
524 config_path.display()
525 );
526 Ok(())
527}
528
529fn find_json_key(content: &str, key: &str) -> Option<usize> {
533 let needle = format!("\"{key}\"");
534 let mut search_from = 0;
535 while let Some(pos) = content[search_from..].find(&needle) {
536 let abs_pos = search_from + pos;
537 let line_start = content[..abs_pos].rfind('\n').map_or(0, |i| i + 1);
539 let line_prefix = content[line_start..abs_pos].trim_start();
540 if line_prefix.starts_with("//") {
541 search_from = abs_pos + needle.len();
542 continue;
543 }
544 let before = &content[..abs_pos];
546 let last_open = before.rfind("/*");
547 let last_close = before.rfind("*/");
548 if let Some(open_pos) = last_open
549 && last_close.is_none_or(|close_pos| close_pos < open_pos)
550 {
551 search_from = abs_pos + needle.len();
552 continue;
553 }
554 return Some(abs_pos);
555 }
556 None
557}
558
559fn update_json_regression(
560 content: &str,
561 baseline: &fallow_config::RegressionBaseline,
562) -> Result<String, String> {
563 let baseline_json =
564 serde_json::to_string_pretty(baseline).map_err(|e| format!("serialization error: {e}"))?;
565
566 let indented: String = baseline_json
568 .lines()
569 .enumerate()
570 .map(|(i, line)| {
571 if i == 0 {
572 format!(" {line}")
573 } else {
574 format!("\n {line}")
575 }
576 })
577 .collect();
578
579 let regression_block = format!(" \"regression\": {{\n \"baseline\": {indented}\n }}");
580
581 if let Some(start) = find_json_key(content, "regression") {
585 let after_key = &content[start..];
586 if let Some(brace_start) = after_key.find('{') {
587 let abs_brace = start + brace_start;
588 let mut depth = 0;
589 let mut end = abs_brace;
590 let mut found_close = false;
591 for (i, ch) in content[abs_brace..].char_indices() {
592 match ch {
593 '{' => depth += 1,
594 '}' => {
595 depth -= 1;
596 if depth == 0 {
597 end = abs_brace + i + 1;
598 found_close = true;
599 break;
600 }
601 }
602 _ => {}
603 }
604 }
605 if !found_close {
606 return Err("malformed JSON: unmatched brace in regression object".to_string());
607 }
608 let mut result = String::new();
609 result.push_str(&content[..start]);
610 result.push_str(®ression_block[2..]); result.push_str(&content[end..]);
612 return Ok(result);
613 }
614 }
615
616 if let Some(last_brace) = content.rfind('}') {
618 let before_brace = content[..last_brace].trim_end();
620 let needs_comma = !before_brace.ends_with('{') && !before_brace.ends_with(',');
621
622 let mut result = String::new();
623 result.push_str(before_brace);
624 if needs_comma {
625 result.push(',');
626 }
627 result.push('\n');
628 result.push_str(®ression_block);
629 result.push('\n');
630 result.push_str(&content[last_brace..]);
631 Ok(result)
632 } else {
633 Err("config file has no closing brace".to_string())
634 }
635}
636
637fn update_toml_regression(content: &str, baseline: &fallow_config::RegressionBaseline) -> String {
639 use std::fmt::Write;
640 let mut section = String::from("[regression.baseline]\n");
642 let _ = writeln!(section, "totalIssues = {}", baseline.total_issues);
643 let _ = writeln!(section, "unusedFiles = {}", baseline.unused_files);
644 let _ = writeln!(section, "unusedExports = {}", baseline.unused_exports);
645 let _ = writeln!(section, "unusedTypes = {}", baseline.unused_types);
646 let _ = writeln!(
647 section,
648 "unusedDependencies = {}",
649 baseline.unused_dependencies
650 );
651 let _ = writeln!(
652 section,
653 "unusedDevDependencies = {}",
654 baseline.unused_dev_dependencies
655 );
656 let _ = writeln!(
657 section,
658 "unusedOptionalDependencies = {}",
659 baseline.unused_optional_dependencies
660 );
661 let _ = writeln!(
662 section,
663 "unusedEnumMembers = {}",
664 baseline.unused_enum_members
665 );
666 let _ = writeln!(
667 section,
668 "unusedClassMembers = {}",
669 baseline.unused_class_members
670 );
671 let _ = writeln!(
672 section,
673 "unresolvedImports = {}",
674 baseline.unresolved_imports
675 );
676 let _ = writeln!(
677 section,
678 "unlistedDependencies = {}",
679 baseline.unlisted_dependencies
680 );
681 let _ = writeln!(section, "duplicateExports = {}", baseline.duplicate_exports);
682 let _ = writeln!(
683 section,
684 "circularDependencies = {}",
685 baseline.circular_dependencies
686 );
687 let _ = writeln!(
688 section,
689 "typeOnlyDependencies = {}",
690 baseline.type_only_dependencies
691 );
692 let _ = writeln!(
693 section,
694 "testOnlyDependencies = {}",
695 baseline.test_only_dependencies
696 );
697
698 if let Some(start) = content.find("[regression.baseline]") {
700 let after = &content[start + "[regression.baseline]".len()..];
702 let end_offset = after.find("\n[").map_or(content.len(), |i| {
703 start + "[regression.baseline]".len() + i + 1
704 });
705
706 let mut result = String::new();
707 result.push_str(&content[..start]);
708 result.push_str(§ion);
709 if end_offset < content.len() {
710 result.push_str(&content[end_offset..]);
711 }
712 result
713 } else {
714 let mut result = content.to_string();
716 if !result.ends_with('\n') {
717 result.push('\n');
718 }
719 result.push('\n');
720 result.push_str(§ion);
721 result
722 }
723}
724
725pub fn load_regression_baseline(path: &Path) -> Result<RegressionBaseline, ExitCode> {
731 let content = std::fs::read_to_string(path).map_err(|e| {
732 if e.kind() == std::io::ErrorKind::NotFound {
733 eprintln!(
734 "Error: no regression baseline found at '{}'.\n\
735 Run with --save-regression-baseline on your main branch to create one.",
736 path.display()
737 );
738 } else {
739 eprintln!(
740 "Error: failed to read regression baseline '{}': {e}",
741 path.display()
742 );
743 }
744 ExitCode::from(2)
745 })?;
746 serde_json::from_str(&content).map_err(|e| {
747 eprintln!(
748 "Error: failed to parse regression baseline '{}': {e}",
749 path.display()
750 );
751 ExitCode::from(2)
752 })
753}
754
755pub fn compare_check_regression(
767 results: &AnalysisResults,
768 opts: &RegressionOpts<'_>,
769 config_baseline: Option<&fallow_config::RegressionBaseline>,
770) -> Result<Option<RegressionOutcome>, ExitCode> {
771 if !opts.fail_on_regression {
772 return Ok(None);
773 }
774
775 if opts.scoped {
777 let reason = "--changed-since or --workspace is active; regression check skipped \
778 (counts not comparable to full-project baseline)";
779 if !opts.quiet {
780 eprintln!("Warning: {reason}");
781 }
782 return Ok(Some(RegressionOutcome::Skipped { reason }));
783 }
784
785 let baseline_counts: CheckCounts = if let Some(baseline_path) = opts.regression_baseline_file {
787 let baseline = load_regression_baseline(baseline_path)?;
789 let Some(counts) = baseline.check else {
790 eprintln!(
791 "Error: regression baseline '{}' has no check data",
792 baseline_path.display()
793 );
794 return Err(ExitCode::from(2));
795 };
796 counts
797 } else if let Some(config_baseline) = config_baseline {
798 CheckCounts::from_config_baseline(config_baseline)
800 } else {
801 eprintln!(
802 "Error: no regression baseline found.\n\
803 Either add a `regression.baseline` section to your config file\n\
804 (run with --save-regression-baseline to generate it),\n\
805 or provide an explicit file via --regression-baseline <PATH>."
806 );
807 return Err(ExitCode::from(2));
808 };
809
810 let current_total = results.total_issues();
811 let baseline_total = baseline_counts.total_issues;
812
813 if opts.tolerance.exceeded(baseline_total, current_total) {
814 let current_counts = CheckCounts::from_results(results);
815 let type_deltas = baseline_counts.deltas(¤t_counts);
816 Ok(Some(RegressionOutcome::Exceeded {
817 baseline_total,
818 current_total,
819 tolerance: opts.tolerance,
820 type_deltas,
821 }))
822 } else {
823 Ok(Some(RegressionOutcome::Pass {
824 baseline_total,
825 current_total,
826 }))
827 }
828}
829
830pub fn print_regression_outcome(outcome: &RegressionOutcome) {
832 match outcome {
833 RegressionOutcome::Pass {
834 baseline_total,
835 current_total,
836 } => {
837 let delta = *current_total as isize - *baseline_total as isize;
838 let sign = if delta >= 0 { "+" } else { "" };
839 eprintln!(
840 "Regression check passed: {current_total} issues (baseline: {baseline_total}, \
841 delta: {sign}{delta})"
842 );
843 }
844 RegressionOutcome::Exceeded {
845 baseline_total,
846 current_total,
847 tolerance,
848 type_deltas,
849 } => {
850 let delta = *current_total as isize - *baseline_total as isize;
851 let tol_str = match tolerance {
852 Tolerance::Percentage(pct) => format!("{pct}%"),
853 Tolerance::Absolute(abs) => format!("{abs}"),
854 };
855 eprintln!(
856 "Regression detected: {current_total} issues (baseline: {baseline_total}, \
857 delta: +{delta}, tolerance: {tol_str})"
858 );
859 for (name, d) in type_deltas {
860 let sign = if *d > 0 { "+" } else { "" };
861 eprintln!(" {name}: {sign}{d}");
862 }
863 }
864 RegressionOutcome::Skipped { .. } => {
865 }
867 }
868}
869
870fn chrono_now() -> String {
872 let duration = std::time::SystemTime::now()
873 .duration_since(std::time::UNIX_EPOCH)
874 .unwrap_or_default();
875 let secs = duration.as_secs();
876 let days = secs / 86400;
878 let time_secs = secs % 86400;
879 let hours = time_secs / 3600;
880 let minutes = (time_secs % 3600) / 60;
881 let seconds = time_secs % 60;
882 let z = days + 719_468;
884 let era = z / 146_097;
885 let doe = z - era * 146_097;
886 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
887 let y = yoe + era * 400;
888 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
889 let mp = (5 * doy + 2) / 153;
890 let d = doy - (153 * mp + 2) / 5 + 1;
891 let m = if mp < 10 { mp + 3 } else { mp - 9 };
892 let y = if m <= 2 { y + 1 } else { y };
893 format!("{y:04}-{m:02}-{d:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
894}
895
896#[cfg(test)]
897mod tests {
898 use super::*;
899 use fallow_core::results::*;
900 use std::path::PathBuf;
901
902 #[test]
905 fn parse_percentage_tolerance() {
906 let t = Tolerance::parse("2%").unwrap();
907 assert!(matches!(t, Tolerance::Percentage(p) if (p - 2.0).abs() < f64::EPSILON));
908 }
909
910 #[test]
911 fn parse_absolute_tolerance() {
912 let t = Tolerance::parse("5").unwrap();
913 assert!(matches!(t, Tolerance::Absolute(5)));
914 }
915
916 #[test]
917 fn parse_zero_tolerance() {
918 let t = Tolerance::parse("0").unwrap();
919 assert!(matches!(t, Tolerance::Absolute(0)));
920 }
921
922 #[test]
923 fn parse_empty_defaults_to_zero() {
924 let t = Tolerance::parse("").unwrap();
925 assert!(matches!(t, Tolerance::Absolute(0)));
926 }
927
928 #[test]
929 fn parse_invalid_percentage() {
930 assert!(Tolerance::parse("abc%").is_err());
931 }
932
933 #[test]
934 fn parse_negative_percentage() {
935 assert!(Tolerance::parse("-1%").is_err());
936 }
937
938 #[test]
939 fn parse_invalid_absolute() {
940 assert!(Tolerance::parse("abc").is_err());
941 }
942
943 #[test]
946 fn zero_tolerance_detects_any_increase() {
947 let t = Tolerance::Absolute(0);
948 assert!(t.exceeded(10, 11));
949 assert!(!t.exceeded(10, 10));
950 assert!(!t.exceeded(10, 9));
951 }
952
953 #[test]
954 fn absolute_tolerance_allows_within_range() {
955 let t = Tolerance::Absolute(3);
956 assert!(!t.exceeded(10, 12)); assert!(!t.exceeded(10, 13)); assert!(t.exceeded(10, 14)); }
960
961 #[test]
962 fn percentage_tolerance_allows_within_range() {
963 let t = Tolerance::Percentage(10.0);
964 assert!(!t.exceeded(100, 109)); assert!(!t.exceeded(100, 110)); assert!(t.exceeded(100, 111)); }
968
969 #[test]
970 fn percentage_tolerance_from_zero_baseline() {
971 let t = Tolerance::Percentage(10.0);
972 assert!(t.exceeded(0, 1)); assert!(!t.exceeded(0, 0)); }
975
976 #[test]
977 fn decrease_never_exceeds() {
978 let t = Tolerance::Absolute(0);
979 assert!(!t.exceeded(10, 5));
980 let t = Tolerance::Percentage(0.0);
981 assert!(!t.exceeded(10, 5));
982 }
983
984 #[test]
987 fn check_counts_from_results() {
988 let mut results = AnalysisResults::default();
989 results.unused_files.push(UnusedFile {
990 path: PathBuf::from("a.ts"),
991 });
992 results.unused_exports.push(UnusedExport {
993 path: PathBuf::from("b.ts"),
994 export_name: "foo".into(),
995 is_type_only: false,
996 line: 1,
997 col: 0,
998 span_start: 0,
999 is_re_export: false,
1000 });
1001 let counts = CheckCounts::from_results(&results);
1002 assert_eq!(counts.total_issues, 2);
1003 assert_eq!(counts.unused_files, 1);
1004 assert_eq!(counts.unused_exports, 1);
1005 assert_eq!(counts.unused_types, 0);
1006 }
1007
1008 #[test]
1011 fn deltas_reports_changes_only() {
1012 let baseline = CheckCounts {
1013 total_issues: 10,
1014 unused_files: 5,
1015 unused_exports: 3,
1016 unused_types: 2,
1017 unused_dependencies: 0,
1018 unused_dev_dependencies: 0,
1019 unused_optional_dependencies: 0,
1020 unused_enum_members: 0,
1021 unused_class_members: 0,
1022 unresolved_imports: 0,
1023 unlisted_dependencies: 0,
1024 duplicate_exports: 0,
1025 circular_dependencies: 0,
1026 type_only_dependencies: 0,
1027 test_only_dependencies: 0,
1028 boundary_violations: 0,
1029 };
1030 let current = CheckCounts {
1031 unused_files: 7, unused_exports: 1, unused_types: 2, ..baseline
1035 };
1036 let deltas = baseline.deltas(¤t);
1037 assert_eq!(deltas.len(), 2);
1038 assert!(deltas.contains(&("unused_files", 2)));
1039 assert!(deltas.contains(&("unused_exports", -2)));
1040 }
1041
1042 #[test]
1045 fn pass_outcome_json() {
1046 let outcome = RegressionOutcome::Pass {
1047 baseline_total: 10,
1048 current_total: 10,
1049 };
1050 let json = outcome.to_json();
1051 assert_eq!(json["status"], "pass");
1052 assert_eq!(json["exceeded"], false);
1053 assert_eq!(json["delta"], 0);
1054 }
1055
1056 #[test]
1057 fn exceeded_outcome_json() {
1058 let outcome = RegressionOutcome::Exceeded {
1059 baseline_total: 10,
1060 current_total: 15,
1061 tolerance: Tolerance::Percentage(2.0),
1062 type_deltas: vec![("unused_files", 5)],
1063 };
1064 let json = outcome.to_json();
1065 assert_eq!(json["status"], "exceeded");
1066 assert_eq!(json["exceeded"], true);
1067 assert_eq!(json["delta"], 5);
1068 assert_eq!(json["tolerance_kind"], "percentage");
1069 }
1070
1071 #[test]
1072 fn skipped_outcome_json() {
1073 let outcome = RegressionOutcome::Skipped {
1074 reason: "test reason",
1075 };
1076 let json = outcome.to_json();
1077 assert_eq!(json["status"], "skipped");
1078 assert_eq!(json["exceeded"], false);
1079 }
1080
1081 #[test]
1084 fn regression_baseline_roundtrip() {
1085 let baseline = RegressionBaseline {
1086 schema_version: 1,
1087 fallow_version: "2.4.0".into(),
1088 timestamp: "2026-03-27T10:00:00Z".into(),
1089 git_sha: Some("abc123".into()),
1090 check: Some(CheckCounts {
1091 total_issues: 42,
1092 unused_files: 5,
1093 unused_exports: 20,
1094 unused_types: 8,
1095 unused_dependencies: 3,
1096 unused_dev_dependencies: 2,
1097 unused_optional_dependencies: 0,
1098 unused_enum_members: 1,
1099 unused_class_members: 1,
1100 unresolved_imports: 0,
1101 unlisted_dependencies: 1,
1102 duplicate_exports: 0,
1103 circular_dependencies: 1,
1104 type_only_dependencies: 0,
1105 test_only_dependencies: 0,
1106 boundary_violations: 0,
1107 }),
1108 dupes: Some(DupesCounts {
1109 clone_groups: 12,
1110 duplication_percentage: 4.2,
1111 }),
1112 };
1113 let json = serde_json::to_string_pretty(&baseline).unwrap();
1114 let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
1115 assert_eq!(loaded.schema_version, 1);
1116 assert_eq!(loaded.check.as_ref().unwrap().total_issues, 42);
1117 assert_eq!(loaded.dupes.as_ref().unwrap().clone_groups, 12);
1118 }
1119
1120 #[test]
1123 fn regression_outcome_is_failure() {
1124 let pass = RegressionOutcome::Pass {
1125 baseline_total: 10,
1126 current_total: 10,
1127 };
1128 assert!(!pass.is_failure());
1129
1130 let exceeded = RegressionOutcome::Exceeded {
1131 baseline_total: 10,
1132 current_total: 15,
1133 tolerance: Tolerance::Absolute(2),
1134 type_deltas: vec![],
1135 };
1136 assert!(exceeded.is_failure());
1137
1138 let skipped = RegressionOutcome::Skipped { reason: "test" };
1139 assert!(!skipped.is_failure());
1140 }
1141
1142 fn sample_baseline() -> fallow_config::RegressionBaseline {
1145 fallow_config::RegressionBaseline {
1146 total_issues: 5,
1147 unused_files: 2,
1148 ..Default::default()
1149 }
1150 }
1151
1152 #[test]
1153 fn json_insert_into_empty_object() {
1154 let result = update_json_regression("{}", &sample_baseline()).unwrap();
1155 assert!(result.contains("\"regression\""));
1156 assert!(result.contains("\"totalIssues\": 5"));
1157 serde_json::from_str::<serde_json::Value>(&result).unwrap();
1159 }
1160
1161 #[test]
1162 fn json_insert_into_existing_config() {
1163 let config = r#"{
1164 "entry": ["src/main.ts"],
1165 "production": true
1166}"#;
1167 let result = update_json_regression(config, &sample_baseline()).unwrap();
1168 assert!(result.contains("\"regression\""));
1169 assert!(result.contains("\"entry\""));
1170 serde_json::from_str::<serde_json::Value>(&result).unwrap();
1171 }
1172
1173 #[test]
1174 fn json_replace_existing_regression() {
1175 let config = r#"{
1176 "entry": ["src/main.ts"],
1177 "regression": {
1178 "baseline": {
1179 "totalIssues": 99
1180 }
1181 }
1182}"#;
1183 let result = update_json_regression(config, &sample_baseline()).unwrap();
1184 assert!(!result.contains("99"));
1186 assert!(result.contains("\"totalIssues\": 5"));
1187 serde_json::from_str::<serde_json::Value>(&result).unwrap();
1188 }
1189
1190 #[test]
1191 fn json_skips_regression_in_comment() {
1192 let config = "{\n // See \"regression\" docs\n \"entry\": []\n}";
1193 let result = update_json_regression(config, &sample_baseline()).unwrap();
1194 assert!(result.contains("\"regression\":"));
1196 assert!(result.contains("\"entry\""));
1197 }
1198
1199 #[test]
1200 fn json_malformed_brace_returns_error() {
1201 let config = r#"{ "regression": { "baseline": { "totalIssues": 1 }"#;
1203 let result = update_json_regression(config, &sample_baseline());
1204 assert!(result.is_err());
1205 }
1206
1207 #[test]
1210 fn toml_insert_into_empty() {
1211 let result = update_toml_regression("", &sample_baseline());
1212 assert!(result.contains("[regression.baseline]"));
1213 assert!(result.contains("totalIssues = 5"));
1214 }
1215
1216 #[test]
1217 fn toml_insert_after_existing_content() {
1218 let config = "[rules]\nunused-files = \"warn\"\n";
1219 let result = update_toml_regression(config, &sample_baseline());
1220 assert!(result.contains("[rules]"));
1221 assert!(result.contains("[regression.baseline]"));
1222 assert!(result.contains("totalIssues = 5"));
1223 }
1224
1225 #[test]
1226 fn toml_replace_existing_section() {
1227 let config =
1228 "[regression.baseline]\ntotalIssues = 99\n\n[rules]\nunused-files = \"warn\"\n";
1229 let result = update_toml_regression(config, &sample_baseline());
1230 assert!(!result.contains("99"));
1231 assert!(result.contains("totalIssues = 5"));
1232 assert!(result.contains("[rules]"));
1233 }
1234
1235 #[test]
1238 fn find_json_key_basic() {
1239 assert_eq!(find_json_key(r#"{"foo": 1}"#, "foo"), Some(1));
1240 }
1241
1242 #[test]
1243 fn find_json_key_skips_comment() {
1244 let content = "{\n // \"foo\" is important\n \"bar\": 1\n}";
1245 assert_eq!(find_json_key(content, "foo"), None);
1246 assert!(find_json_key(content, "bar").is_some());
1247 }
1248
1249 #[test]
1250 fn find_json_key_not_found() {
1251 assert_eq!(find_json_key("{}", "missing"), None);
1252 }
1253
1254 #[test]
1255 fn find_json_key_skips_block_comment() {
1256 let content = "{\n /* \"foo\": old value */\n \"foo\": 1\n}";
1257 let pos = find_json_key(content, "foo").unwrap();
1259 assert!(content[pos..].starts_with("\"foo\": 1"));
1260 }
1261
1262 #[test]
1265 fn parse_whitespace_padded_tolerance() {
1266 let t = Tolerance::parse(" 5 ").unwrap();
1267 assert!(matches!(t, Tolerance::Absolute(5)));
1268 }
1269
1270 #[test]
1271 fn parse_whitespace_only_defaults_to_zero() {
1272 let t = Tolerance::parse(" ").unwrap();
1273 assert!(matches!(t, Tolerance::Absolute(0)));
1274 }
1275
1276 #[test]
1277 fn parse_zero_percent_tolerance() {
1278 let t = Tolerance::parse("0%").unwrap();
1279 assert!(matches!(t, Tolerance::Percentage(p) if p == 0.0));
1280 }
1281
1282 #[test]
1283 fn parse_decimal_percentage_tolerance() {
1284 let t = Tolerance::parse("1.5%").unwrap();
1285 assert!(matches!(t, Tolerance::Percentage(p) if (p - 1.5).abs() < f64::EPSILON));
1286 }
1287
1288 #[test]
1289 fn parse_large_absolute_tolerance() {
1290 let t = Tolerance::parse("1000").unwrap();
1291 assert!(matches!(t, Tolerance::Absolute(1000)));
1292 }
1293
1294 #[test]
1295 fn parse_negative_absolute_is_err() {
1296 assert!(Tolerance::parse("-1").is_err());
1298 }
1299
1300 #[test]
1301 fn parse_whitespace_padded_percentage() {
1302 let t = Tolerance::parse(" 3.5% ").unwrap();
1303 assert!(matches!(t, Tolerance::Percentage(p) if (p - 3.5).abs() < f64::EPSILON));
1304 }
1305
1306 #[test]
1309 fn zero_pct_tolerance_detects_any_increase() {
1310 let t = Tolerance::Percentage(0.0);
1311 assert!(t.exceeded(100, 101));
1312 assert!(!t.exceeded(100, 100));
1313 assert!(!t.exceeded(100, 99));
1314 }
1315
1316 #[test]
1317 fn percentage_tolerance_with_small_baseline() {
1318 let t = Tolerance::Percentage(10.0);
1320 assert!(t.exceeded(3, 4)); assert!(!t.exceeded(3, 3)); }
1323
1324 #[test]
1325 fn percentage_tolerance_large_percentage() {
1326 let t = Tolerance::Percentage(100.0);
1327 assert!(!t.exceeded(10, 20)); assert!(t.exceeded(10, 21)); }
1331
1332 #[test]
1333 fn absolute_tolerance_at_exact_boundary() {
1334 let t = Tolerance::Absolute(5);
1335 assert!(!t.exceeded(10, 15)); assert!(t.exceeded(10, 16)); }
1338
1339 #[test]
1340 fn decrease_never_exceeds_for_all_variants() {
1341 let t = Tolerance::Absolute(0);
1342 assert!(!t.exceeded(10, 0));
1343 let t = Tolerance::Percentage(0.0);
1344 assert!(!t.exceeded(10, 0));
1345 }
1346
1347 #[test]
1348 fn equal_values_never_exceed() {
1349 assert!(!Tolerance::Absolute(0).exceeded(0, 0));
1350 assert!(!Tolerance::Percentage(0.0).exceeded(0, 0));
1351 assert!(!Tolerance::Absolute(0).exceeded(100, 100));
1352 assert!(!Tolerance::Percentage(0.0).exceeded(100, 100));
1353 }
1354
1355 #[test]
1358 fn check_counts_config_roundtrip() {
1359 let counts = CheckCounts {
1360 total_issues: 42,
1361 unused_files: 5,
1362 unused_exports: 20,
1363 unused_types: 8,
1364 unused_dependencies: 3,
1365 unused_dev_dependencies: 2,
1366 unused_optional_dependencies: 1,
1367 unused_enum_members: 1,
1368 unused_class_members: 1,
1369 unresolved_imports: 0,
1370 unlisted_dependencies: 1,
1371 duplicate_exports: 0,
1372 circular_dependencies: 0,
1373 type_only_dependencies: 0,
1374 test_only_dependencies: 0,
1375 boundary_violations: 0,
1376 };
1377 let config_baseline = counts.to_config_baseline();
1378 let roundtripped = CheckCounts::from_config_baseline(&config_baseline);
1379 assert_eq!(roundtripped.total_issues, 42);
1380 assert_eq!(roundtripped.unused_files, 5);
1381 assert_eq!(roundtripped.unused_exports, 20);
1382 assert_eq!(roundtripped.unused_types, 8);
1383 assert_eq!(roundtripped.unused_dependencies, 3);
1384 assert_eq!(roundtripped.unused_dev_dependencies, 2);
1385 assert_eq!(roundtripped.unused_optional_dependencies, 1);
1386 assert_eq!(roundtripped.unused_enum_members, 1);
1387 assert_eq!(roundtripped.unused_class_members, 1);
1388 assert_eq!(roundtripped.unresolved_imports, 0);
1389 assert_eq!(roundtripped.unlisted_dependencies, 1);
1390 assert_eq!(roundtripped.duplicate_exports, 0);
1391 assert_eq!(roundtripped.circular_dependencies, 0);
1392 assert_eq!(roundtripped.type_only_dependencies, 0);
1393 assert_eq!(roundtripped.test_only_dependencies, 0);
1394 }
1395
1396 #[test]
1397 fn check_counts_zero_config_roundtrip() {
1398 let counts = CheckCounts {
1399 total_issues: 0,
1400 unused_files: 0,
1401 unused_exports: 0,
1402 unused_types: 0,
1403 unused_dependencies: 0,
1404 unused_dev_dependencies: 0,
1405 unused_optional_dependencies: 0,
1406 unused_enum_members: 0,
1407 unused_class_members: 0,
1408 unresolved_imports: 0,
1409 unlisted_dependencies: 0,
1410 duplicate_exports: 0,
1411 circular_dependencies: 0,
1412 type_only_dependencies: 0,
1413 test_only_dependencies: 0,
1414 boundary_violations: 0,
1415 };
1416 let config_baseline = counts.to_config_baseline();
1417 let roundtripped = CheckCounts::from_config_baseline(&config_baseline);
1418 assert_eq!(roundtripped.total_issues, 0);
1419 assert_eq!(roundtripped.unused_files, 0);
1420 }
1421
1422 #[test]
1425 fn deltas_empty_when_identical() {
1426 let counts = CheckCounts {
1427 total_issues: 10,
1428 unused_files: 5,
1429 unused_exports: 3,
1430 unused_types: 2,
1431 unused_dependencies: 0,
1432 unused_dev_dependencies: 0,
1433 unused_optional_dependencies: 0,
1434 unused_enum_members: 0,
1435 unused_class_members: 0,
1436 unresolved_imports: 0,
1437 unlisted_dependencies: 0,
1438 duplicate_exports: 0,
1439 circular_dependencies: 0,
1440 type_only_dependencies: 0,
1441 test_only_dependencies: 0,
1442 boundary_violations: 0,
1443 };
1444 let deltas = counts.deltas(&counts);
1445 assert!(deltas.is_empty());
1446 }
1447
1448 #[test]
1449 fn deltas_all_categories_changed() {
1450 let baseline = CheckCounts {
1451 total_issues: 0,
1452 unused_files: 0,
1453 unused_exports: 0,
1454 unused_types: 0,
1455 unused_dependencies: 0,
1456 unused_dev_dependencies: 0,
1457 unused_optional_dependencies: 0,
1458 unused_enum_members: 0,
1459 unused_class_members: 0,
1460 unresolved_imports: 0,
1461 unlisted_dependencies: 0,
1462 duplicate_exports: 0,
1463 circular_dependencies: 0,
1464 type_only_dependencies: 0,
1465 test_only_dependencies: 0,
1466 boundary_violations: 0,
1467 };
1468 let current = CheckCounts {
1469 total_issues: 14,
1470 unused_files: 1,
1471 unused_exports: 1,
1472 unused_types: 1,
1473 unused_dependencies: 1,
1474 unused_dev_dependencies: 1,
1475 unused_optional_dependencies: 1,
1476 unused_enum_members: 1,
1477 unused_class_members: 1,
1478 unresolved_imports: 1,
1479 unlisted_dependencies: 1,
1480 duplicate_exports: 1,
1481 circular_dependencies: 1,
1482 type_only_dependencies: 1,
1483 test_only_dependencies: 1,
1484 boundary_violations: 1,
1485 };
1486 let deltas = baseline.deltas(¤t);
1487 assert_eq!(deltas.len(), 15);
1489 for (_, d) in &deltas {
1490 assert_eq!(*d, 1);
1491 }
1492 }
1493
1494 #[test]
1495 fn deltas_mixed_increase_decrease() {
1496 let baseline = CheckCounts {
1497 total_issues: 10,
1498 unused_files: 5,
1499 unused_exports: 3,
1500 unused_types: 2,
1501 unused_dependencies: 0,
1502 unused_dev_dependencies: 0,
1503 unused_optional_dependencies: 0,
1504 unused_enum_members: 0,
1505 unused_class_members: 0,
1506 unresolved_imports: 0,
1507 unlisted_dependencies: 0,
1508 duplicate_exports: 0,
1509 circular_dependencies: 0,
1510 type_only_dependencies: 0,
1511 test_only_dependencies: 0,
1512 boundary_violations: 0,
1513 };
1514 let current = CheckCounts {
1515 unused_files: 3, unused_exports: 5, unused_types: 0, unresolved_imports: 1, ..baseline
1520 };
1521 let deltas = baseline.deltas(¤t);
1522 assert_eq!(deltas.len(), 4);
1523 assert!(deltas.contains(&("unused_files", -2)));
1524 assert!(deltas.contains(&("unused_exports", 2)));
1525 assert!(deltas.contains(&("unused_types", -2)));
1526 assert!(deltas.contains(&("unresolved_imports", 1)));
1527 }
1528
1529 #[test]
1532 fn exceeded_outcome_json_absolute() {
1533 let outcome = RegressionOutcome::Exceeded {
1534 baseline_total: 10,
1535 current_total: 15,
1536 tolerance: Tolerance::Absolute(2),
1537 type_deltas: vec![("unused_files", 5)],
1538 };
1539 let json = outcome.to_json();
1540 assert_eq!(json["status"], "exceeded");
1541 assert_eq!(json["tolerance_kind"], "absolute");
1542 assert_eq!(json["tolerance"], 2.0);
1543 assert_eq!(json["delta"], 5);
1544 }
1545
1546 #[test]
1547 fn pass_outcome_json_with_improvement() {
1548 let outcome = RegressionOutcome::Pass {
1549 baseline_total: 10,
1550 current_total: 5,
1551 };
1552 let json = outcome.to_json();
1553 assert_eq!(json["status"], "pass");
1554 assert_eq!(json["delta"], -5);
1555 assert_eq!(json["exceeded"], false);
1556 }
1557
1558 #[test]
1561 fn dupes_counts_roundtrip() {
1562 let dupes = DupesCounts {
1563 clone_groups: 8,
1564 duplication_percentage: 3.17,
1565 };
1566 let json = serde_json::to_string(&dupes).unwrap();
1567 let loaded: DupesCounts = serde_json::from_str(&json).unwrap();
1568 assert_eq!(loaded.clone_groups, 8);
1569 assert!((loaded.duplication_percentage - 3.17).abs() < f64::EPSILON);
1570 }
1571
1572 #[test]
1573 fn dupes_counts_default_fields() {
1574 let json = "{}";
1576 let loaded: DupesCounts = serde_json::from_str(json).unwrap();
1577 assert_eq!(loaded.clone_groups, 0);
1578 assert!((loaded.duplication_percentage).abs() < f64::EPSILON);
1579 }
1580
1581 #[test]
1584 fn baseline_without_check_section() {
1585 let baseline = RegressionBaseline {
1586 schema_version: 1,
1587 fallow_version: "2.4.0".into(),
1588 timestamp: "2026-03-27T10:00:00Z".into(),
1589 git_sha: None,
1590 check: None,
1591 dupes: Some(DupesCounts {
1592 clone_groups: 3,
1593 duplication_percentage: 1.0,
1594 }),
1595 };
1596 let json = serde_json::to_string_pretty(&baseline).unwrap();
1597 let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
1598 assert!(loaded.check.is_none());
1599 assert!(loaded.dupes.is_some());
1600 }
1601
1602 #[test]
1603 fn baseline_without_dupes_section() {
1604 let baseline = RegressionBaseline {
1605 schema_version: 1,
1606 fallow_version: "2.4.0".into(),
1607 timestamp: "2026-03-27T10:00:00Z".into(),
1608 git_sha: Some("deadbeef".into()),
1609 check: Some(CheckCounts {
1610 total_issues: 1,
1611 unused_files: 1,
1612 ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
1613 }),
1614 dupes: None,
1615 };
1616 let json = serde_json::to_string_pretty(&baseline).unwrap();
1617 let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
1618 assert!(loaded.check.is_some());
1619 assert!(loaded.dupes.is_none());
1620 assert_eq!(loaded.git_sha.as_deref(), Some("deadbeef"));
1621 }
1622
1623 #[test]
1624 fn baseline_without_git_sha() {
1625 let baseline = RegressionBaseline {
1626 schema_version: 1,
1627 fallow_version: "2.4.0".into(),
1628 timestamp: "2026-03-27T10:00:00Z".into(),
1629 git_sha: None,
1630 check: None,
1631 dupes: None,
1632 };
1633 let json = serde_json::to_string_pretty(&baseline).unwrap();
1634 assert!(!json.contains("git_sha"));
1636 let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
1637 assert!(loaded.git_sha.is_none());
1638 }
1639
1640 #[test]
1643 fn baseline_json_with_unknown_check_fields_deserializes() {
1644 let json = r#"{
1645 "schema_version": 1,
1646 "fallow_version": "3.0.0",
1647 "timestamp": "2026-03-27T10:00:00Z",
1648 "check": {
1649 "total_issues": 10,
1650 "unused_files": 2,
1651 "some_future_field": 99
1652 }
1653 }"#;
1654 let loaded: Result<RegressionBaseline, _> = serde_json::from_str(json);
1656 assert!(loaded.is_ok());
1658 let loaded = loaded.unwrap();
1659 assert_eq!(loaded.check.as_ref().unwrap().total_issues, 10);
1660 }
1661
1662 #[test]
1665 fn save_load_roundtrip() {
1666 let dir = tempfile::tempdir().unwrap();
1667 let path = dir.path().join("regression-baseline.json");
1668 let counts = CheckCounts {
1669 total_issues: 15,
1670 unused_files: 3,
1671 unused_exports: 5,
1672 unused_types: 2,
1673 unused_dependencies: 1,
1674 unused_dev_dependencies: 1,
1675 unused_optional_dependencies: 0,
1676 unused_enum_members: 1,
1677 unused_class_members: 0,
1678 unresolved_imports: 1,
1679 unlisted_dependencies: 0,
1680 duplicate_exports: 1,
1681 circular_dependencies: 0,
1682 type_only_dependencies: 0,
1683 test_only_dependencies: 0,
1684 boundary_violations: 0,
1685 };
1686 let dupes = DupesCounts {
1687 clone_groups: 4,
1688 duplication_percentage: 2.5,
1689 };
1690
1691 save_regression_baseline(&path, dir.path(), Some(&counts), Some(&dupes)).unwrap();
1692 let loaded = load_regression_baseline(&path).unwrap();
1693
1694 assert_eq!(loaded.schema_version, REGRESSION_SCHEMA_VERSION);
1695 let check = loaded.check.unwrap();
1696 assert_eq!(check.total_issues, 15);
1697 assert_eq!(check.unused_files, 3);
1698 assert_eq!(check.unused_exports, 5);
1699 assert_eq!(check.unused_types, 2);
1700 assert_eq!(check.unused_dependencies, 1);
1701 assert_eq!(check.unresolved_imports, 1);
1702 assert_eq!(check.duplicate_exports, 1);
1703 let dupes = loaded.dupes.unwrap();
1704 assert_eq!(dupes.clone_groups, 4);
1705 assert!((dupes.duplication_percentage - 2.5).abs() < f64::EPSILON);
1706 }
1707
1708 #[test]
1709 fn save_load_roundtrip_check_only() {
1710 let dir = tempfile::tempdir().unwrap();
1711 let path = dir.path().join("regression-baseline.json");
1712 let counts = CheckCounts {
1713 total_issues: 5,
1714 unused_files: 5,
1715 ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
1716 };
1717
1718 save_regression_baseline(&path, dir.path(), Some(&counts), None).unwrap();
1719 let loaded = load_regression_baseline(&path).unwrap();
1720
1721 assert!(loaded.check.is_some());
1722 assert!(loaded.dupes.is_none());
1723 assert_eq!(loaded.check.unwrap().unused_files, 5);
1724 }
1725
1726 #[test]
1727 fn save_creates_parent_directories() {
1728 let dir = tempfile::tempdir().unwrap();
1729 let path = dir.path().join("nested").join("dir").join("baseline.json");
1730 let counts = CheckCounts {
1731 total_issues: 1,
1732 unused_files: 1,
1733 ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
1734 };
1735
1736 save_regression_baseline(&path, dir.path(), Some(&counts), None).unwrap();
1737 assert!(path.exists());
1738 }
1739
1740 #[test]
1741 fn load_nonexistent_file_returns_error() {
1742 let result = load_regression_baseline(Path::new("/tmp/nonexistent-baseline-12345.json"));
1743 assert!(result.is_err());
1744 }
1745
1746 #[test]
1747 fn load_invalid_json_returns_error() {
1748 let dir = tempfile::tempdir().unwrap();
1749 let path = dir.path().join("bad.json");
1750 std::fs::write(&path, "not valid json {{{").unwrap();
1751 let result = load_regression_baseline(&path);
1752 assert!(result.is_err());
1753 }
1754
1755 #[test]
1758 fn save_baseline_to_json_config() {
1759 let dir = tempfile::tempdir().unwrap();
1760 let config_path = dir.path().join(".fallowrc.json");
1761 std::fs::write(&config_path, r#"{"entry": ["src/main.ts"]}"#).unwrap();
1762
1763 let counts = CheckCounts {
1764 total_issues: 7,
1765 unused_files: 3,
1766 unused_exports: 4,
1767 ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
1768 };
1769 save_baseline_to_config(&config_path, &counts).unwrap();
1770
1771 let content = std::fs::read_to_string(&config_path).unwrap();
1772 assert!(content.contains("\"regression\""));
1773 assert!(content.contains("\"totalIssues\": 7"));
1774 serde_json::from_str::<serde_json::Value>(&content).unwrap();
1776 }
1777
1778 #[test]
1779 fn save_baseline_to_toml_config() {
1780 let dir = tempfile::tempdir().unwrap();
1781 let config_path = dir.path().join("fallow.toml");
1782 std::fs::write(&config_path, "[rules]\nunused-files = \"warn\"\n").unwrap();
1783
1784 let counts = CheckCounts {
1785 total_issues: 7,
1786 unused_files: 3,
1787 unused_exports: 4,
1788 ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
1789 };
1790 save_baseline_to_config(&config_path, &counts).unwrap();
1791
1792 let content = std::fs::read_to_string(&config_path).unwrap();
1793 assert!(content.contains("[regression.baseline]"));
1794 assert!(content.contains("totalIssues = 7"));
1795 assert!(content.contains("[rules]"));
1796 }
1797
1798 #[test]
1799 fn save_baseline_to_nonexistent_json_config() {
1800 let dir = tempfile::tempdir().unwrap();
1801 let config_path = dir.path().join(".fallowrc.json");
1802 let counts = CheckCounts {
1805 total_issues: 1,
1806 unused_files: 1,
1807 ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
1808 };
1809 save_baseline_to_config(&config_path, &counts).unwrap();
1810
1811 let content = std::fs::read_to_string(&config_path).unwrap();
1812 assert!(content.contains("\"regression\""));
1813 serde_json::from_str::<serde_json::Value>(&content).unwrap();
1814 }
1815
1816 #[test]
1817 fn save_baseline_to_nonexistent_toml_config() {
1818 let dir = tempfile::tempdir().unwrap();
1819 let config_path = dir.path().join("fallow.toml");
1820
1821 let counts = CheckCounts {
1822 total_issues: 0,
1823 ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
1824 };
1825 save_baseline_to_config(&config_path, &counts).unwrap();
1826
1827 let content = std::fs::read_to_string(&config_path).unwrap();
1828 assert!(content.contains("[regression.baseline]"));
1829 assert!(content.contains("totalIssues = 0"));
1830 }
1831
1832 #[test]
1835 fn json_insert_with_trailing_comma() {
1836 let config = r#"{
1837 "entry": ["src/main.ts"],
1838}"#;
1839 let result = update_json_regression(config, &sample_baseline()).unwrap();
1841 assert!(result.contains("\"regression\""));
1842 }
1843
1844 #[test]
1845 fn json_no_closing_brace_returns_error() {
1846 let result = update_json_regression("", &sample_baseline());
1847 assert!(result.is_err());
1848 }
1849
1850 #[test]
1851 fn json_nested_regression_object_replaced_correctly() {
1852 let config = r#"{
1853 "regression": {
1854 "baseline": {
1855 "totalIssues": 99,
1856 "unusedFiles": 10
1857 },
1858 "tolerance": "5%"
1859 },
1860 "entry": ["src/main.ts"]
1861}"#;
1862 let result = update_json_regression(config, &sample_baseline()).unwrap();
1863 assert!(!result.contains("99"));
1864 assert!(result.contains("\"totalIssues\": 5"));
1865 assert!(result.contains("\"entry\""));
1866 }
1867
1868 #[test]
1871 fn toml_content_without_trailing_newline() {
1872 let config = "[rules]\nunused-files = \"warn\"";
1873 let result = update_toml_regression(config, &sample_baseline());
1874 assert!(result.contains("[regression.baseline]"));
1875 assert!(result.contains("[rules]"));
1876 }
1877
1878 #[test]
1879 fn toml_replace_section_not_at_end() {
1880 let config = "[regression.baseline]\ntotalIssues = 99\nunusedFiles = 10\n\n[rules]\nunused-files = \"warn\"\n";
1881 let result = update_toml_regression(config, &sample_baseline());
1882 assert!(!result.contains("99"));
1883 assert!(result.contains("totalIssues = 5"));
1884 assert!(result.contains("[rules]"));
1885 assert!(result.contains("unused-files = \"warn\""));
1886 }
1887
1888 #[test]
1889 fn toml_replace_section_at_end() {
1890 let config =
1891 "[rules]\nunused-files = \"warn\"\n\n[regression.baseline]\ntotalIssues = 99\n";
1892 let result = update_toml_regression(config, &sample_baseline());
1893 assert!(!result.contains("99"));
1894 assert!(result.contains("totalIssues = 5"));
1895 assert!(result.contains("[rules]"));
1896 }
1897
1898 #[test]
1901 fn find_json_key_multiple_same_keys() {
1902 let content = r#"{"foo": 1, "bar": {"foo": 2}}"#;
1904 let pos = find_json_key(content, "foo").unwrap();
1905 assert_eq!(pos, 1);
1906 }
1907
1908 #[test]
1909 fn find_json_key_in_nested_comment_then_real() {
1910 let content = "{\n // \"entry\": old\n /* \"entry\": also old */\n \"entry\": []\n}";
1911 let pos = find_json_key(content, "entry").unwrap();
1912 assert!(content[pos..].starts_with("\"entry\": []"));
1913 }
1914
1915 #[test]
1918 fn chrono_now_format() {
1919 let ts = chrono_now();
1920 assert_eq!(ts.len(), 20);
1922 assert!(ts.ends_with('Z'));
1923 assert_eq!(&ts[4..5], "-");
1924 assert_eq!(&ts[7..8], "-");
1925 assert_eq!(&ts[10..11], "T");
1926 assert_eq!(&ts[13..14], ":");
1927 assert_eq!(&ts[16..17], ":");
1928 }
1929
1930 #[test]
1933 fn print_pass_outcome_does_not_panic() {
1934 let outcome = RegressionOutcome::Pass {
1935 baseline_total: 10,
1936 current_total: 8,
1937 };
1938 print_regression_outcome(&outcome);
1940 }
1941
1942 #[test]
1943 fn print_exceeded_outcome_does_not_panic() {
1944 let outcome = RegressionOutcome::Exceeded {
1945 baseline_total: 10,
1946 current_total: 15,
1947 tolerance: Tolerance::Percentage(2.0),
1948 type_deltas: vec![("unused_files", 5), ("unused_exports", -2)],
1949 };
1950 print_regression_outcome(&outcome);
1951 }
1952
1953 #[test]
1954 fn print_exceeded_outcome_absolute_does_not_panic() {
1955 let outcome = RegressionOutcome::Exceeded {
1956 baseline_total: 10,
1957 current_total: 15,
1958 tolerance: Tolerance::Absolute(2),
1959 type_deltas: vec![("unused_files", 3), ("unresolved_imports", 2)],
1960 };
1961 print_regression_outcome(&outcome);
1962 }
1963
1964 #[test]
1965 fn print_skipped_outcome_does_not_panic() {
1966 let outcome = RegressionOutcome::Skipped {
1967 reason: "test reason",
1968 };
1969 print_regression_outcome(&outcome);
1970 }
1971
1972 #[test]
1973 fn print_exceeded_with_empty_deltas_does_not_panic() {
1974 let outcome = RegressionOutcome::Exceeded {
1975 baseline_total: 10,
1976 current_total: 15,
1977 tolerance: Tolerance::Absolute(0),
1978 type_deltas: vec![],
1979 };
1980 print_regression_outcome(&outcome);
1981 }
1982
1983 fn make_opts(
1986 fail: bool,
1987 tolerance: Tolerance,
1988 scoped: bool,
1989 baseline_file: Option<&Path>,
1990 ) -> RegressionOpts<'_> {
1991 RegressionOpts {
1992 fail_on_regression: fail,
1993 tolerance,
1994 regression_baseline_file: baseline_file,
1995 save_target: SaveRegressionTarget::None,
1996 scoped,
1997 quiet: true,
1998 }
1999 }
2000
2001 #[test]
2002 fn compare_returns_none_when_disabled() {
2003 let results = AnalysisResults::default();
2004 let opts = make_opts(false, Tolerance::Absolute(0), false, None);
2005 let config_baseline = fallow_config::RegressionBaseline {
2006 total_issues: 5,
2007 ..Default::default()
2008 };
2009 let outcome = compare_check_regression(&results, &opts, Some(&config_baseline)).unwrap();
2010 assert!(outcome.is_none());
2011 }
2012
2013 #[test]
2014 fn compare_returns_skipped_when_scoped() {
2015 let results = AnalysisResults::default();
2016 let opts = make_opts(true, Tolerance::Absolute(0), true, None);
2017 let config_baseline = fallow_config::RegressionBaseline {
2018 total_issues: 5,
2019 ..Default::default()
2020 };
2021 let outcome = compare_check_regression(&results, &opts, Some(&config_baseline)).unwrap();
2022 assert!(matches!(outcome, Some(RegressionOutcome::Skipped { .. })));
2023 }
2024
2025 #[test]
2026 fn compare_pass_with_config_baseline() {
2027 let results = AnalysisResults::default(); let opts = make_opts(true, Tolerance::Absolute(0), false, None);
2029 let config_baseline = fallow_config::RegressionBaseline {
2030 total_issues: 0,
2031 ..Default::default()
2032 };
2033 let outcome = compare_check_regression(&results, &opts, Some(&config_baseline)).unwrap();
2034 match outcome {
2035 Some(RegressionOutcome::Pass {
2036 baseline_total,
2037 current_total,
2038 }) => {
2039 assert_eq!(baseline_total, 0);
2040 assert_eq!(current_total, 0);
2041 }
2042 other => panic!("expected Pass, got {other:?}"),
2043 }
2044 }
2045
2046 #[test]
2047 fn compare_exceeded_with_config_baseline() {
2048 let mut results = AnalysisResults::default();
2049 results.unused_files.push(UnusedFile {
2050 path: PathBuf::from("a.ts"),
2051 });
2052 results.unused_files.push(UnusedFile {
2053 path: PathBuf::from("b.ts"),
2054 });
2055 let opts = make_opts(true, Tolerance::Absolute(0), false, None);
2056 let config_baseline = fallow_config::RegressionBaseline {
2057 total_issues: 0,
2058 ..Default::default()
2059 };
2060 let outcome = compare_check_regression(&results, &opts, Some(&config_baseline)).unwrap();
2061 match outcome {
2062 Some(RegressionOutcome::Exceeded {
2063 baseline_total,
2064 current_total,
2065 ..
2066 }) => {
2067 assert_eq!(baseline_total, 0);
2068 assert_eq!(current_total, 2);
2069 }
2070 other => panic!("expected Exceeded, got {other:?}"),
2071 }
2072 }
2073
2074 #[test]
2075 fn compare_pass_within_tolerance() {
2076 let mut results = AnalysisResults::default();
2077 results.unused_files.push(UnusedFile {
2078 path: PathBuf::from("a.ts"),
2079 });
2080 let opts = make_opts(true, Tolerance::Absolute(5), false, None);
2081 let config_baseline = fallow_config::RegressionBaseline {
2082 total_issues: 0,
2083 ..Default::default()
2084 };
2085 let outcome = compare_check_regression(&results, &opts, Some(&config_baseline)).unwrap();
2086 assert!(matches!(outcome, Some(RegressionOutcome::Pass { .. })));
2087 }
2088
2089 #[test]
2090 fn compare_improvement_is_pass() {
2091 let results = AnalysisResults::default(); let opts = make_opts(true, Tolerance::Absolute(0), false, None);
2094 let config_baseline = fallow_config::RegressionBaseline {
2095 total_issues: 10,
2096 unused_files: 5,
2097 unused_exports: 5,
2098 ..Default::default()
2099 };
2100 let outcome = compare_check_regression(&results, &opts, Some(&config_baseline)).unwrap();
2101 match outcome {
2102 Some(RegressionOutcome::Pass {
2103 baseline_total,
2104 current_total,
2105 }) => {
2106 assert_eq!(baseline_total, 10);
2107 assert_eq!(current_total, 0);
2108 }
2109 other => panic!("expected Pass, got {other:?}"),
2110 }
2111 }
2112
2113 #[test]
2114 fn compare_with_file_baseline() {
2115 let dir = tempfile::tempdir().unwrap();
2116 let baseline_path = dir.path().join("baseline.json");
2117
2118 let counts = CheckCounts {
2120 total_issues: 5,
2121 unused_files: 5,
2122 ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
2123 };
2124 save_regression_baseline(&baseline_path, dir.path(), Some(&counts), None).unwrap();
2125
2126 let results = AnalysisResults::default();
2128 let opts = make_opts(true, Tolerance::Absolute(0), false, Some(&baseline_path));
2129 let outcome = compare_check_regression(&results, &opts, None).unwrap();
2130 assert!(matches!(outcome, Some(RegressionOutcome::Pass { .. })));
2131 }
2132
2133 #[test]
2134 fn compare_file_baseline_missing_check_data_returns_error() {
2135 let dir = tempfile::tempdir().unwrap();
2136 let baseline_path = dir.path().join("baseline.json");
2137
2138 save_regression_baseline(
2140 &baseline_path,
2141 dir.path(),
2142 None,
2143 Some(&DupesCounts {
2144 clone_groups: 1,
2145 duplication_percentage: 1.0,
2146 }),
2147 )
2148 .unwrap();
2149
2150 let results = AnalysisResults::default();
2151 let opts = make_opts(true, Tolerance::Absolute(0), false, Some(&baseline_path));
2152 let outcome = compare_check_regression(&results, &opts, None);
2153 assert!(outcome.is_err());
2154 }
2155
2156 #[test]
2157 fn compare_no_baseline_source_returns_error() {
2158 let results = AnalysisResults::default();
2159 let opts = make_opts(true, Tolerance::Absolute(0), false, None);
2160 let outcome = compare_check_regression(&results, &opts, None);
2161 assert!(outcome.is_err());
2162 }
2163
2164 #[test]
2165 fn compare_exceeded_includes_type_deltas() {
2166 let mut results = AnalysisResults::default();
2167 results.unused_files.push(UnusedFile {
2168 path: PathBuf::from("a.ts"),
2169 });
2170 results.unused_files.push(UnusedFile {
2171 path: PathBuf::from("b.ts"),
2172 });
2173 results.unused_exports.push(UnusedExport {
2174 path: PathBuf::from("c.ts"),
2175 export_name: "foo".into(),
2176 is_type_only: false,
2177 line: 1,
2178 col: 0,
2179 span_start: 0,
2180 is_re_export: false,
2181 });
2182
2183 let opts = make_opts(true, Tolerance::Absolute(0), false, None);
2184 let config_baseline = fallow_config::RegressionBaseline {
2185 total_issues: 0,
2186 ..Default::default()
2187 };
2188 let outcome = compare_check_regression(&results, &opts, Some(&config_baseline)).unwrap();
2189
2190 match outcome {
2191 Some(RegressionOutcome::Exceeded { type_deltas, .. }) => {
2192 assert!(type_deltas.contains(&("unused_files", 2)));
2193 assert!(type_deltas.contains(&("unused_exports", 1)));
2194 }
2195 other => panic!("expected Exceeded, got {other:?}"),
2196 }
2197 }
2198
2199 #[test]
2200 fn compare_with_percentage_tolerance() {
2201 let mut results = AnalysisResults::default();
2202 results.unused_files.push(UnusedFile {
2204 path: PathBuf::from("a.ts"),
2205 });
2206
2207 let opts = make_opts(true, Tolerance::Percentage(50.0), false, None);
2208 let config_baseline = fallow_config::RegressionBaseline {
2212 total_issues: 10,
2213 unused_files: 10,
2214 ..Default::default()
2215 };
2216 let outcome = compare_check_regression(&results, &opts, Some(&config_baseline)).unwrap();
2217 assert!(matches!(outcome, Some(RegressionOutcome::Pass { .. })));
2218 }
2219}