1use std::path::Path;
2use std::process::ExitCode;
3
4use fallow_config::OutputFormat;
5use fallow_core::git_env::clear_ambient_git_env;
6use fallow_core::results::AnalysisResults;
7
8use super::counts::{CheckCounts, DupesCounts, REGRESSION_SCHEMA_VERSION, RegressionBaseline};
9use super::outcome::RegressionOutcome;
10use super::tolerance::Tolerance;
11
12use crate::error::emit_error;
13
14const SECS_PER_DAY: u64 = 86_400;
16
17#[derive(Clone, Copy)]
19pub enum SaveRegressionTarget<'a> {
20 None,
22 Config,
24 File(&'a Path),
26}
27
28#[derive(Clone, Copy)]
30pub struct RegressionOpts<'a> {
31 pub fail_on_regression: bool,
32 pub tolerance: Tolerance,
33 pub regression_baseline_file: Option<&'a Path>,
35 pub save_target: SaveRegressionTarget<'a>,
37 pub scoped: bool,
39 pub quiet: bool,
40 pub output: OutputFormat,
43}
44
45fn is_likely_gitignored(path: &Path, root: &Path) -> bool {
48 let mut command = std::process::Command::new("git");
49 command
50 .args(["check-ignore", "-q"])
51 .arg(path)
52 .current_dir(root);
53 clear_ambient_git_env(&mut command);
54 command.output().ok().is_some_and(|o| o.status.success())
55}
56
57fn current_git_sha(root: &Path) -> Option<String> {
59 let mut command = std::process::Command::new("git");
60 command.args(["rev-parse", "HEAD"]).current_dir(root);
61 clear_ambient_git_env(&mut command);
62 command
63 .output()
64 .ok()
65 .filter(|o| o.status.success())
66 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
67}
68
69pub fn save_regression_baseline(
75 path: &Path,
76 root: &Path,
77 check_counts: Option<&CheckCounts>,
78 dupes_counts: Option<&DupesCounts>,
79 output: OutputFormat,
80) -> Result<(), ExitCode> {
81 let baseline = RegressionBaseline {
82 schema_version: REGRESSION_SCHEMA_VERSION,
83 fallow_version: env!("CARGO_PKG_VERSION").to_string(),
84 timestamp: chrono_now(),
85 git_sha: current_git_sha(root),
86 check: check_counts.cloned(),
87 dupes: dupes_counts.cloned(),
88 };
89 let json = serde_json::to_string_pretty(&baseline).map_err(|e| {
90 emit_error(
91 &format!("failed to serialize regression baseline: {e}"),
92 2,
93 output,
94 )
95 })?;
96 if let Some(parent) = path.parent() {
97 let _ = std::fs::create_dir_all(parent);
98 }
99 std::fs::write(path, json).map_err(|e| {
100 emit_error(
101 &format!("failed to save regression baseline: {e}"),
102 2,
103 output,
104 )
105 })?;
106 eprintln!("Regression baseline saved to {}", path.display());
107 if is_likely_gitignored(path, root) {
108 eprintln!(
109 "Warning: '{}' may be gitignored. Commit this file so CI can compare against it.",
110 path.display()
111 );
112 }
113 Ok(())
114}
115
116pub fn save_baseline_to_config(
126 config_path: &Path,
127 counts: &CheckCounts,
128 output: OutputFormat,
129) -> Result<(), ExitCode> {
130 let content = match std::fs::read_to_string(config_path) {
131 Ok(c) => c,
132 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
133 let is_toml = config_path.extension().is_some_and(|ext| ext == "toml");
134 if is_toml {
135 String::new()
136 } else {
137 "{}".to_string()
138 }
139 }
140 Err(e) => {
141 return Err(emit_error(
142 &format!(
143 "failed to read config file '{}': {e}",
144 config_path.display()
145 ),
146 2,
147 output,
148 ));
149 }
150 };
151
152 let baseline = counts.to_config_baseline();
153 let is_toml = config_path.extension().is_some_and(|ext| ext == "toml");
154
155 let updated = if is_toml {
156 Ok(update_toml_regression(&content, &baseline))
157 } else {
158 update_json_regression(&content, &baseline)
159 }
160 .map_err(|e| {
161 emit_error(
162 &format!(
163 "failed to update config file '{}': {e}",
164 config_path.display()
165 ),
166 2,
167 output,
168 )
169 })?;
170
171 std::fs::write(config_path, updated).map_err(|e| {
172 emit_error(
173 &format!(
174 "failed to write config file '{}': {e}",
175 config_path.display()
176 ),
177 2,
178 output,
179 )
180 })?;
181
182 eprintln!(
183 "Regression baseline saved to {} (regression.baseline section)",
184 config_path.display()
185 );
186 Ok(())
187}
188
189fn find_json_key(content: &str, key: &str) -> Option<usize> {
193 let needle = format!("\"{key}\"");
194 let mut search_from = 0;
195 while let Some(pos) = content[search_from..].find(&needle) {
196 let abs_pos = search_from + pos;
197 let line_start = content[..abs_pos].rfind('\n').map_or(0, |i| i + 1);
198 let line_prefix = content[line_start..abs_pos].trim_start();
199 if line_prefix.starts_with("//") {
200 search_from = abs_pos + needle.len();
201 continue;
202 }
203 let before = &content[..abs_pos];
204 let last_open = before.rfind("/*");
205 let last_close = before.rfind("*/");
206 if let Some(open_pos) = last_open
207 && last_close.is_none_or(|close_pos| close_pos < open_pos)
208 {
209 search_from = abs_pos + needle.len();
210 continue;
211 }
212 return Some(abs_pos);
213 }
214 None
215}
216
217fn update_json_regression(
218 content: &str,
219 baseline: &fallow_config::RegressionBaseline,
220) -> Result<String, String> {
221 let baseline_json =
222 serde_json::to_string_pretty(baseline).map_err(|e| format!("serialization error: {e}"))?;
223
224 let indented: String = baseline_json
225 .lines()
226 .enumerate()
227 .map(|(i, line)| {
228 if i == 0 {
229 format!(" {line}")
230 } else {
231 format!("\n {line}")
232 }
233 })
234 .collect();
235
236 let regression_block = format!(" \"regression\": {{\n \"baseline\": {indented}\n }}");
237
238 if let Some(start) = find_json_key(content, "regression") {
239 let after_key = &content[start..];
240 if let Some(brace_start) = after_key.find('{') {
241 let abs_brace = start + brace_start;
242 let mut depth = 0;
243 let mut end = abs_brace;
244 let mut found_close = false;
245 for (i, ch) in content[abs_brace..].char_indices() {
246 match ch {
247 '{' => depth += 1,
248 '}' => {
249 depth -= 1;
250 if depth == 0 {
251 end = abs_brace + i + 1;
252 found_close = true;
253 break;
254 }
255 }
256 _ => {}
257 }
258 }
259 if !found_close {
260 return Err("malformed JSON: unmatched brace in regression object".to_string());
261 }
262 let mut result = String::new();
263 result.push_str(&content[..start]);
264 result.push_str(®ression_block[2..]); result.push_str(&content[end..]);
266 return Ok(result);
267 }
268 }
269
270 if let Some(last_brace) = content.rfind('}') {
271 let before_brace = content[..last_brace].trim_end();
272 let needs_comma = !before_brace.ends_with('{') && !before_brace.ends_with(',');
273
274 let mut result = String::new();
275 result.push_str(before_brace);
276 if needs_comma {
277 result.push(',');
278 }
279 result.push('\n');
280 result.push_str(®ression_block);
281 result.push('\n');
282 result.push_str(&content[last_brace..]);
283 Ok(result)
284 } else {
285 Err("config file has no closing brace".to_string())
286 }
287}
288
289fn update_toml_regression(content: &str, baseline: &fallow_config::RegressionBaseline) -> String {
291 use std::fmt::Write;
292 let mut section = String::from("[regression.baseline]\n");
293 let _ = writeln!(section, "totalIssues = {}", baseline.total_issues);
294 let _ = writeln!(section, "unusedFiles = {}", baseline.unused_files);
295 let _ = writeln!(section, "unusedExports = {}", baseline.unused_exports);
296 let _ = writeln!(section, "unusedTypes = {}", baseline.unused_types);
297 let _ = writeln!(
298 section,
299 "unusedDependencies = {}",
300 baseline.unused_dependencies
301 );
302 let _ = writeln!(
303 section,
304 "unusedDevDependencies = {}",
305 baseline.unused_dev_dependencies
306 );
307 let _ = writeln!(
308 section,
309 "unusedOptionalDependencies = {}",
310 baseline.unused_optional_dependencies
311 );
312 let _ = writeln!(
313 section,
314 "unusedEnumMembers = {}",
315 baseline.unused_enum_members
316 );
317 let _ = writeln!(
318 section,
319 "unusedClassMembers = {}",
320 baseline.unused_class_members
321 );
322 let _ = writeln!(
323 section,
324 "unresolvedImports = {}",
325 baseline.unresolved_imports
326 );
327 let _ = writeln!(
328 section,
329 "unlistedDependencies = {}",
330 baseline.unlisted_dependencies
331 );
332 let _ = writeln!(section, "duplicateExports = {}", baseline.duplicate_exports);
333 let _ = writeln!(
334 section,
335 "circularDependencies = {}",
336 baseline.circular_dependencies
337 );
338 let _ = writeln!(
339 section,
340 "typeOnlyDependencies = {}",
341 baseline.type_only_dependencies
342 );
343 let _ = writeln!(
344 section,
345 "testOnlyDependencies = {}",
346 baseline.test_only_dependencies
347 );
348
349 if let Some(start) = content.find("[regression.baseline]") {
350 let after = &content[start + "[regression.baseline]".len()..];
351 let end_offset = after.find("\n[").map_or(content.len(), |i| {
352 start + "[regression.baseline]".len() + i + 1
353 });
354
355 let mut result = String::new();
356 result.push_str(&content[..start]);
357 result.push_str(§ion);
358 if end_offset < content.len() {
359 result.push_str(&content[end_offset..]);
360 }
361 result
362 } else {
363 let mut result = content.to_string();
364 if !result.ends_with('\n') {
365 result.push('\n');
366 }
367 result.push('\n');
368 result.push_str(§ion);
369 result
370 }
371}
372
373fn format_schema_mismatch_error(
376 path: &Path,
377 expected: u32,
378 actual: u32,
379 writer_version: &str,
380) -> String {
381 let path_display = path.display();
382 if actual == 0 {
383 format!(
384 "regression baseline '{path_display}' appears to predate schema versioning \
385 (schema_version is 0; this fallow build expects {expected}).\n\
386 The baseline was written by fallow {writer_version}.\n\
387 Regenerate it by running: fallow dead-code --save-regression-baseline {path_display}"
388 )
389 } else {
390 format!(
391 "regression baseline '{path_display}' has schema_version {actual} but this fallow build expects {expected}.\n\
392 The baseline was written by fallow {writer_version}.\n\
393 Regenerate it by running: fallow dead-code --save-regression-baseline {path_display}"
394 )
395 }
396}
397
398fn format_missing_schema_version_error(path: &Path) -> String {
402 let path_display = path.display();
403 let expected = REGRESSION_SCHEMA_VERSION;
404 format!(
405 "regression baseline '{path_display}' is missing the schema_version field; \
406 this fallow build expects schema_version {expected}.\n\
407 The baseline likely predates schema versioning or was hand-edited.\n\
408 Regenerate it by running: fallow dead-code --save-regression-baseline {path_display}"
409 )
410}
411
412pub fn load_regression_baseline(
425 path: &Path,
426 output: OutputFormat,
427) -> Result<RegressionBaseline, ExitCode> {
428 let content = std::fs::read_to_string(path).map_err(|e| {
429 if e.kind() == std::io::ErrorKind::NotFound {
430 emit_error(
431 &format!(
432 "no regression baseline found at '{}'.\n\
433 Run with --save-regression-baseline on your main branch to create one.",
434 path.display()
435 ),
436 2,
437 output,
438 )
439 } else {
440 emit_error(
441 &format!(
442 "failed to read regression baseline '{}': {e}",
443 path.display()
444 ),
445 2,
446 output,
447 )
448 }
449 })?;
450 let baseline: RegressionBaseline = serde_json::from_str(&content).map_err(|e| {
451 let message = if e.to_string().contains("missing field `schema_version`") {
452 format_missing_schema_version_error(path)
453 } else {
454 format!(
455 "failed to parse regression baseline '{}': {e}",
456 path.display()
457 )
458 };
459 emit_error(&message, 2, output)
460 })?;
461 if baseline.schema_version != REGRESSION_SCHEMA_VERSION {
462 let message = format_schema_mismatch_error(
463 path,
464 REGRESSION_SCHEMA_VERSION,
465 baseline.schema_version,
466 &baseline.fallow_version,
467 );
468 return Err(emit_error(&message, 2, output));
469 }
470 Ok(baseline)
471}
472
473pub fn compare_check_regression(
485 results: &AnalysisResults,
486 opts: &RegressionOpts<'_>,
487 config_baseline: Option<&fallow_config::RegressionBaseline>,
488) -> Result<Option<RegressionOutcome>, ExitCode> {
489 if !opts.fail_on_regression {
490 return Ok(None);
491 }
492
493 if opts.scoped {
494 let reason = "--changed-since or --workspace is active; regression check skipped \
495 (counts not comparable to full-project baseline)";
496 if !opts.quiet {
497 eprintln!("Warning: {reason}");
498 }
499 return Ok(Some(RegressionOutcome::Skipped { reason }));
500 }
501
502 let baseline_counts: CheckCounts = if let Some(baseline_path) = opts.regression_baseline_file {
503 let baseline = load_regression_baseline(baseline_path, opts.output)?;
504 let Some(counts) = baseline.check else {
505 return Err(emit_error(
506 &format!(
507 "regression baseline '{}' has no check data",
508 baseline_path.display()
509 ),
510 2,
511 opts.output,
512 ));
513 };
514 counts
515 } else if let Some(config_baseline) = config_baseline {
516 CheckCounts::from_config_baseline(config_baseline)
517 } else {
518 return Err(emit_error(
519 "no regression baseline found.\n\
520 Either add a `regression.baseline` section to your config file\n\
521 (run with --save-regression-baseline to generate it),\n\
522 or provide an explicit file via --regression-baseline <PATH>.",
523 2,
524 opts.output,
525 ));
526 };
527
528 let current_total = results.total_issues();
529 let baseline_total = baseline_counts.total_issues;
530
531 if opts.tolerance.exceeded(baseline_total, current_total) {
532 let current_counts = CheckCounts::from_results(results);
533 let type_deltas = baseline_counts.deltas(¤t_counts);
534 Ok(Some(RegressionOutcome::Exceeded {
535 baseline_total,
536 current_total,
537 tolerance: opts.tolerance,
538 type_deltas,
539 }))
540 } else {
541 Ok(Some(RegressionOutcome::Pass {
542 baseline_total,
543 current_total,
544 }))
545 }
546}
547
548fn chrono_now() -> String {
550 let duration = std::time::SystemTime::now()
551 .duration_since(std::time::UNIX_EPOCH)
552 .unwrap_or_default();
553 let secs = duration.as_secs();
554 let days = secs / SECS_PER_DAY;
555 let time_secs = secs % SECS_PER_DAY;
556 let hours = time_secs / 3600;
557 let minutes = (time_secs % 3600) / 60;
558 let seconds = time_secs % 60;
559 let z = days + 719_468;
560 let era = z / 146_097;
561 let doe = z - era * 146_097;
562 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
563 let y = yoe + era * 400;
564 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
565 let mp = (5 * doy + 2) / 153;
566 let d = doy - (153 * mp + 2) / 5 + 1;
567 let m = if mp < 10 { mp + 3 } else { mp - 9 };
568 let y = if m <= 2 { y + 1 } else { y };
569 format!("{y:04}-{m:02}-{d:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575 use fallow_core::results::*;
576 use std::path::PathBuf;
577
578 fn sample_baseline() -> fallow_config::RegressionBaseline {
579 fallow_config::RegressionBaseline {
580 total_issues: 5,
581 unused_files: 2,
582 ..Default::default()
583 }
584 }
585
586 #[test]
587 fn json_insert_into_empty_object() {
588 let result = update_json_regression("{}", &sample_baseline()).unwrap();
589 assert!(result.contains("\"regression\""));
590 assert!(result.contains("\"totalIssues\": 5"));
591 serde_json::from_str::<serde_json::Value>(&result).unwrap();
592 }
593
594 #[test]
595 fn json_insert_into_existing_config() {
596 let config = r#"{
597 "entry": ["src/main.ts"],
598 "production": true
599}"#;
600 let result = update_json_regression(config, &sample_baseline()).unwrap();
601 assert!(result.contains("\"regression\""));
602 assert!(result.contains("\"entry\""));
603 serde_json::from_str::<serde_json::Value>(&result).unwrap();
604 }
605
606 #[test]
607 fn json_replace_existing_regression() {
608 let config = r#"{
609 "entry": ["src/main.ts"],
610 "regression": {
611 "baseline": {
612 "totalIssues": 99
613 }
614 }
615}"#;
616 let result = update_json_regression(config, &sample_baseline()).unwrap();
617 assert!(!result.contains("99"));
618 assert!(result.contains("\"totalIssues\": 5"));
619 serde_json::from_str::<serde_json::Value>(&result).unwrap();
620 }
621
622 #[test]
623 fn json_skips_regression_in_comment() {
624 let config = "{\n // See \"regression\" docs\n \"entry\": []\n}";
625 let result = update_json_regression(config, &sample_baseline()).unwrap();
626 assert!(result.contains("\"regression\":"));
627 assert!(result.contains("\"entry\""));
628 }
629
630 #[test]
631 fn json_malformed_brace_returns_error() {
632 let config = r#"{ "regression": { "baseline": { "totalIssues": 1 }"#;
633 let result = update_json_regression(config, &sample_baseline());
634 assert!(result.is_err());
635 }
636
637 #[test]
638 fn toml_insert_into_empty() {
639 let result = update_toml_regression("", &sample_baseline());
640 assert!(result.contains("[regression.baseline]"));
641 assert!(result.contains("totalIssues = 5"));
642 }
643
644 #[test]
645 fn toml_insert_after_existing_content() {
646 let config = "[rules]\nunused-files = \"warn\"\n";
647 let result = update_toml_regression(config, &sample_baseline());
648 assert!(result.contains("[rules]"));
649 assert!(result.contains("[regression.baseline]"));
650 assert!(result.contains("totalIssues = 5"));
651 }
652
653 #[test]
654 fn toml_replace_existing_section() {
655 let config =
656 "[regression.baseline]\ntotalIssues = 99\n\n[rules]\nunused-files = \"warn\"\n";
657 let result = update_toml_regression(config, &sample_baseline());
658 assert!(!result.contains("99"));
659 assert!(result.contains("totalIssues = 5"));
660 assert!(result.contains("[rules]"));
661 }
662
663 #[test]
664 fn find_json_key_basic() {
665 assert_eq!(find_json_key(r#"{"foo": 1}"#, "foo"), Some(1));
666 }
667
668 #[test]
669 fn find_json_key_skips_comment() {
670 let content = "{\n // \"foo\" is important\n \"bar\": 1\n}";
671 assert_eq!(find_json_key(content, "foo"), None);
672 assert!(find_json_key(content, "bar").is_some());
673 }
674
675 #[test]
676 fn find_json_key_not_found() {
677 assert_eq!(find_json_key("{}", "missing"), None);
678 }
679
680 #[test]
681 fn find_json_key_skips_block_comment() {
682 let content = "{\n /* \"foo\": old value */\n \"foo\": 1\n}";
683 let pos = find_json_key(content, "foo").unwrap();
684 assert!(content[pos..].starts_with("\"foo\": 1"));
685 }
686
687 #[test]
688 fn chrono_now_format() {
689 let ts = chrono_now();
690 assert_eq!(ts.len(), 20);
691 assert!(ts.ends_with('Z'));
692 assert_eq!(&ts[4..5], "-");
693 assert_eq!(&ts[7..8], "-");
694 assert_eq!(&ts[10..11], "T");
695 assert_eq!(&ts[13..14], ":");
696 assert_eq!(&ts[16..17], ":");
697 }
698
699 #[test]
700 fn save_load_roundtrip() {
701 let dir = tempfile::tempdir().unwrap();
702 let path = dir.path().join("regression-baseline.json");
703 let counts = CheckCounts {
704 total_issues: 15,
705 unused_files: 3,
706 unused_exports: 5,
707 unused_types: 2,
708 unused_dependencies: 1,
709 unused_dev_dependencies: 1,
710 unused_optional_dependencies: 0,
711 unused_enum_members: 1,
712 unused_class_members: 0,
713 unused_store_members: 0,
714 unprovided_injects: 0,
715 unrendered_components: 0,
716 unused_component_props: 0,
717 unused_component_emits: 0,
718 unused_component_inputs: 0,
719 unused_component_outputs: 0,
720 unused_svelte_events: 0,
721 unused_server_actions: 0,
722 unused_load_data_keys: 0,
723 unresolved_imports: 1,
724 unlisted_dependencies: 0,
725 duplicate_exports: 1,
726 circular_dependencies: 0,
727 re_export_cycles: 0,
728 type_only_dependencies: 0,
729 test_only_dependencies: 0,
730 boundary_violations: 0,
731 boundary_coverage_violations: 0,
732 boundary_call_violations: 0,
733 policy_violations: 0,
734 };
735 let dupes = DupesCounts {
736 clone_groups: 4,
737 duplication_percentage: 2.5,
738 };
739
740 save_regression_baseline(
741 &path,
742 dir.path(),
743 Some(&counts),
744 Some(&dupes),
745 OutputFormat::Human,
746 )
747 .unwrap();
748 let loaded = load_regression_baseline(&path, OutputFormat::Human).unwrap();
749
750 assert_eq!(loaded.schema_version, REGRESSION_SCHEMA_VERSION);
751 let check = loaded.check.unwrap();
752 assert_eq!(check.total_issues, 15);
753 assert_eq!(check.unused_files, 3);
754 assert_eq!(check.unused_exports, 5);
755 assert_eq!(check.unused_types, 2);
756 assert_eq!(check.unused_dependencies, 1);
757 assert_eq!(check.unresolved_imports, 1);
758 assert_eq!(check.duplicate_exports, 1);
759 let dupes = loaded.dupes.unwrap();
760 assert_eq!(dupes.clone_groups, 4);
761 assert!((dupes.duplication_percentage - 2.5).abs() < f64::EPSILON);
762 }
763
764 #[test]
765 fn save_load_roundtrip_check_only() {
766 let dir = tempfile::tempdir().unwrap();
767 let path = dir.path().join("regression-baseline.json");
768 let counts = CheckCounts {
769 total_issues: 5,
770 unused_files: 5,
771 ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
772 };
773
774 save_regression_baseline(&path, dir.path(), Some(&counts), None, OutputFormat::Human)
775 .unwrap();
776 let loaded = load_regression_baseline(&path, OutputFormat::Human).unwrap();
777
778 assert!(loaded.check.is_some());
779 assert!(loaded.dupes.is_none());
780 assert_eq!(loaded.check.unwrap().unused_files, 5);
781 }
782
783 #[test]
784 fn save_creates_parent_directories() {
785 let dir = tempfile::tempdir().unwrap();
786 let path = dir.path().join("nested").join("dir").join("baseline.json");
787 let counts = CheckCounts {
788 total_issues: 1,
789 unused_files: 1,
790 ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
791 };
792
793 save_regression_baseline(&path, dir.path(), Some(&counts), None, OutputFormat::Human)
794 .unwrap();
795 assert!(path.exists());
796 }
797
798 #[test]
799 fn load_nonexistent_file_returns_error() {
800 let result = load_regression_baseline(
801 Path::new("/tmp/nonexistent-baseline-12345.json"),
802 OutputFormat::Human,
803 );
804 assert!(result.is_err());
805 }
806
807 #[test]
808 fn load_invalid_json_returns_error() {
809 let dir = tempfile::tempdir().unwrap();
810 let path = dir.path().join("bad.json");
811 std::fs::write(&path, "not valid json {{{").unwrap();
812 let result = load_regression_baseline(&path, OutputFormat::Human);
813 assert!(result.is_err());
814 }
815
816 #[test]
817 fn save_baseline_to_json_config() {
818 let dir = tempfile::tempdir().unwrap();
819 let config_path = dir.path().join(".fallowrc.json");
820 std::fs::write(&config_path, r#"{"entry": ["src/main.ts"]}"#).unwrap();
821
822 let counts = CheckCounts {
823 total_issues: 7,
824 unused_files: 3,
825 unused_exports: 4,
826 ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
827 };
828 save_baseline_to_config(&config_path, &counts, OutputFormat::Human).unwrap();
829
830 let content = std::fs::read_to_string(&config_path).unwrap();
831 assert!(content.contains("\"regression\""));
832 assert!(content.contains("\"totalIssues\": 7"));
833 serde_json::from_str::<serde_json::Value>(&content).unwrap();
834 }
835
836 #[test]
837 fn save_baseline_to_toml_config() {
838 let dir = tempfile::tempdir().unwrap();
839 let config_path = dir.path().join("fallow.toml");
840 std::fs::write(&config_path, "[rules]\nunused-files = \"warn\"\n").unwrap();
841
842 let counts = CheckCounts {
843 total_issues: 7,
844 unused_files: 3,
845 unused_exports: 4,
846 ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
847 };
848 save_baseline_to_config(&config_path, &counts, OutputFormat::Human).unwrap();
849
850 let content = std::fs::read_to_string(&config_path).unwrap();
851 assert!(content.contains("[regression.baseline]"));
852 assert!(content.contains("totalIssues = 7"));
853 assert!(content.contains("[rules]"));
854 }
855
856 #[test]
857 fn save_baseline_to_nonexistent_json_config() {
858 let dir = tempfile::tempdir().unwrap();
859 let config_path = dir.path().join(".fallowrc.json");
860
861 let counts = CheckCounts {
862 total_issues: 1,
863 unused_files: 1,
864 ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
865 };
866 save_baseline_to_config(&config_path, &counts, OutputFormat::Human).unwrap();
867
868 let content = std::fs::read_to_string(&config_path).unwrap();
869 assert!(content.contains("\"regression\""));
870 serde_json::from_str::<serde_json::Value>(&content).unwrap();
871 }
872
873 #[test]
874 fn save_baseline_to_nonexistent_toml_config() {
875 let dir = tempfile::tempdir().unwrap();
876 let config_path = dir.path().join("fallow.toml");
877
878 let counts = CheckCounts {
879 total_issues: 0,
880 ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
881 };
882 save_baseline_to_config(&config_path, &counts, OutputFormat::Human).unwrap();
883
884 let content = std::fs::read_to_string(&config_path).unwrap();
885 assert!(content.contains("[regression.baseline]"));
886 assert!(content.contains("totalIssues = 0"));
887 }
888
889 #[test]
890 fn json_insert_with_trailing_comma() {
891 let config = r#"{
892 "entry": ["src/main.ts"],
893}"#;
894 let result = update_json_regression(config, &sample_baseline()).unwrap();
895 assert!(result.contains("\"regression\""));
896 }
897
898 #[test]
899 fn json_no_closing_brace_returns_error() {
900 let result = update_json_regression("", &sample_baseline());
901 assert!(result.is_err());
902 }
903
904 #[test]
905 fn json_nested_regression_object_replaced_correctly() {
906 let config = r#"{
907 "regression": {
908 "baseline": {
909 "totalIssues": 99,
910 "unusedFiles": 10
911 },
912 "tolerance": "5%"
913 },
914 "entry": ["src/main.ts"]
915}"#;
916 let result = update_json_regression(config, &sample_baseline()).unwrap();
917 assert!(!result.contains("99"));
918 assert!(result.contains("\"totalIssues\": 5"));
919 assert!(result.contains("\"entry\""));
920 }
921
922 #[test]
923 fn toml_content_without_trailing_newline() {
924 let config = "[rules]\nunused-files = \"warn\"";
925 let result = update_toml_regression(config, &sample_baseline());
926 assert!(result.contains("[regression.baseline]"));
927 assert!(result.contains("[rules]"));
928 }
929
930 #[test]
931 fn toml_replace_section_not_at_end() {
932 let config = "[regression.baseline]\ntotalIssues = 99\nunusedFiles = 10\n\n[rules]\nunused-files = \"warn\"\n";
933 let result = update_toml_regression(config, &sample_baseline());
934 assert!(!result.contains("99"));
935 assert!(result.contains("totalIssues = 5"));
936 assert!(result.contains("[rules]"));
937 assert!(result.contains("unused-files = \"warn\""));
938 }
939
940 #[test]
941 fn toml_replace_section_at_end() {
942 let config =
943 "[rules]\nunused-files = \"warn\"\n\n[regression.baseline]\ntotalIssues = 99\n";
944 let result = update_toml_regression(config, &sample_baseline());
945 assert!(!result.contains("99"));
946 assert!(result.contains("totalIssues = 5"));
947 assert!(result.contains("[rules]"));
948 }
949
950 #[test]
951 fn find_json_key_multiple_same_keys() {
952 let content = r#"{"foo": 1, "bar": {"foo": 2}}"#;
953 let pos = find_json_key(content, "foo").unwrap();
954 assert_eq!(pos, 1);
955 }
956
957 #[test]
958 fn find_json_key_in_nested_comment_then_real() {
959 let content = "{\n // \"entry\": old\n /* \"entry\": also old */\n \"entry\": []\n}";
960 let pos = find_json_key(content, "entry").unwrap();
961 assert!(content[pos..].starts_with("\"entry\": []"));
962 }
963
964 fn make_opts(
965 fail: bool,
966 tolerance: Tolerance,
967 scoped: bool,
968 baseline_file: Option<&Path>,
969 ) -> RegressionOpts<'_> {
970 RegressionOpts {
971 fail_on_regression: fail,
972 tolerance,
973 regression_baseline_file: baseline_file,
974 save_target: SaveRegressionTarget::None,
975 scoped,
976 quiet: true,
977 output: OutputFormat::Human,
978 }
979 }
980
981 #[test]
982 fn compare_returns_none_when_disabled() {
983 let results = AnalysisResults::default();
984 let opts = make_opts(false, Tolerance::Absolute(0), false, None);
985 let config_baseline = fallow_config::RegressionBaseline {
986 total_issues: 5,
987 ..Default::default()
988 };
989 let outcome = compare_check_regression(&results, &opts, Some(&config_baseline)).unwrap();
990 assert!(outcome.is_none());
991 }
992
993 #[test]
994 fn compare_returns_skipped_when_scoped() {
995 let results = AnalysisResults::default();
996 let opts = make_opts(true, Tolerance::Absolute(0), true, None);
997 let config_baseline = fallow_config::RegressionBaseline {
998 total_issues: 5,
999 ..Default::default()
1000 };
1001 let outcome = compare_check_regression(&results, &opts, Some(&config_baseline)).unwrap();
1002 assert!(matches!(outcome, Some(RegressionOutcome::Skipped { .. })));
1003 }
1004
1005 #[test]
1006 fn compare_pass_with_config_baseline() {
1007 let results = AnalysisResults::default(); let opts = make_opts(true, Tolerance::Absolute(0), false, None);
1009 let config_baseline = fallow_config::RegressionBaseline {
1010 total_issues: 0,
1011 ..Default::default()
1012 };
1013 let outcome = compare_check_regression(&results, &opts, Some(&config_baseline)).unwrap();
1014 match outcome {
1015 Some(RegressionOutcome::Pass {
1016 baseline_total,
1017 current_total,
1018 }) => {
1019 assert_eq!(baseline_total, 0);
1020 assert_eq!(current_total, 0);
1021 }
1022 other => panic!("expected Pass, got {other:?}"),
1023 }
1024 }
1025
1026 #[test]
1027 fn compare_exceeded_with_config_baseline() {
1028 let mut results = AnalysisResults::default();
1029 results
1030 .unused_files
1031 .push(UnusedFileFinding::with_actions(UnusedFile {
1032 path: PathBuf::from("a.ts"),
1033 }));
1034 results
1035 .unused_files
1036 .push(UnusedFileFinding::with_actions(UnusedFile {
1037 path: PathBuf::from("b.ts"),
1038 }));
1039 let opts = make_opts(true, Tolerance::Absolute(0), false, None);
1040 let config_baseline = fallow_config::RegressionBaseline {
1041 total_issues: 0,
1042 ..Default::default()
1043 };
1044 let outcome = compare_check_regression(&results, &opts, Some(&config_baseline)).unwrap();
1045 match outcome {
1046 Some(RegressionOutcome::Exceeded {
1047 baseline_total,
1048 current_total,
1049 ..
1050 }) => {
1051 assert_eq!(baseline_total, 0);
1052 assert_eq!(current_total, 2);
1053 }
1054 other => panic!("expected Exceeded, got {other:?}"),
1055 }
1056 }
1057
1058 #[test]
1059 fn compare_pass_within_tolerance() {
1060 let mut results = AnalysisResults::default();
1061 results
1062 .unused_files
1063 .push(UnusedFileFinding::with_actions(UnusedFile {
1064 path: PathBuf::from("a.ts"),
1065 }));
1066 let opts = make_opts(true, Tolerance::Absolute(5), false, None);
1067 let config_baseline = fallow_config::RegressionBaseline {
1068 total_issues: 0,
1069 ..Default::default()
1070 };
1071 let outcome = compare_check_regression(&results, &opts, Some(&config_baseline)).unwrap();
1072 assert!(matches!(outcome, Some(RegressionOutcome::Pass { .. })));
1073 }
1074
1075 #[test]
1076 fn compare_improvement_is_pass() {
1077 let results = AnalysisResults::default(); let opts = make_opts(true, Tolerance::Absolute(0), false, None);
1079 let config_baseline = fallow_config::RegressionBaseline {
1080 total_issues: 10,
1081 unused_files: 5,
1082 unused_exports: 5,
1083 ..Default::default()
1084 };
1085 let outcome = compare_check_regression(&results, &opts, Some(&config_baseline)).unwrap();
1086 match outcome {
1087 Some(RegressionOutcome::Pass {
1088 baseline_total,
1089 current_total,
1090 }) => {
1091 assert_eq!(baseline_total, 10);
1092 assert_eq!(current_total, 0);
1093 }
1094 other => panic!("expected Pass, got {other:?}"),
1095 }
1096 }
1097
1098 #[test]
1099 fn compare_with_file_baseline() {
1100 let dir = tempfile::tempdir().unwrap();
1101 let baseline_path = dir.path().join("baseline.json");
1102
1103 let counts = CheckCounts {
1104 total_issues: 5,
1105 unused_files: 5,
1106 ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
1107 };
1108 save_regression_baseline(
1109 &baseline_path,
1110 dir.path(),
1111 Some(&counts),
1112 None,
1113 OutputFormat::Human,
1114 )
1115 .unwrap();
1116
1117 let results = AnalysisResults::default();
1118 let opts = make_opts(true, Tolerance::Absolute(0), false, Some(&baseline_path));
1119 let outcome = compare_check_regression(&results, &opts, None).unwrap();
1120 assert!(matches!(outcome, Some(RegressionOutcome::Pass { .. })));
1121 }
1122
1123 #[test]
1124 fn compare_file_baseline_missing_check_data_returns_error() {
1125 let dir = tempfile::tempdir().unwrap();
1126 let baseline_path = dir.path().join("baseline.json");
1127
1128 save_regression_baseline(
1129 &baseline_path,
1130 dir.path(),
1131 None,
1132 Some(&DupesCounts {
1133 clone_groups: 1,
1134 duplication_percentage: 1.0,
1135 }),
1136 OutputFormat::Human,
1137 )
1138 .unwrap();
1139
1140 let results = AnalysisResults::default();
1141 let opts = make_opts(true, Tolerance::Absolute(0), false, Some(&baseline_path));
1142 let outcome = compare_check_regression(&results, &opts, None);
1143 assert!(outcome.is_err());
1144 }
1145
1146 #[test]
1147 fn compare_no_baseline_source_returns_error() {
1148 let results = AnalysisResults::default();
1149 let opts = make_opts(true, Tolerance::Absolute(0), false, None);
1150 let outcome = compare_check_regression(&results, &opts, None);
1151 assert!(outcome.is_err());
1152 }
1153
1154 #[test]
1155 fn compare_exceeded_includes_type_deltas() {
1156 let mut results = AnalysisResults::default();
1157 results
1158 .unused_files
1159 .push(UnusedFileFinding::with_actions(UnusedFile {
1160 path: PathBuf::from("a.ts"),
1161 }));
1162 results
1163 .unused_files
1164 .push(UnusedFileFinding::with_actions(UnusedFile {
1165 path: PathBuf::from("b.ts"),
1166 }));
1167 results
1168 .unused_exports
1169 .push(UnusedExportFinding::with_actions(UnusedExport {
1170 path: PathBuf::from("c.ts"),
1171 export_name: "foo".into(),
1172 is_type_only: false,
1173 line: 1,
1174 col: 0,
1175 span_start: 0,
1176 is_re_export: false,
1177 }));
1178
1179 let opts = make_opts(true, Tolerance::Absolute(0), false, None);
1180 let config_baseline = fallow_config::RegressionBaseline {
1181 total_issues: 0,
1182 ..Default::default()
1183 };
1184 let outcome = compare_check_regression(&results, &opts, Some(&config_baseline)).unwrap();
1185
1186 match outcome {
1187 Some(RegressionOutcome::Exceeded { type_deltas, .. }) => {
1188 assert!(type_deltas.contains(&("unused_files", 2)));
1189 assert!(type_deltas.contains(&("unused_exports", 1)));
1190 }
1191 other => panic!("expected Exceeded, got {other:?}"),
1192 }
1193 }
1194
1195 #[test]
1196 fn compare_with_percentage_tolerance() {
1197 let mut results = AnalysisResults::default();
1198 results
1199 .unused_files
1200 .push(UnusedFileFinding::with_actions(UnusedFile {
1201 path: PathBuf::from("a.ts"),
1202 }));
1203
1204 let opts = make_opts(true, Tolerance::Percentage(50.0), false, None);
1205 let config_baseline = fallow_config::RegressionBaseline {
1206 total_issues: 10,
1207 unused_files: 10,
1208 ..Default::default()
1209 };
1210 let outcome = compare_check_regression(&results, &opts, Some(&config_baseline)).unwrap();
1211 assert!(matches!(outcome, Some(RegressionOutcome::Pass { .. })));
1212 }
1213
1214 fn write_baseline_with_schema_version(dir: &Path, version: u32) -> PathBuf {
1215 let path = dir.join("baseline.json");
1216 let body = format!(
1217 r#"{{
1218 "schema_version": {version},
1219 "fallow_version": "3.0.0",
1220 "timestamp": "2026-05-21T00:00:00Z",
1221 "check": {{
1222 "total_issues": 0,
1223 "unused_files": 0
1224 }}
1225}}"#
1226 );
1227 std::fs::write(&path, body).unwrap();
1228 path
1229 }
1230
1231 #[test]
1232 fn load_rejects_schema_version_too_high() {
1233 let dir = tempfile::tempdir().unwrap();
1234 let path = write_baseline_with_schema_version(dir.path(), REGRESSION_SCHEMA_VERSION + 1);
1235 let result = load_regression_baseline(&path, OutputFormat::Human);
1236 assert!(result.is_err());
1237 }
1238
1239 #[test]
1240 fn load_rejects_schema_version_zero_predates_versioning() {
1241 let dir = tempfile::tempdir().unwrap();
1242 let path = write_baseline_with_schema_version(dir.path(), 0);
1243 let result = load_regression_baseline(&path, OutputFormat::Human);
1244 assert!(result.is_err());
1245 }
1246
1247 #[test]
1248 fn load_accepts_current_schema_version() {
1249 let dir = tempfile::tempdir().unwrap();
1250 let path = write_baseline_with_schema_version(dir.path(), REGRESSION_SCHEMA_VERSION);
1251 let loaded = load_regression_baseline(&path, OutputFormat::Human).unwrap();
1252 assert_eq!(loaded.schema_version, REGRESSION_SCHEMA_VERSION);
1253 }
1254
1255 #[test]
1256 fn load_rewrites_missing_schema_version_field_error() {
1257 let dir = tempfile::tempdir().unwrap();
1258 let path = dir.path().join("baseline.json");
1259 std::fs::write(
1260 &path,
1261 r#"{
1262 "fallow_version": "1.0.0",
1263 "timestamp": "2026-05-21T00:00:00Z",
1264 "check": {}
1265}"#,
1266 )
1267 .unwrap();
1268 let result = load_regression_baseline(&path, OutputFormat::Human);
1269 assert!(result.is_err());
1270 }
1271
1272 #[test]
1273 fn format_schema_mismatch_error_too_high() {
1274 let msg =
1275 format_schema_mismatch_error(Path::new("/repo/.fallow-baseline.json"), 1, 99, "3.0.0");
1276 assert!(msg.contains("schema_version 99"));
1277 assert!(msg.contains("expects 1"));
1278 assert!(msg.contains("fallow 3.0.0"));
1279 assert!(
1280 msg.contains("fallow dead-code --save-regression-baseline /repo/.fallow-baseline.json")
1281 );
1282 assert!(!msg.to_lowercase().contains("refresh"));
1283 assert!(msg.contains("schema_version"));
1284 }
1285
1286 #[test]
1287 fn format_schema_mismatch_error_actual_zero_special_case() {
1288 let msg =
1289 format_schema_mismatch_error(Path::new("/repo/.fallow-baseline.json"), 1, 0, "2.0.0");
1290 assert!(msg.contains("predate"));
1291 assert!(msg.contains("fallow 2.0.0"));
1292 assert!(
1293 msg.contains("fallow dead-code --save-regression-baseline /repo/.fallow-baseline.json")
1294 );
1295 }
1296
1297 #[test]
1298 fn format_missing_schema_version_error_includes_regenerate_command() {
1299 let msg = format_missing_schema_version_error(Path::new("/repo/baseline.json"));
1300 assert!(msg.contains("missing the schema_version field"));
1301 assert!(msg.contains("fallow dead-code --save-regression-baseline /repo/baseline.json"));
1302 }
1303
1304 #[test]
1305 fn save_load_preserves_schema_version() {
1306 let dir = tempfile::tempdir().unwrap();
1307 let path = dir.path().join("baseline.json");
1308 let counts = CheckCounts {
1309 total_issues: 1,
1310 unused_files: 1,
1311 ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
1312 };
1313 save_regression_baseline(&path, dir.path(), Some(&counts), None, OutputFormat::Human)
1314 .unwrap();
1315 let loaded = load_regression_baseline(&path, OutputFormat::Human).unwrap();
1316 assert_eq!(loaded.schema_version, REGRESSION_SCHEMA_VERSION);
1317 }
1318}