Skip to main content

fallow_cli/regression/
baseline.rs

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
13/// Number of seconds in one day.
14const SECS_PER_DAY: u64 = 86_400;
15
16// ── Public API ──────────────────────────────────────────────────
17
18/// Where to save the regression baseline.
19#[derive(Clone, Copy)]
20pub enum SaveRegressionTarget<'a> {
21    /// Don't save.
22    None,
23    /// Save into the config file (.fallowrc.json / fallow.toml).
24    Config,
25    /// Save to an explicit file path.
26    File(&'a Path),
27}
28
29/// Options for regression detection.
30#[derive(Clone, Copy)]
31pub struct RegressionOpts<'a> {
32    pub fail_on_regression: bool,
33    pub tolerance: Tolerance,
34    /// Explicit regression baseline file path (overrides config).
35    pub regression_baseline_file: Option<&'a Path>,
36    /// Where to save the regression baseline.
37    pub save_target: SaveRegressionTarget<'a>,
38    /// Whether --changed-since or --workspace is active (makes counts incomparable).
39    pub scoped: bool,
40    pub quiet: bool,
41}
42
43/// Check whether a path is likely gitignored by running `git check-ignore`.
44/// Returns `false` if git is unavailable or the check fails (conservative).
45fn 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
55/// Get the current git SHA, if available.
56fn 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
66/// Save the current analysis results as a regression baseline.
67///
68/// # Errors
69///
70/// Returns an error if the baseline cannot be serialized or written to disk.
71pub 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    // Ensure parent directory exists
94    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    // Always print save confirmation — this is a side effect the user must verify,
105    // not progress noise that --quiet should suppress.
106    eprintln!("Regression baseline saved to {}", path.display());
107    // Warn if the saved path appears to be gitignored
108    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
117/// Save regression baseline counts into the project's config file.
118///
119/// Reads the existing config, adds/updates the `regression.baseline` section,
120/// and writes it back. For JSONC files, comments are preserved using a targeted
121/// insertion/replacement strategy.
122///
123/// # Errors
124///
125/// Returns an error if the config file cannot be read, updated, or written back.
126pub fn save_baseline_to_config(
127    config_path: &Path,
128    counts: &CheckCounts,
129    output: OutputFormat,
130) -> Result<(), ExitCode> {
131    // If the config file doesn't exist yet, create a minimal one
132    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
191/// Update a JSONC config file with regression baseline, preserving comments.
192/// Find a JSON key in content, skipping `//` line comments and `/* */` block comments.
193/// Returns the byte offset of the opening `"` of the key.
194fn 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        // Check if this match is inside a // comment line
200        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        // Check if inside a /* */ block comment
207        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    // Indent the baseline JSON by 4 spaces (nested inside "regression": { "baseline": ... })
229    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    // Check if "regression" key already exists — replace it.
244    // Only match "regression" that appears as a JSON key (preceded by whitespace or line start),
245    // not inside comments or string values.
246    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(&regression_block[2..]); // skip leading "  " — reuse original indent
273            result.push_str(&content[end..]);
274            return Ok(result);
275        }
276    }
277
278    // No existing regression key — insert before the last `}`
279    if let Some(last_brace) = content.rfind('}') {
280        // Find the last non-whitespace character before the closing brace
281        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(&regression_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
299/// Update a TOML config file with regression baseline.
300fn update_toml_regression(content: &str, baseline: &fallow_config::RegressionBaseline) -> String {
301    use std::fmt::Write;
302    // Build the TOML section
303    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    // Check if [regression.baseline] already exists — replace it
361    if let Some(start) = content.find("[regression.baseline]") {
362        // Find the next section header or end of file
363        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(&section);
371        if end_offset < content.len() {
372            result.push_str(&content[end_offset..]);
373        }
374        result
375    } else {
376        // Append the section
377        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(&section);
383        result
384    }
385}
386
387/// Load a regression baseline from disk.
388///
389/// # Errors
390///
391/// Returns an error if the file does not exist, cannot be read, or contains invalid JSON.
392pub 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
417/// Compare current check results against a regression baseline.
418///
419/// Resolution order for the baseline:
420/// 1. Explicit file via `--regression-baseline <PATH>`
421/// 2. Config-embedded `regression.baseline` section
422/// 3. Error with actionable message
423///
424/// # Errors
425///
426/// Returns an error if the baseline file cannot be loaded, is missing check data,
427/// or no baseline source is available.
428pub 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    // Skip if results are scoped (counts not comparable to full-project baseline)
438    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    // Resolution order: explicit file > config section > error
448    let baseline_counts: CheckCounts = if let Some(baseline_path) = opts.regression_baseline_file {
449        // Explicit --regression-baseline <PATH>: load from file
450        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        // Config-embedded baseline: read from .fallowrc.json / fallow.toml
461        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(&current_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
492/// ISO 8601 UTC timestamp without external dependencies.
493fn 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    // Manual UTC decomposition — avoids chrono dependency
499    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    // Days since epoch to Y-M-D (civil date algorithm)
505    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    // ── update_json_regression ──────────────────────────────────────
525
526    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        // Should be valid JSON
540        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        // Old value replaced
567        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        // Should insert new regression, not try to replace the comment
577        assert!(result.contains("\"regression\":"));
578        assert!(result.contains("\"entry\""));
579    }
580
581    #[test]
582    fn json_malformed_brace_returns_error() {
583        // regression key exists but no matching closing brace
584        let config = r#"{ "regression": { "baseline": { "totalIssues": 1 }"#;
585        let result = update_json_regression(config, &sample_baseline());
586        assert!(result.is_err());
587    }
588
589    // ── update_toml_regression ──────────────────────────────────────
590
591    #[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    // ── find_json_key ───────────────────────────────────────────────
618
619    #[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        // Should find the real key, not the one inside /* */
640        let pos = find_json_key(content, "foo").unwrap();
641        assert!(content[pos..].starts_with("\"foo\": 1"));
642    }
643
644    // ── chrono_now ─────────────────────────────────────────────────
645
646    #[test]
647    fn chrono_now_format() {
648        let ts = chrono_now();
649        // Should be ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ
650        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    // ── save/load roundtrip ────────────────────────────────────────
660
661    #[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    // ── save_baseline_to_config ────────────────────────────────────
762
763    #[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        // Should still be valid JSON
781        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        // File doesn't exist — should create it from scratch
809
810        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    // ── update_json_regression edge cases ──────────────────────────
839
840    #[test]
841    fn json_insert_with_trailing_comma() {
842        let config = r#"{
843  "entry": ["src/main.ts"],
844}"#;
845        // Trailing comma — our insertion should still produce reasonable output
846        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    // ── update_toml_regression edge cases ──────────────────────────
875
876    #[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    // ── find_json_key edge cases ────────────────────────────────────
905
906    #[test]
907    fn find_json_key_multiple_same_keys() {
908        // Returns the first occurrence
909        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    // ── compare_check_regression ────────────────────────────────────
922
923    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(); // 0 issues
966        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        // Current has fewer issues than baseline
1030        let results = AnalysisResults::default(); // 0 issues
1031        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        // Save a baseline to file
1057        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        // Compare with empty results -> pass (improvement)
1072        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 a baseline with no check data (dupes only)
1084        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        // Add 1 issue
1149        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        // baseline=10, 50% of 10 = 5, delta=1-10=-9 (improvement, should pass)
1155        // Wait, total_issues in config is the baseline for comparison.
1156        // results has 1 issue, baseline has 10 -> improvement -> pass
1157        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}