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