Skip to main content

fallow_cli/regression/
baseline.rs

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// ── Public API ──────────────────────────────────────────────────
11
12/// Where to save the regression baseline.
13#[derive(Clone, Copy)]
14pub enum SaveRegressionTarget<'a> {
15    /// Don't save.
16    None,
17    /// Save into the config file (.fallowrc.json / fallow.toml).
18    Config,
19    /// Save to an explicit file path.
20    File(&'a Path),
21}
22
23/// Options for regression detection.
24#[derive(Clone, Copy)]
25pub struct RegressionOpts<'a> {
26    pub fail_on_regression: bool,
27    pub tolerance: Tolerance,
28    /// Explicit regression baseline file path (overrides config).
29    pub regression_baseline_file: Option<&'a Path>,
30    /// Where to save the regression baseline.
31    pub save_target: SaveRegressionTarget<'a>,
32    /// Whether --changed-since or --workspace is active (makes counts incomparable).
33    pub scoped: bool,
34    pub quiet: bool,
35}
36
37/// Check whether a path is likely gitignored by running `git check-ignore`.
38/// Returns `false` if git is unavailable or the check fails (conservative).
39fn 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
49/// Get the current git SHA, if available.
50fn 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
60/// Save the current analysis results as a regression baseline.
61///
62/// # Errors
63///
64/// Returns an error if the baseline cannot be serialized or written to disk.
65pub 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    // Ensure parent directory exists
84    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    // Always print save confirmation — this is a side effect the user must verify,
92    // not progress noise that --quiet should suppress.
93    eprintln!("Regression baseline saved to {}", path.display());
94    // Warn if the saved path appears to be gitignored
95    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
104/// Save regression baseline counts into the project's config file.
105///
106/// Reads the existing config, adds/updates the `regression.baseline` section,
107/// and writes it back. For JSONC files, comments are preserved using a targeted
108/// insertion/replacement strategy.
109///
110/// # Errors
111///
112/// Returns an error if the config file cannot be read, updated, or written back.
113pub fn save_baseline_to_config(config_path: &Path, counts: &CheckCounts) -> Result<(), ExitCode> {
114    // If the config file doesn't exist yet, create a minimal one
115    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
165/// Update a JSONC config file with regression baseline, preserving comments.
166/// Find a JSON key in content, skipping `//` line comments and `/* */` block comments.
167/// Returns the byte offset of the opening `"` of the key.
168fn 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        // Check if this match is inside a // comment line
174        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        // Check if inside a /* */ block comment
181        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    // Indent the baseline JSON by 4 spaces (nested inside "regression": { "baseline": ... })
203    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    // Check if "regression" key already exists — replace it.
218    // Only match "regression" that appears as a JSON key (preceded by whitespace or line start),
219    // not inside comments or string values.
220    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(&regression_block[2..]); // skip leading "  " — reuse original indent
247            result.push_str(&content[end..]);
248            return Ok(result);
249        }
250    }
251
252    // No existing regression key — insert before the last `}`
253    if let Some(last_brace) = content.rfind('}') {
254        // Find the last non-whitespace character before the closing brace
255        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(&regression_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
273/// Update a TOML config file with regression baseline.
274fn update_toml_regression(content: &str, baseline: &fallow_config::RegressionBaseline) -> String {
275    use std::fmt::Write;
276    // Build the TOML section
277    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    // Check if [regression.baseline] already exists — replace it
335    if let Some(start) = content.find("[regression.baseline]") {
336        // Find the next section header or end of file
337        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(&section);
345        if end_offset < content.len() {
346            result.push_str(&content[end_offset..]);
347        }
348        result
349    } else {
350        // Append the section
351        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(&section);
357        result
358    }
359}
360
361/// Load a regression baseline from disk.
362///
363/// # Errors
364///
365/// Returns an error if the file does not exist, cannot be read, or contains invalid JSON.
366pub 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
391/// Compare current check results against a regression baseline.
392///
393/// Resolution order for the baseline:
394/// 1. Explicit file via `--regression-baseline <PATH>`
395/// 2. Config-embedded `regression.baseline` section
396/// 3. Error with actionable message
397///
398/// # Errors
399///
400/// Returns an error if the baseline file cannot be loaded, is missing check data,
401/// or no baseline source is available.
402pub 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    // Skip if results are scoped (counts not comparable to full-project baseline)
412    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    // Resolution order: explicit file > config section > error
422    let baseline_counts: CheckCounts = if let Some(baseline_path) = opts.regression_baseline_file {
423        // Explicit --regression-baseline <PATH>: load from file
424        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        // Config-embedded baseline: read from .fallowrc.json / fallow.toml
435        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(&current_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
466/// ISO 8601 UTC timestamp without external dependencies.
467fn 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    // Manual UTC decomposition — avoids chrono dependency
473    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    // Days since epoch to Y-M-D (civil date algorithm)
479    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    // ── update_json_regression ──────────────────────────────────────
499
500    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        // Should be valid JSON
514        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        // Old value replaced
541        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        // Should insert new regression, not try to replace the comment
551        assert!(result.contains("\"regression\":"));
552        assert!(result.contains("\"entry\""));
553    }
554
555    #[test]
556    fn json_malformed_brace_returns_error() {
557        // regression key exists but no matching closing brace
558        let config = r#"{ "regression": { "baseline": { "totalIssues": 1 }"#;
559        let result = update_json_regression(config, &sample_baseline());
560        assert!(result.is_err());
561    }
562
563    // ── update_toml_regression ──────────────────────────────────────
564
565    #[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    // ── find_json_key ───────────────────────────────────────────────
592
593    #[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        // Should find the real key, not the one inside /* */
614        let pos = find_json_key(content, "foo").unwrap();
615        assert!(content[pos..].starts_with("\"foo\": 1"));
616    }
617
618    // ── chrono_now ─────────────────────────────────────────────────
619
620    #[test]
621    fn chrono_now_format() {
622        let ts = chrono_now();
623        // Should be ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ
624        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    // ── save/load roundtrip ────────────────────────────────────────
634
635    #[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    // ── save_baseline_to_config ────────────────────────────────────
727
728    #[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        // Should still be valid JSON
746        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        // File doesn't exist — should create it from scratch
774
775        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    // ── update_json_regression edge cases ──────────────────────────
804
805    #[test]
806    fn json_insert_with_trailing_comma() {
807        let config = r#"{
808  "entry": ["src/main.ts"],
809}"#;
810        // Trailing comma — our insertion should still produce reasonable output
811        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    // ── update_toml_regression edge cases ──────────────────────────
840
841    #[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    // ── find_json_key edge cases ────────────────────────────────────
870
871    #[test]
872    fn find_json_key_multiple_same_keys() {
873        // Returns the first occurrence
874        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    // ── compare_check_regression ────────────────────────────────────
887
888    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(); // 0 issues
931        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        // Current has fewer issues than baseline
995        let results = AnalysisResults::default(); // 0 issues
996        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        // Save a baseline to file
1022        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        // Compare with empty results -> pass (improvement)
1030        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 a baseline with no check data (dupes only)
1042        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        // Add 1 issue
1106        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        // baseline=10, 50% of 10 = 5, delta=1-10=-9 (improvement, should pass)
1112        // Wait, total_issues in config is the baseline for comparison.
1113        // results has 1 issue, baseline has 10 -> improvement -> pass
1114        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}