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