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