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