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