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