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