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