Skip to main content

fallow_cli/regression/
baseline.rs

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