Skip to main content

fallow_cli/
regression.rs

1use std::path::Path;
2use std::process::ExitCode;
3
4use fallow_core::results::AnalysisResults;
5
6// ── Tolerance ───────────────────────────────────────────────────
7
8/// How much increase is allowed before a regression is flagged.
9#[derive(Debug, Clone, Copy)]
10pub enum Tolerance {
11    /// Percentage increase relative to the baseline total (e.g., 2.0 means 2%).
12    Percentage(f64),
13    /// Absolute increase in issue count.
14    Absolute(usize),
15}
16
17impl Tolerance {
18    /// Parse a tolerance string: `"2%"` for percentage, `"5"` for absolute.
19    /// Default when no value is given: `Absolute(0)` (zero tolerance).
20    ///
21    /// # Errors
22    ///
23    /// Returns an error if the string is not a valid number or percentage,
24    /// or if a percentage value is negative.
25    pub fn parse(s: &str) -> Result<Self, String> {
26        let s = s.trim();
27        if s.is_empty() {
28            return Ok(Self::Absolute(0));
29        }
30        if let Some(pct_str) = s.strip_suffix('%') {
31            let pct: f64 = pct_str
32                .trim()
33                .parse()
34                .map_err(|_| format!("invalid tolerance percentage: {s}"))?;
35            if pct < 0.0 {
36                return Err(format!("tolerance percentage must be non-negative: {s}"));
37            }
38            Ok(Self::Percentage(pct))
39        } else {
40            let abs: usize = s
41                .parse()
42                .map_err(|_| format!("invalid tolerance value: {s} (use a number or N%)"))?;
43            Ok(Self::Absolute(abs))
44        }
45    }
46
47    /// Check whether the delta exceeds this tolerance.
48    #[expect(
49        clippy::cast_possible_truncation,
50        reason = "percentage of a count is bounded by the count itself"
51    )]
52    fn exceeded(&self, baseline_total: usize, current_total: usize) -> bool {
53        if current_total <= baseline_total {
54            return false;
55        }
56        let delta = current_total - baseline_total;
57        match *self {
58            Self::Percentage(pct) => {
59                if baseline_total == 0 {
60                    // Any increase from zero is a regression when pct tolerance is used
61                    return delta > 0;
62                }
63                let allowed = (baseline_total as f64 * pct / 100.0).floor() as usize;
64                delta > allowed
65            }
66            Self::Absolute(abs) => delta > abs,
67        }
68    }
69}
70
71// ── Regression baseline ─────────────────────────────────────────
72
73/// Regression baseline: stores issue counts per type for comparison.
74///
75/// Unlike `BaselineData` which stores individual issue identities for suppression,
76/// this stores counts for "did the total go up?" regression detection.
77#[derive(Debug, serde::Serialize, serde::Deserialize)]
78pub struct RegressionBaseline {
79    /// Schema version for forward compatibility.
80    pub schema_version: u32,
81    /// Fallow version that produced this baseline.
82    pub fallow_version: String,
83    /// ISO 8601 timestamp.
84    pub timestamp: String,
85    /// Git SHA at baseline time, if available.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub git_sha: Option<String>,
88    /// Dead code issue counts.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub check: Option<CheckCounts>,
91    /// Duplication counts.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub dupes: Option<DupesCounts>,
94}
95
96const REGRESSION_SCHEMA_VERSION: u32 = 1;
97
98/// Per-type issue counts for dead code analysis.
99///
100/// All fields use `#[serde(default)]` for forward compatibility: when fallow adds a new
101/// issue type, old baselines will deserialize with the new field defaulting to zero
102/// instead of failing.
103#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
104pub struct CheckCounts {
105    #[serde(default)]
106    pub total_issues: usize,
107    #[serde(default)]
108    pub unused_files: usize,
109    #[serde(default)]
110    pub unused_exports: usize,
111    #[serde(default)]
112    pub unused_types: usize,
113    #[serde(default)]
114    pub unused_dependencies: usize,
115    #[serde(default)]
116    pub unused_dev_dependencies: usize,
117    #[serde(default)]
118    pub unused_optional_dependencies: usize,
119    #[serde(default)]
120    pub unused_enum_members: usize,
121    #[serde(default)]
122    pub unused_class_members: usize,
123    #[serde(default)]
124    pub unresolved_imports: usize,
125    #[serde(default)]
126    pub unlisted_dependencies: usize,
127    #[serde(default)]
128    pub duplicate_exports: usize,
129    #[serde(default)]
130    pub circular_dependencies: usize,
131    #[serde(default)]
132    pub type_only_dependencies: usize,
133    #[serde(default)]
134    pub test_only_dependencies: usize,
135}
136
137impl CheckCounts {
138    #[must_use]
139    pub const fn from_results(results: &AnalysisResults) -> Self {
140        Self {
141            total_issues: results.total_issues(),
142            unused_files: results.unused_files.len(),
143            unused_exports: results.unused_exports.len(),
144            unused_types: results.unused_types.len(),
145            unused_dependencies: results.unused_dependencies.len(),
146            unused_dev_dependencies: results.unused_dev_dependencies.len(),
147            unused_optional_dependencies: results.unused_optional_dependencies.len(),
148            unused_enum_members: results.unused_enum_members.len(),
149            unused_class_members: results.unused_class_members.len(),
150            unresolved_imports: results.unresolved_imports.len(),
151            unlisted_dependencies: results.unlisted_dependencies.len(),
152            duplicate_exports: results.duplicate_exports.len(),
153            circular_dependencies: results.circular_dependencies.len(),
154            type_only_dependencies: results.type_only_dependencies.len(),
155            test_only_dependencies: results.test_only_dependencies.len(),
156        }
157    }
158
159    /// Convert from config-embedded baseline.
160    #[must_use]
161    pub const fn from_config_baseline(b: &fallow_config::RegressionBaseline) -> Self {
162        Self {
163            total_issues: b.total_issues,
164            unused_files: b.unused_files,
165            unused_exports: b.unused_exports,
166            unused_types: b.unused_types,
167            unused_dependencies: b.unused_dependencies,
168            unused_dev_dependencies: b.unused_dev_dependencies,
169            unused_optional_dependencies: b.unused_optional_dependencies,
170            unused_enum_members: b.unused_enum_members,
171            unused_class_members: b.unused_class_members,
172            unresolved_imports: b.unresolved_imports,
173            unlisted_dependencies: b.unlisted_dependencies,
174            duplicate_exports: b.duplicate_exports,
175            circular_dependencies: b.circular_dependencies,
176            type_only_dependencies: b.type_only_dependencies,
177            test_only_dependencies: b.test_only_dependencies,
178        }
179    }
180
181    /// Convert to config-embeddable baseline.
182    #[must_use]
183    pub const fn to_config_baseline(&self) -> fallow_config::RegressionBaseline {
184        fallow_config::RegressionBaseline {
185            total_issues: self.total_issues,
186            unused_files: self.unused_files,
187            unused_exports: self.unused_exports,
188            unused_types: self.unused_types,
189            unused_dependencies: self.unused_dependencies,
190            unused_dev_dependencies: self.unused_dev_dependencies,
191            unused_optional_dependencies: self.unused_optional_dependencies,
192            unused_enum_members: self.unused_enum_members,
193            unused_class_members: self.unused_class_members,
194            unresolved_imports: self.unresolved_imports,
195            unlisted_dependencies: self.unlisted_dependencies,
196            duplicate_exports: self.duplicate_exports,
197            circular_dependencies: self.circular_dependencies,
198            type_only_dependencies: self.type_only_dependencies,
199            test_only_dependencies: self.test_only_dependencies,
200        }
201    }
202
203    /// Per-type deltas (current - baseline) for display. Only includes types with changes.
204    fn deltas(&self, current: &Self) -> Vec<(&'static str, isize)> {
205        let pairs: Vec<(&str, usize, usize)> = vec![
206            ("unused_files", self.unused_files, current.unused_files),
207            (
208                "unused_exports",
209                self.unused_exports,
210                current.unused_exports,
211            ),
212            ("unused_types", self.unused_types, current.unused_types),
213            (
214                "unused_dependencies",
215                self.unused_dependencies,
216                current.unused_dependencies,
217            ),
218            (
219                "unused_dev_dependencies",
220                self.unused_dev_dependencies,
221                current.unused_dev_dependencies,
222            ),
223            (
224                "unused_optional_dependencies",
225                self.unused_optional_dependencies,
226                current.unused_optional_dependencies,
227            ),
228            (
229                "unused_enum_members",
230                self.unused_enum_members,
231                current.unused_enum_members,
232            ),
233            (
234                "unused_class_members",
235                self.unused_class_members,
236                current.unused_class_members,
237            ),
238            (
239                "unresolved_imports",
240                self.unresolved_imports,
241                current.unresolved_imports,
242            ),
243            (
244                "unlisted_dependencies",
245                self.unlisted_dependencies,
246                current.unlisted_dependencies,
247            ),
248            (
249                "duplicate_exports",
250                self.duplicate_exports,
251                current.duplicate_exports,
252            ),
253            (
254                "circular_dependencies",
255                self.circular_dependencies,
256                current.circular_dependencies,
257            ),
258            (
259                "type_only_dependencies",
260                self.type_only_dependencies,
261                current.type_only_dependencies,
262            ),
263            (
264                "test_only_dependencies",
265                self.test_only_dependencies,
266                current.test_only_dependencies,
267            ),
268        ];
269        pairs
270            .into_iter()
271            .filter_map(|(name, baseline, current)| {
272                let delta = current as isize - baseline as isize;
273                if delta != 0 {
274                    Some((name, delta))
275                } else {
276                    None
277                }
278            })
279            .collect()
280    }
281}
282
283/// Duplication counts for regression baseline.
284#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
285pub struct DupesCounts {
286    #[serde(default)]
287    pub clone_groups: usize,
288    #[serde(default)]
289    pub duplication_percentage: f64,
290}
291
292// ── Regression outcome ──────────────────────────────────────────
293
294/// Result of a regression check.
295#[derive(Debug)]
296pub enum RegressionOutcome {
297    /// No regression — current issues are within tolerance.
298    Pass {
299        baseline_total: usize,
300        current_total: usize,
301    },
302    /// Regression exceeded tolerance.
303    Exceeded {
304        baseline_total: usize,
305        current_total: usize,
306        tolerance: Tolerance,
307        /// Per-type deltas for human output.
308        type_deltas: Vec<(&'static str, isize)>,
309    },
310    /// Regression check was skipped (e.g., --changed-since active).
311    Skipped { reason: &'static str },
312}
313
314impl RegressionOutcome {
315    /// Whether this outcome should cause a non-zero exit code.
316    #[must_use]
317    pub const fn is_failure(&self) -> bool {
318        matches!(self, Self::Exceeded { .. })
319    }
320
321    /// Build a JSON value for the regression outcome (added to JSON output envelope).
322    #[must_use]
323    pub fn to_json(&self) -> serde_json::Value {
324        match self {
325            Self::Pass {
326                baseline_total,
327                current_total,
328            } => serde_json::json!({
329                "status": "pass",
330                "baseline_total": baseline_total,
331                "current_total": current_total,
332                "delta": *current_total as isize - *baseline_total as isize,
333                "exceeded": false,
334            }),
335            Self::Exceeded {
336                baseline_total,
337                current_total,
338                tolerance,
339                ..
340            } => {
341                let (tolerance_value, tolerance_kind) = match tolerance {
342                    Tolerance::Percentage(pct) => (*pct, "percentage"),
343                    Tolerance::Absolute(abs) => (*abs as f64, "absolute"),
344                };
345                serde_json::json!({
346                    "status": "exceeded",
347                    "baseline_total": baseline_total,
348                    "current_total": current_total,
349                    "delta": *current_total as isize - *baseline_total as isize,
350                    "tolerance": tolerance_value,
351                    "tolerance_kind": tolerance_kind,
352                    "exceeded": true,
353                })
354            }
355            Self::Skipped { reason } => serde_json::json!({
356                "status": "skipped",
357                "reason": reason,
358                "exceeded": false,
359            }),
360        }
361    }
362}
363
364// ── Public API ──────────────────────────────────────────────────
365
366/// Where to save the regression baseline.
367#[derive(Clone, Copy)]
368pub enum SaveRegressionTarget<'a> {
369    /// Don't save.
370    None,
371    /// Save into the config file (.fallowrc.json / fallow.toml).
372    Config,
373    /// Save to an explicit file path.
374    File(&'a Path),
375}
376
377/// Options for regression detection.
378#[derive(Clone, Copy)]
379pub struct RegressionOpts<'a> {
380    pub fail_on_regression: bool,
381    pub tolerance: Tolerance,
382    /// Explicit regression baseline file path (overrides config).
383    pub regression_baseline_file: Option<&'a Path>,
384    /// Where to save the regression baseline.
385    pub save_target: SaveRegressionTarget<'a>,
386    /// Whether --changed-since or --workspace is active (makes counts incomparable).
387    pub scoped: bool,
388    pub quiet: bool,
389}
390
391/// Check whether a path is likely gitignored by running `git check-ignore`.
392/// Returns `false` if git is unavailable or the check fails (conservative).
393fn is_likely_gitignored(path: &Path, root: &Path) -> bool {
394    std::process::Command::new("git")
395        .args(["check-ignore", "-q"])
396        .arg(path)
397        .current_dir(root)
398        .output()
399        .ok()
400        .is_some_and(|o| o.status.success())
401}
402
403/// Get the current git SHA, if available.
404fn current_git_sha(root: &Path) -> Option<String> {
405    std::process::Command::new("git")
406        .args(["rev-parse", "HEAD"])
407        .current_dir(root)
408        .output()
409        .ok()
410        .filter(|o| o.status.success())
411        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
412}
413
414/// Save the current analysis results as a regression baseline.
415///
416/// # Errors
417///
418/// Returns an error if the baseline cannot be serialized or written to disk.
419pub fn save_regression_baseline(
420    path: &Path,
421    root: &Path,
422    check_counts: Option<&CheckCounts>,
423    dupes_counts: Option<&DupesCounts>,
424) -> Result<(), ExitCode> {
425    let baseline = RegressionBaseline {
426        schema_version: REGRESSION_SCHEMA_VERSION,
427        fallow_version: env!("CARGO_PKG_VERSION").to_string(),
428        timestamp: chrono_now(),
429        git_sha: current_git_sha(root),
430        check: check_counts.cloned(),
431        dupes: dupes_counts.cloned(),
432    };
433    let json = serde_json::to_string_pretty(&baseline).map_err(|e| {
434        eprintln!("Error: failed to serialize regression baseline: {e}");
435        ExitCode::from(2)
436    })?;
437    // Ensure parent directory exists
438    if let Some(parent) = path.parent() {
439        let _ = std::fs::create_dir_all(parent);
440    }
441    std::fs::write(path, json).map_err(|e| {
442        eprintln!("Error: failed to save regression baseline: {e}");
443        ExitCode::from(2)
444    })?;
445    // Always print save confirmation — this is a side effect the user must verify,
446    // not progress noise that --quiet should suppress.
447    eprintln!("Regression baseline saved to {}", path.display());
448    // Warn if the saved path appears to be gitignored
449    if is_likely_gitignored(path, root) {
450        eprintln!(
451            "Warning: '{}' may be gitignored. Commit this file so CI can compare against it.",
452            path.display()
453        );
454    }
455    Ok(())
456}
457
458/// Save regression baseline counts into the project's config file.
459///
460/// Reads the existing config, adds/updates the `regression.baseline` section,
461/// and writes it back. For JSONC files, comments are preserved using a targeted
462/// insertion/replacement strategy.
463///
464/// # Errors
465///
466/// Returns an error if the config file cannot be read, updated, or written back.
467pub fn save_baseline_to_config(config_path: &Path, counts: &CheckCounts) -> Result<(), ExitCode> {
468    // If the config file doesn't exist yet, create a minimal one
469    let content = match std::fs::read_to_string(config_path) {
470        Ok(c) => c,
471        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
472            let is_toml = config_path.extension().is_some_and(|ext| ext == "toml");
473            if is_toml {
474                String::new()
475            } else {
476                "{}".to_string()
477            }
478        }
479        Err(e) => {
480            eprintln!(
481                "Error: failed to read config file '{}': {e}",
482                config_path.display()
483            );
484            return Err(ExitCode::from(2));
485        }
486    };
487
488    let baseline = counts.to_config_baseline();
489    let is_toml = config_path.extension().is_some_and(|ext| ext == "toml");
490
491    let updated = if is_toml {
492        Ok(update_toml_regression(&content, &baseline))
493    } else {
494        update_json_regression(&content, &baseline)
495    }
496    .map_err(|e| {
497        eprintln!(
498            "Error: failed to update config file '{}': {e}",
499            config_path.display()
500        );
501        ExitCode::from(2)
502    })?;
503
504    std::fs::write(config_path, updated).map_err(|e| {
505        eprintln!(
506            "Error: failed to write config file '{}': {e}",
507            config_path.display()
508        );
509        ExitCode::from(2)
510    })?;
511
512    eprintln!(
513        "Regression baseline saved to {} (regression.baseline section)",
514        config_path.display()
515    );
516    Ok(())
517}
518
519/// Update a JSONC config file with regression baseline, preserving comments.
520/// Find a JSON key in content, skipping `//` line comments and `/* */` block comments.
521/// Returns the byte offset of the opening `"` of the key.
522fn find_json_key(content: &str, key: &str) -> Option<usize> {
523    let needle = format!("\"{key}\"");
524    let mut search_from = 0;
525    while let Some(pos) = content[search_from..].find(&needle) {
526        let abs_pos = search_from + pos;
527        // Check if this match is inside a // comment line
528        let line_start = content[..abs_pos].rfind('\n').map_or(0, |i| i + 1);
529        let line_prefix = content[line_start..abs_pos].trim_start();
530        if line_prefix.starts_with("//") {
531            search_from = abs_pos + needle.len();
532            continue;
533        }
534        // Check if inside a /* */ block comment
535        let before = &content[..abs_pos];
536        let last_open = before.rfind("/*");
537        let last_close = before.rfind("*/");
538        if let Some(open_pos) = last_open
539            && last_close.is_none_or(|close_pos| close_pos < open_pos)
540        {
541            search_from = abs_pos + needle.len();
542            continue;
543        }
544        return Some(abs_pos);
545    }
546    None
547}
548
549fn update_json_regression(
550    content: &str,
551    baseline: &fallow_config::RegressionBaseline,
552) -> Result<String, String> {
553    let baseline_json =
554        serde_json::to_string_pretty(baseline).map_err(|e| format!("serialization error: {e}"))?;
555
556    // Indent the baseline JSON by 4 spaces (nested inside "regression": { "baseline": ... })
557    let indented: String = baseline_json
558        .lines()
559        .enumerate()
560        .map(|(i, line)| {
561            if i == 0 {
562                format!("    {line}")
563            } else {
564                format!("\n    {line}")
565            }
566        })
567        .collect();
568
569    let regression_block = format!("  \"regression\": {{\n    \"baseline\": {indented}\n  }}");
570
571    // Check if "regression" key already exists — replace it.
572    // Only match "regression" that appears as a JSON key (preceded by whitespace or line start),
573    // not inside comments or string values.
574    if let Some(start) = find_json_key(content, "regression") {
575        let after_key = &content[start..];
576        if let Some(brace_start) = after_key.find('{') {
577            let abs_brace = start + brace_start;
578            let mut depth = 0;
579            let mut end = abs_brace;
580            let mut found_close = false;
581            for (i, ch) in content[abs_brace..].char_indices() {
582                match ch {
583                    '{' => depth += 1,
584                    '}' => {
585                        depth -= 1;
586                        if depth == 0 {
587                            end = abs_brace + i + 1;
588                            found_close = true;
589                            break;
590                        }
591                    }
592                    _ => {}
593                }
594            }
595            if !found_close {
596                return Err("malformed JSON: unmatched brace in regression object".to_string());
597            }
598            let mut result = String::new();
599            result.push_str(&content[..start]);
600            result.push_str(&regression_block[2..]); // skip leading "  " — reuse original indent
601            result.push_str(&content[end..]);
602            return Ok(result);
603        }
604    }
605
606    // No existing regression key — insert before the last `}`
607    if let Some(last_brace) = content.rfind('}') {
608        // Find the last non-whitespace character before the closing brace
609        let before_brace = content[..last_brace].trim_end();
610        let needs_comma = !before_brace.ends_with('{') && !before_brace.ends_with(',');
611
612        let mut result = String::new();
613        result.push_str(before_brace);
614        if needs_comma {
615            result.push(',');
616        }
617        result.push('\n');
618        result.push_str(&regression_block);
619        result.push('\n');
620        result.push_str(&content[last_brace..]);
621        Ok(result)
622    } else {
623        Err("config file has no closing brace".to_string())
624    }
625}
626
627/// Update a TOML config file with regression baseline.
628fn update_toml_regression(content: &str, baseline: &fallow_config::RegressionBaseline) -> String {
629    use std::fmt::Write;
630    // Build the TOML section
631    let mut section = String::from("[regression.baseline]\n");
632    let _ = writeln!(section, "totalIssues = {}", baseline.total_issues);
633    let _ = writeln!(section, "unusedFiles = {}", baseline.unused_files);
634    let _ = writeln!(section, "unusedExports = {}", baseline.unused_exports);
635    let _ = writeln!(section, "unusedTypes = {}", baseline.unused_types);
636    let _ = writeln!(
637        section,
638        "unusedDependencies = {}",
639        baseline.unused_dependencies
640    );
641    let _ = writeln!(
642        section,
643        "unusedDevDependencies = {}",
644        baseline.unused_dev_dependencies
645    );
646    let _ = writeln!(
647        section,
648        "unusedOptionalDependencies = {}",
649        baseline.unused_optional_dependencies
650    );
651    let _ = writeln!(
652        section,
653        "unusedEnumMembers = {}",
654        baseline.unused_enum_members
655    );
656    let _ = writeln!(
657        section,
658        "unusedClassMembers = {}",
659        baseline.unused_class_members
660    );
661    let _ = writeln!(
662        section,
663        "unresolvedImports = {}",
664        baseline.unresolved_imports
665    );
666    let _ = writeln!(
667        section,
668        "unlistedDependencies = {}",
669        baseline.unlisted_dependencies
670    );
671    let _ = writeln!(section, "duplicateExports = {}", baseline.duplicate_exports);
672    let _ = writeln!(
673        section,
674        "circularDependencies = {}",
675        baseline.circular_dependencies
676    );
677    let _ = writeln!(
678        section,
679        "typeOnlyDependencies = {}",
680        baseline.type_only_dependencies
681    );
682    let _ = writeln!(
683        section,
684        "testOnlyDependencies = {}",
685        baseline.test_only_dependencies
686    );
687
688    // Check if [regression.baseline] already exists — replace it
689    if let Some(start) = content.find("[regression.baseline]") {
690        // Find the next section header or end of file
691        let after = &content[start + "[regression.baseline]".len()..];
692        let end_offset = after.find("\n[").map_or(content.len(), |i| {
693            start + "[regression.baseline]".len() + i + 1
694        });
695
696        let mut result = String::new();
697        result.push_str(&content[..start]);
698        result.push_str(&section);
699        if end_offset < content.len() {
700            result.push_str(&content[end_offset..]);
701        }
702        result
703    } else {
704        // Append the section
705        let mut result = content.to_string();
706        if !result.ends_with('\n') {
707            result.push('\n');
708        }
709        result.push('\n');
710        result.push_str(&section);
711        result
712    }
713}
714
715/// Load a regression baseline from disk.
716///
717/// # Errors
718///
719/// Returns an error if the file does not exist, cannot be read, or contains invalid JSON.
720pub fn load_regression_baseline(path: &Path) -> Result<RegressionBaseline, ExitCode> {
721    let content = std::fs::read_to_string(path).map_err(|e| {
722        if e.kind() == std::io::ErrorKind::NotFound {
723            eprintln!(
724                "Error: no regression baseline found at '{}'.\n\
725                 Run with --save-regression-baseline on your main branch to create one.",
726                path.display()
727            );
728        } else {
729            eprintln!(
730                "Error: failed to read regression baseline '{}': {e}",
731                path.display()
732            );
733        }
734        ExitCode::from(2)
735    })?;
736    serde_json::from_str(&content).map_err(|e| {
737        eprintln!(
738            "Error: failed to parse regression baseline '{}': {e}",
739            path.display()
740        );
741        ExitCode::from(2)
742    })
743}
744
745/// Compare current check results against a regression baseline.
746///
747/// Resolution order for the baseline:
748/// 1. Explicit file via `--regression-baseline <PATH>`
749/// 2. Config-embedded `regression.baseline` section
750/// 3. Error with actionable message
751///
752/// # Errors
753///
754/// Returns an error if the baseline file cannot be loaded, is missing check data,
755/// or no baseline source is available.
756pub fn compare_check_regression(
757    results: &AnalysisResults,
758    opts: &RegressionOpts<'_>,
759    config_baseline: Option<&fallow_config::RegressionBaseline>,
760) -> Result<Option<RegressionOutcome>, ExitCode> {
761    if !opts.fail_on_regression {
762        return Ok(None);
763    }
764
765    // Skip if results are scoped (counts not comparable to full-project baseline)
766    if opts.scoped {
767        let reason = "--changed-since or --workspace is active; regression check skipped \
768                      (counts not comparable to full-project baseline)";
769        if !opts.quiet {
770            eprintln!("Warning: {reason}");
771        }
772        return Ok(Some(RegressionOutcome::Skipped { reason }));
773    }
774
775    // Resolution order: explicit file > config section > error
776    let baseline_counts: CheckCounts = if let Some(baseline_path) = opts.regression_baseline_file {
777        // Explicit --regression-baseline <PATH>: load from file
778        let baseline = load_regression_baseline(baseline_path)?;
779        let Some(counts) = baseline.check else {
780            eprintln!(
781                "Error: regression baseline '{}' has no check data",
782                baseline_path.display()
783            );
784            return Err(ExitCode::from(2));
785        };
786        counts
787    } else if let Some(config_baseline) = config_baseline {
788        // Config-embedded baseline: read from .fallowrc.json / fallow.toml
789        CheckCounts::from_config_baseline(config_baseline)
790    } else {
791        eprintln!(
792            "Error: no regression baseline found.\n\
793             Either add a `regression.baseline` section to your config file\n\
794             (run with --save-regression-baseline to generate it),\n\
795             or provide an explicit file via --regression-baseline <PATH>."
796        );
797        return Err(ExitCode::from(2));
798    };
799
800    let current_total = results.total_issues();
801    let baseline_total = baseline_counts.total_issues;
802
803    if opts.tolerance.exceeded(baseline_total, current_total) {
804        let current_counts = CheckCounts::from_results(results);
805        let type_deltas = baseline_counts.deltas(&current_counts);
806        Ok(Some(RegressionOutcome::Exceeded {
807            baseline_total,
808            current_total,
809            tolerance: opts.tolerance,
810            type_deltas,
811        }))
812    } else {
813        Ok(Some(RegressionOutcome::Pass {
814            baseline_total,
815            current_total,
816        }))
817    }
818}
819
820/// Print regression outcome to stderr (human-readable summary).
821pub fn print_regression_outcome(outcome: &RegressionOutcome) {
822    match outcome {
823        RegressionOutcome::Pass {
824            baseline_total,
825            current_total,
826        } => {
827            let delta = *current_total as isize - *baseline_total as isize;
828            let sign = if delta >= 0 { "+" } else { "" };
829            eprintln!(
830                "Regression check passed: {current_total} issues (baseline: {baseline_total}, \
831                 delta: {sign}{delta})"
832            );
833        }
834        RegressionOutcome::Exceeded {
835            baseline_total,
836            current_total,
837            tolerance,
838            type_deltas,
839        } => {
840            let delta = *current_total as isize - *baseline_total as isize;
841            let tol_str = match tolerance {
842                Tolerance::Percentage(pct) => format!("{pct}%"),
843                Tolerance::Absolute(abs) => format!("{abs}"),
844            };
845            eprintln!(
846                "Regression detected: {current_total} issues (baseline: {baseline_total}, \
847                 delta: +{delta}, tolerance: {tol_str})"
848            );
849            for (name, d) in type_deltas {
850                let sign = if *d > 0 { "+" } else { "" };
851                eprintln!("  {name}: {sign}{d}");
852            }
853        }
854        RegressionOutcome::Skipped { .. } => {
855            // Warning already printed in compare_* functions
856        }
857    }
858}
859
860/// ISO 8601 UTC timestamp without external dependencies.
861fn chrono_now() -> String {
862    let duration = std::time::SystemTime::now()
863        .duration_since(std::time::UNIX_EPOCH)
864        .unwrap_or_default();
865    let secs = duration.as_secs();
866    // Manual UTC decomposition — avoids chrono dependency
867    let days = secs / 86400;
868    let time_secs = secs % 86400;
869    let hours = time_secs / 3600;
870    let minutes = (time_secs % 3600) / 60;
871    let seconds = time_secs % 60;
872    // Days since epoch to Y-M-D (civil date algorithm)
873    let z = days + 719_468;
874    let era = z / 146_097;
875    let doe = z - era * 146_097;
876    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
877    let y = yoe + era * 400;
878    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
879    let mp = (5 * doy + 2) / 153;
880    let d = doy - (153 * mp + 2) / 5 + 1;
881    let m = if mp < 10 { mp + 3 } else { mp - 9 };
882    let y = if m <= 2 { y + 1 } else { y };
883    format!("{y:04}-{m:02}-{d:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
884}
885
886#[cfg(test)]
887mod tests {
888    use super::*;
889    use fallow_core::results::*;
890    use std::path::PathBuf;
891
892    // ── Tolerance parsing ───────────────────────────────────────────
893
894    #[test]
895    fn parse_percentage_tolerance() {
896        let t = Tolerance::parse("2%").unwrap();
897        assert!(matches!(t, Tolerance::Percentage(p) if (p - 2.0).abs() < f64::EPSILON));
898    }
899
900    #[test]
901    fn parse_absolute_tolerance() {
902        let t = Tolerance::parse("5").unwrap();
903        assert!(matches!(t, Tolerance::Absolute(5)));
904    }
905
906    #[test]
907    fn parse_zero_tolerance() {
908        let t = Tolerance::parse("0").unwrap();
909        assert!(matches!(t, Tolerance::Absolute(0)));
910    }
911
912    #[test]
913    fn parse_empty_defaults_to_zero() {
914        let t = Tolerance::parse("").unwrap();
915        assert!(matches!(t, Tolerance::Absolute(0)));
916    }
917
918    #[test]
919    fn parse_invalid_percentage() {
920        assert!(Tolerance::parse("abc%").is_err());
921    }
922
923    #[test]
924    fn parse_negative_percentage() {
925        assert!(Tolerance::parse("-1%").is_err());
926    }
927
928    #[test]
929    fn parse_invalid_absolute() {
930        assert!(Tolerance::parse("abc").is_err());
931    }
932
933    // ── Tolerance::exceeded ────────────────────────────────────────
934
935    #[test]
936    fn zero_tolerance_detects_any_increase() {
937        let t = Tolerance::Absolute(0);
938        assert!(t.exceeded(10, 11));
939        assert!(!t.exceeded(10, 10));
940        assert!(!t.exceeded(10, 9));
941    }
942
943    #[test]
944    fn absolute_tolerance_allows_within_range() {
945        let t = Tolerance::Absolute(3);
946        assert!(!t.exceeded(10, 12)); // delta=2, allowed=3
947        assert!(!t.exceeded(10, 13)); // delta=3, allowed=3
948        assert!(t.exceeded(10, 14)); // delta=4, allowed=3
949    }
950
951    #[test]
952    fn percentage_tolerance_allows_within_range() {
953        let t = Tolerance::Percentage(10.0);
954        assert!(!t.exceeded(100, 109)); // delta=9, allowed=floor(10)=10
955        assert!(!t.exceeded(100, 110)); // delta=10, allowed=10
956        assert!(t.exceeded(100, 111)); // delta=11, allowed=10
957    }
958
959    #[test]
960    fn percentage_tolerance_from_zero_baseline() {
961        let t = Tolerance::Percentage(10.0);
962        assert!(t.exceeded(0, 1)); // any increase from zero
963        assert!(!t.exceeded(0, 0)); // no increase
964    }
965
966    #[test]
967    fn decrease_never_exceeds() {
968        let t = Tolerance::Absolute(0);
969        assert!(!t.exceeded(10, 5));
970        let t = Tolerance::Percentage(0.0);
971        assert!(!t.exceeded(10, 5));
972    }
973
974    // ── CheckCounts::from_results ──────────────────────────────────
975
976    #[test]
977    fn check_counts_from_results() {
978        let mut results = AnalysisResults::default();
979        results.unused_files.push(UnusedFile {
980            path: PathBuf::from("a.ts"),
981        });
982        results.unused_exports.push(UnusedExport {
983            path: PathBuf::from("b.ts"),
984            export_name: "foo".into(),
985            is_type_only: false,
986            line: 1,
987            col: 0,
988            span_start: 0,
989            is_re_export: false,
990        });
991        let counts = CheckCounts::from_results(&results);
992        assert_eq!(counts.total_issues, 2);
993        assert_eq!(counts.unused_files, 1);
994        assert_eq!(counts.unused_exports, 1);
995        assert_eq!(counts.unused_types, 0);
996    }
997
998    // ── CheckCounts::deltas ────────────────────────────────────────
999
1000    #[test]
1001    fn deltas_reports_changes_only() {
1002        let baseline = CheckCounts {
1003            total_issues: 10,
1004            unused_files: 5,
1005            unused_exports: 3,
1006            unused_types: 2,
1007            unused_dependencies: 0,
1008            unused_dev_dependencies: 0,
1009            unused_optional_dependencies: 0,
1010            unused_enum_members: 0,
1011            unused_class_members: 0,
1012            unresolved_imports: 0,
1013            unlisted_dependencies: 0,
1014            duplicate_exports: 0,
1015            circular_dependencies: 0,
1016            type_only_dependencies: 0,
1017            test_only_dependencies: 0,
1018        };
1019        let current = CheckCounts {
1020            unused_files: 7,   // +2
1021            unused_exports: 1, // -2
1022            unused_types: 2,   // 0 (no change)
1023            ..baseline
1024        };
1025        let deltas = baseline.deltas(&current);
1026        assert_eq!(deltas.len(), 2);
1027        assert!(deltas.contains(&("unused_files", 2)));
1028        assert!(deltas.contains(&("unused_exports", -2)));
1029    }
1030
1031    // ── RegressionOutcome::to_json ──────────────────────────────────
1032
1033    #[test]
1034    fn pass_outcome_json() {
1035        let outcome = RegressionOutcome::Pass {
1036            baseline_total: 10,
1037            current_total: 10,
1038        };
1039        let json = outcome.to_json();
1040        assert_eq!(json["status"], "pass");
1041        assert_eq!(json["exceeded"], false);
1042        assert_eq!(json["delta"], 0);
1043    }
1044
1045    #[test]
1046    fn exceeded_outcome_json() {
1047        let outcome = RegressionOutcome::Exceeded {
1048            baseline_total: 10,
1049            current_total: 15,
1050            tolerance: Tolerance::Percentage(2.0),
1051            type_deltas: vec![("unused_files", 5)],
1052        };
1053        let json = outcome.to_json();
1054        assert_eq!(json["status"], "exceeded");
1055        assert_eq!(json["exceeded"], true);
1056        assert_eq!(json["delta"], 5);
1057        assert_eq!(json["tolerance_kind"], "percentage");
1058    }
1059
1060    #[test]
1061    fn skipped_outcome_json() {
1062        let outcome = RegressionOutcome::Skipped {
1063            reason: "test reason",
1064        };
1065        let json = outcome.to_json();
1066        assert_eq!(json["status"], "skipped");
1067        assert_eq!(json["exceeded"], false);
1068    }
1069
1070    // ── Regression baseline serialization roundtrip ────────────────
1071
1072    #[test]
1073    fn regression_baseline_roundtrip() {
1074        let baseline = RegressionBaseline {
1075            schema_version: 1,
1076            fallow_version: "2.4.0".into(),
1077            timestamp: "2026-03-27T10:00:00Z".into(),
1078            git_sha: Some("abc123".into()),
1079            check: Some(CheckCounts {
1080                total_issues: 42,
1081                unused_files: 5,
1082                unused_exports: 20,
1083                unused_types: 8,
1084                unused_dependencies: 3,
1085                unused_dev_dependencies: 2,
1086                unused_optional_dependencies: 0,
1087                unused_enum_members: 1,
1088                unused_class_members: 1,
1089                unresolved_imports: 0,
1090                unlisted_dependencies: 1,
1091                duplicate_exports: 0,
1092                circular_dependencies: 1,
1093                type_only_dependencies: 0,
1094                test_only_dependencies: 0,
1095            }),
1096            dupes: Some(DupesCounts {
1097                clone_groups: 12,
1098                duplication_percentage: 4.2,
1099            }),
1100        };
1101        let json = serde_json::to_string_pretty(&baseline).unwrap();
1102        let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
1103        assert_eq!(loaded.schema_version, 1);
1104        assert_eq!(loaded.check.as_ref().unwrap().total_issues, 42);
1105        assert_eq!(loaded.dupes.as_ref().unwrap().clone_groups, 12);
1106    }
1107
1108    // ── Tolerance display in regression messages ────────────────────
1109
1110    #[test]
1111    fn regression_outcome_is_failure() {
1112        let pass = RegressionOutcome::Pass {
1113            baseline_total: 10,
1114            current_total: 10,
1115        };
1116        assert!(!pass.is_failure());
1117
1118        let exceeded = RegressionOutcome::Exceeded {
1119            baseline_total: 10,
1120            current_total: 15,
1121            tolerance: Tolerance::Absolute(2),
1122            type_deltas: vec![],
1123        };
1124        assert!(exceeded.is_failure());
1125
1126        let skipped = RegressionOutcome::Skipped { reason: "test" };
1127        assert!(!skipped.is_failure());
1128    }
1129
1130    // ── update_json_regression ──────────────────────────────────────
1131
1132    fn sample_baseline() -> fallow_config::RegressionBaseline {
1133        fallow_config::RegressionBaseline {
1134            total_issues: 5,
1135            unused_files: 2,
1136            ..Default::default()
1137        }
1138    }
1139
1140    #[test]
1141    fn json_insert_into_empty_object() {
1142        let result = update_json_regression("{}", &sample_baseline()).unwrap();
1143        assert!(result.contains("\"regression\""));
1144        assert!(result.contains("\"totalIssues\": 5"));
1145        // Should be valid JSON
1146        serde_json::from_str::<serde_json::Value>(&result).unwrap();
1147    }
1148
1149    #[test]
1150    fn json_insert_into_existing_config() {
1151        let config = r#"{
1152  "entry": ["src/main.ts"],
1153  "production": true
1154}"#;
1155        let result = update_json_regression(config, &sample_baseline()).unwrap();
1156        assert!(result.contains("\"regression\""));
1157        assert!(result.contains("\"entry\""));
1158        serde_json::from_str::<serde_json::Value>(&result).unwrap();
1159    }
1160
1161    #[test]
1162    fn json_replace_existing_regression() {
1163        let config = r#"{
1164  "entry": ["src/main.ts"],
1165  "regression": {
1166    "baseline": {
1167      "totalIssues": 99
1168    }
1169  }
1170}"#;
1171        let result = update_json_regression(config, &sample_baseline()).unwrap();
1172        // Old value replaced
1173        assert!(!result.contains("99"));
1174        assert!(result.contains("\"totalIssues\": 5"));
1175        serde_json::from_str::<serde_json::Value>(&result).unwrap();
1176    }
1177
1178    #[test]
1179    fn json_skips_regression_in_comment() {
1180        let config = "{\n  // See \"regression\" docs\n  \"entry\": []\n}";
1181        let result = update_json_regression(config, &sample_baseline()).unwrap();
1182        // Should insert new regression, not try to replace the comment
1183        assert!(result.contains("\"regression\":"));
1184        assert!(result.contains("\"entry\""));
1185    }
1186
1187    #[test]
1188    fn json_malformed_brace_returns_error() {
1189        // regression key exists but no matching closing brace
1190        let config = r#"{ "regression": { "baseline": { "totalIssues": 1 }"#;
1191        let result = update_json_regression(config, &sample_baseline());
1192        assert!(result.is_err());
1193    }
1194
1195    // ── update_toml_regression ──────────────────────────────────────
1196
1197    #[test]
1198    fn toml_insert_into_empty() {
1199        let result = update_toml_regression("", &sample_baseline());
1200        assert!(result.contains("[regression.baseline]"));
1201        assert!(result.contains("totalIssues = 5"));
1202    }
1203
1204    #[test]
1205    fn toml_insert_after_existing_content() {
1206        let config = "[rules]\nunused-files = \"warn\"\n";
1207        let result = update_toml_regression(config, &sample_baseline());
1208        assert!(result.contains("[rules]"));
1209        assert!(result.contains("[regression.baseline]"));
1210        assert!(result.contains("totalIssues = 5"));
1211    }
1212
1213    #[test]
1214    fn toml_replace_existing_section() {
1215        let config =
1216            "[regression.baseline]\ntotalIssues = 99\n\n[rules]\nunused-files = \"warn\"\n";
1217        let result = update_toml_regression(config, &sample_baseline());
1218        assert!(!result.contains("99"));
1219        assert!(result.contains("totalIssues = 5"));
1220        assert!(result.contains("[rules]"));
1221    }
1222
1223    // ── find_json_key ───────────────────────────────────────────────
1224
1225    #[test]
1226    fn find_json_key_basic() {
1227        assert_eq!(find_json_key(r#"{"foo": 1}"#, "foo"), Some(1));
1228    }
1229
1230    #[test]
1231    fn find_json_key_skips_comment() {
1232        let content = "{\n  // \"foo\" is important\n  \"bar\": 1\n}";
1233        assert_eq!(find_json_key(content, "foo"), None);
1234        assert!(find_json_key(content, "bar").is_some());
1235    }
1236
1237    #[test]
1238    fn find_json_key_not_found() {
1239        assert_eq!(find_json_key("{}", "missing"), None);
1240    }
1241
1242    #[test]
1243    fn find_json_key_skips_block_comment() {
1244        let content = "{\n  /* \"foo\": old value */\n  \"foo\": 1\n}";
1245        // Should find the real key, not the one inside /* */
1246        let pos = find_json_key(content, "foo").unwrap();
1247        assert!(content[pos..].starts_with("\"foo\": 1"));
1248    }
1249
1250    // ── Additional tolerance parsing ────────────────────────────────
1251
1252    #[test]
1253    fn parse_whitespace_padded_tolerance() {
1254        let t = Tolerance::parse("  5  ").unwrap();
1255        assert!(matches!(t, Tolerance::Absolute(5)));
1256    }
1257
1258    #[test]
1259    fn parse_whitespace_only_defaults_to_zero() {
1260        let t = Tolerance::parse("   ").unwrap();
1261        assert!(matches!(t, Tolerance::Absolute(0)));
1262    }
1263
1264    #[test]
1265    fn parse_zero_percent_tolerance() {
1266        let t = Tolerance::parse("0%").unwrap();
1267        assert!(matches!(t, Tolerance::Percentage(p) if p == 0.0));
1268    }
1269
1270    #[test]
1271    fn parse_decimal_percentage_tolerance() {
1272        let t = Tolerance::parse("1.5%").unwrap();
1273        assert!(matches!(t, Tolerance::Percentage(p) if (p - 1.5).abs() < f64::EPSILON));
1274    }
1275
1276    #[test]
1277    fn parse_large_absolute_tolerance() {
1278        let t = Tolerance::parse("1000").unwrap();
1279        assert!(matches!(t, Tolerance::Absolute(1000)));
1280    }
1281
1282    #[test]
1283    fn parse_negative_absolute_is_err() {
1284        // usize can't be negative, so parsing "-1" as usize fails
1285        assert!(Tolerance::parse("-1").is_err());
1286    }
1287
1288    #[test]
1289    fn parse_whitespace_padded_percentage() {
1290        let t = Tolerance::parse("  3.5%  ").unwrap();
1291        assert!(matches!(t, Tolerance::Percentage(p) if (p - 3.5).abs() < f64::EPSILON));
1292    }
1293
1294    // ── Additional Tolerance::exceeded ──────────────────────────────
1295
1296    #[test]
1297    fn zero_pct_tolerance_detects_any_increase() {
1298        let t = Tolerance::Percentage(0.0);
1299        assert!(t.exceeded(100, 101));
1300        assert!(!t.exceeded(100, 100));
1301        assert!(!t.exceeded(100, 99));
1302    }
1303
1304    #[test]
1305    fn percentage_tolerance_with_small_baseline() {
1306        // baseline=3, 10% of 3 = 0.3, floor = 0 => delta > 0 triggers
1307        let t = Tolerance::Percentage(10.0);
1308        assert!(t.exceeded(3, 4)); // delta=1 > allowed=0
1309        assert!(!t.exceeded(3, 3)); // no increase
1310    }
1311
1312    #[test]
1313    fn percentage_tolerance_large_percentage() {
1314        let t = Tolerance::Percentage(100.0);
1315        // baseline=10, 100% of 10 = 10, floor=10 => delta > 10 triggers
1316        assert!(!t.exceeded(10, 20)); // delta=10, allowed=10
1317        assert!(t.exceeded(10, 21)); // delta=11, allowed=10
1318    }
1319
1320    #[test]
1321    fn absolute_tolerance_at_exact_boundary() {
1322        let t = Tolerance::Absolute(5);
1323        assert!(!t.exceeded(10, 15)); // delta=5, allowed=5
1324        assert!(t.exceeded(10, 16)); // delta=6, allowed=5
1325    }
1326
1327    #[test]
1328    fn decrease_never_exceeds_for_all_variants() {
1329        let t = Tolerance::Absolute(0);
1330        assert!(!t.exceeded(10, 0));
1331        let t = Tolerance::Percentage(0.0);
1332        assert!(!t.exceeded(10, 0));
1333    }
1334
1335    #[test]
1336    fn equal_values_never_exceed() {
1337        assert!(!Tolerance::Absolute(0).exceeded(0, 0));
1338        assert!(!Tolerance::Percentage(0.0).exceeded(0, 0));
1339        assert!(!Tolerance::Absolute(0).exceeded(100, 100));
1340        assert!(!Tolerance::Percentage(0.0).exceeded(100, 100));
1341    }
1342
1343    // ── CheckCounts config baseline roundtrip ────────────────────────
1344
1345    #[test]
1346    fn check_counts_config_roundtrip() {
1347        let counts = CheckCounts {
1348            total_issues: 42,
1349            unused_files: 5,
1350            unused_exports: 20,
1351            unused_types: 8,
1352            unused_dependencies: 3,
1353            unused_dev_dependencies: 2,
1354            unused_optional_dependencies: 1,
1355            unused_enum_members: 1,
1356            unused_class_members: 1,
1357            unresolved_imports: 0,
1358            unlisted_dependencies: 1,
1359            duplicate_exports: 0,
1360            circular_dependencies: 0,
1361            type_only_dependencies: 0,
1362            test_only_dependencies: 0,
1363        };
1364        let config_baseline = counts.to_config_baseline();
1365        let roundtripped = CheckCounts::from_config_baseline(&config_baseline);
1366        assert_eq!(roundtripped.total_issues, 42);
1367        assert_eq!(roundtripped.unused_files, 5);
1368        assert_eq!(roundtripped.unused_exports, 20);
1369        assert_eq!(roundtripped.unused_types, 8);
1370        assert_eq!(roundtripped.unused_dependencies, 3);
1371        assert_eq!(roundtripped.unused_dev_dependencies, 2);
1372        assert_eq!(roundtripped.unused_optional_dependencies, 1);
1373        assert_eq!(roundtripped.unused_enum_members, 1);
1374        assert_eq!(roundtripped.unused_class_members, 1);
1375        assert_eq!(roundtripped.unresolved_imports, 0);
1376        assert_eq!(roundtripped.unlisted_dependencies, 1);
1377        assert_eq!(roundtripped.duplicate_exports, 0);
1378        assert_eq!(roundtripped.circular_dependencies, 0);
1379        assert_eq!(roundtripped.type_only_dependencies, 0);
1380        assert_eq!(roundtripped.test_only_dependencies, 0);
1381    }
1382
1383    #[test]
1384    fn check_counts_zero_config_roundtrip() {
1385        let counts = CheckCounts {
1386            total_issues: 0,
1387            unused_files: 0,
1388            unused_exports: 0,
1389            unused_types: 0,
1390            unused_dependencies: 0,
1391            unused_dev_dependencies: 0,
1392            unused_optional_dependencies: 0,
1393            unused_enum_members: 0,
1394            unused_class_members: 0,
1395            unresolved_imports: 0,
1396            unlisted_dependencies: 0,
1397            duplicate_exports: 0,
1398            circular_dependencies: 0,
1399            type_only_dependencies: 0,
1400            test_only_dependencies: 0,
1401        };
1402        let config_baseline = counts.to_config_baseline();
1403        let roundtripped = CheckCounts::from_config_baseline(&config_baseline);
1404        assert_eq!(roundtripped.total_issues, 0);
1405        assert_eq!(roundtripped.unused_files, 0);
1406    }
1407
1408    // ── deltas edge cases ──────────────────────────────────────────
1409
1410    #[test]
1411    fn deltas_empty_when_identical() {
1412        let counts = CheckCounts {
1413            total_issues: 10,
1414            unused_files: 5,
1415            unused_exports: 3,
1416            unused_types: 2,
1417            unused_dependencies: 0,
1418            unused_dev_dependencies: 0,
1419            unused_optional_dependencies: 0,
1420            unused_enum_members: 0,
1421            unused_class_members: 0,
1422            unresolved_imports: 0,
1423            unlisted_dependencies: 0,
1424            duplicate_exports: 0,
1425            circular_dependencies: 0,
1426            type_only_dependencies: 0,
1427            test_only_dependencies: 0,
1428        };
1429        let deltas = counts.deltas(&counts);
1430        assert!(deltas.is_empty());
1431    }
1432
1433    #[test]
1434    fn deltas_all_categories_changed() {
1435        let baseline = CheckCounts {
1436            total_issues: 0,
1437            unused_files: 0,
1438            unused_exports: 0,
1439            unused_types: 0,
1440            unused_dependencies: 0,
1441            unused_dev_dependencies: 0,
1442            unused_optional_dependencies: 0,
1443            unused_enum_members: 0,
1444            unused_class_members: 0,
1445            unresolved_imports: 0,
1446            unlisted_dependencies: 0,
1447            duplicate_exports: 0,
1448            circular_dependencies: 0,
1449            type_only_dependencies: 0,
1450            test_only_dependencies: 0,
1451        };
1452        let current = CheckCounts {
1453            total_issues: 14,
1454            unused_files: 1,
1455            unused_exports: 1,
1456            unused_types: 1,
1457            unused_dependencies: 1,
1458            unused_dev_dependencies: 1,
1459            unused_optional_dependencies: 1,
1460            unused_enum_members: 1,
1461            unused_class_members: 1,
1462            unresolved_imports: 1,
1463            unlisted_dependencies: 1,
1464            duplicate_exports: 1,
1465            circular_dependencies: 1,
1466            type_only_dependencies: 1,
1467            test_only_dependencies: 1,
1468        };
1469        let deltas = baseline.deltas(&current);
1470        // total_issues is not in deltas — only per-type fields
1471        assert_eq!(deltas.len(), 14);
1472        for (_, d) in &deltas {
1473            assert_eq!(*d, 1);
1474        }
1475    }
1476
1477    #[test]
1478    fn deltas_mixed_increase_decrease() {
1479        let baseline = CheckCounts {
1480            total_issues: 10,
1481            unused_files: 5,
1482            unused_exports: 3,
1483            unused_types: 2,
1484            unused_dependencies: 0,
1485            unused_dev_dependencies: 0,
1486            unused_optional_dependencies: 0,
1487            unused_enum_members: 0,
1488            unused_class_members: 0,
1489            unresolved_imports: 0,
1490            unlisted_dependencies: 0,
1491            duplicate_exports: 0,
1492            circular_dependencies: 0,
1493            type_only_dependencies: 0,
1494            test_only_dependencies: 0,
1495        };
1496        let current = CheckCounts {
1497            unused_files: 3,       // -2
1498            unused_exports: 5,     // +2
1499            unused_types: 0,       // -2
1500            unresolved_imports: 1, // +1
1501            ..baseline
1502        };
1503        let deltas = baseline.deltas(&current);
1504        assert_eq!(deltas.len(), 4);
1505        assert!(deltas.contains(&("unused_files", -2)));
1506        assert!(deltas.contains(&("unused_exports", 2)));
1507        assert!(deltas.contains(&("unused_types", -2)));
1508        assert!(deltas.contains(&("unresolved_imports", 1)));
1509    }
1510
1511    // ── RegressionOutcome JSON with absolute tolerance ──────────────
1512
1513    #[test]
1514    fn exceeded_outcome_json_absolute() {
1515        let outcome = RegressionOutcome::Exceeded {
1516            baseline_total: 10,
1517            current_total: 15,
1518            tolerance: Tolerance::Absolute(2),
1519            type_deltas: vec![("unused_files", 5)],
1520        };
1521        let json = outcome.to_json();
1522        assert_eq!(json["status"], "exceeded");
1523        assert_eq!(json["tolerance_kind"], "absolute");
1524        assert_eq!(json["tolerance"], 2.0);
1525        assert_eq!(json["delta"], 5);
1526    }
1527
1528    #[test]
1529    fn pass_outcome_json_with_improvement() {
1530        let outcome = RegressionOutcome::Pass {
1531            baseline_total: 10,
1532            current_total: 5,
1533        };
1534        let json = outcome.to_json();
1535        assert_eq!(json["status"], "pass");
1536        assert_eq!(json["delta"], -5);
1537        assert_eq!(json["exceeded"], false);
1538    }
1539
1540    // ── DupesCounts serialization ──────────────────────────────────
1541
1542    #[test]
1543    fn dupes_counts_roundtrip() {
1544        let dupes = DupesCounts {
1545            clone_groups: 8,
1546            duplication_percentage: 3.17,
1547        };
1548        let json = serde_json::to_string(&dupes).unwrap();
1549        let loaded: DupesCounts = serde_json::from_str(&json).unwrap();
1550        assert_eq!(loaded.clone_groups, 8);
1551        assert!((loaded.duplication_percentage - 3.17).abs() < f64::EPSILON);
1552    }
1553
1554    #[test]
1555    fn dupes_counts_default_fields() {
1556        // Deserializing with missing fields should default to zero
1557        let json = "{}";
1558        let loaded: DupesCounts = serde_json::from_str(json).unwrap();
1559        assert_eq!(loaded.clone_groups, 0);
1560        assert!((loaded.duplication_percentage).abs() < f64::EPSILON);
1561    }
1562
1563    // ── RegressionBaseline with missing optional sections ──────────
1564
1565    #[test]
1566    fn baseline_without_check_section() {
1567        let baseline = RegressionBaseline {
1568            schema_version: 1,
1569            fallow_version: "2.4.0".into(),
1570            timestamp: "2026-03-27T10:00:00Z".into(),
1571            git_sha: None,
1572            check: None,
1573            dupes: Some(DupesCounts {
1574                clone_groups: 3,
1575                duplication_percentage: 1.0,
1576            }),
1577        };
1578        let json = serde_json::to_string_pretty(&baseline).unwrap();
1579        let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
1580        assert!(loaded.check.is_none());
1581        assert!(loaded.dupes.is_some());
1582    }
1583
1584    #[test]
1585    fn baseline_without_dupes_section() {
1586        let baseline = RegressionBaseline {
1587            schema_version: 1,
1588            fallow_version: "2.4.0".into(),
1589            timestamp: "2026-03-27T10:00:00Z".into(),
1590            git_sha: Some("deadbeef".into()),
1591            check: Some(CheckCounts {
1592                total_issues: 1,
1593                unused_files: 1,
1594                ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
1595            }),
1596            dupes: None,
1597        };
1598        let json = serde_json::to_string_pretty(&baseline).unwrap();
1599        let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
1600        assert!(loaded.check.is_some());
1601        assert!(loaded.dupes.is_none());
1602        assert_eq!(loaded.git_sha.as_deref(), Some("deadbeef"));
1603    }
1604
1605    #[test]
1606    fn baseline_without_git_sha() {
1607        let baseline = RegressionBaseline {
1608            schema_version: 1,
1609            fallow_version: "2.4.0".into(),
1610            timestamp: "2026-03-27T10:00:00Z".into(),
1611            git_sha: None,
1612            check: None,
1613            dupes: None,
1614        };
1615        let json = serde_json::to_string_pretty(&baseline).unwrap();
1616        // git_sha should be skipped in serialization
1617        assert!(!json.contains("git_sha"));
1618        let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
1619        assert!(loaded.git_sha.is_none());
1620    }
1621
1622    // ── Forward compatibility: extra fields are ignored ──────────────
1623
1624    #[test]
1625    fn baseline_json_with_unknown_check_fields_deserializes() {
1626        let json = r#"{
1627            "schema_version": 1,
1628            "fallow_version": "3.0.0",
1629            "timestamp": "2026-03-27T10:00:00Z",
1630            "check": {
1631                "total_issues": 10,
1632                "unused_files": 2,
1633                "some_future_field": 99
1634            }
1635        }"#;
1636        // Should not fail — extra fields are ignored by serde default
1637        let loaded: Result<RegressionBaseline, _> = serde_json::from_str(json);
1638        // Note: serde doesn't deny unknown fields by default, so this should work
1639        assert!(loaded.is_ok());
1640        let loaded = loaded.unwrap();
1641        assert_eq!(loaded.check.as_ref().unwrap().total_issues, 10);
1642    }
1643
1644    // ── save/load roundtrip ────────────────────────────────────────
1645
1646    #[test]
1647    fn save_load_roundtrip() {
1648        let dir = tempfile::tempdir().unwrap();
1649        let path = dir.path().join("regression-baseline.json");
1650        let counts = CheckCounts {
1651            total_issues: 15,
1652            unused_files: 3,
1653            unused_exports: 5,
1654            unused_types: 2,
1655            unused_dependencies: 1,
1656            unused_dev_dependencies: 1,
1657            unused_optional_dependencies: 0,
1658            unused_enum_members: 1,
1659            unused_class_members: 0,
1660            unresolved_imports: 1,
1661            unlisted_dependencies: 0,
1662            duplicate_exports: 1,
1663            circular_dependencies: 0,
1664            type_only_dependencies: 0,
1665            test_only_dependencies: 0,
1666        };
1667        let dupes = DupesCounts {
1668            clone_groups: 4,
1669            duplication_percentage: 2.5,
1670        };
1671
1672        save_regression_baseline(&path, dir.path(), Some(&counts), Some(&dupes)).unwrap();
1673        let loaded = load_regression_baseline(&path).unwrap();
1674
1675        assert_eq!(loaded.schema_version, REGRESSION_SCHEMA_VERSION);
1676        let check = loaded.check.unwrap();
1677        assert_eq!(check.total_issues, 15);
1678        assert_eq!(check.unused_files, 3);
1679        assert_eq!(check.unused_exports, 5);
1680        assert_eq!(check.unused_types, 2);
1681        assert_eq!(check.unused_dependencies, 1);
1682        assert_eq!(check.unresolved_imports, 1);
1683        assert_eq!(check.duplicate_exports, 1);
1684        let dupes = loaded.dupes.unwrap();
1685        assert_eq!(dupes.clone_groups, 4);
1686        assert!((dupes.duplication_percentage - 2.5).abs() < f64::EPSILON);
1687    }
1688
1689    #[test]
1690    fn save_load_roundtrip_check_only() {
1691        let dir = tempfile::tempdir().unwrap();
1692        let path = dir.path().join("regression-baseline.json");
1693        let counts = CheckCounts {
1694            total_issues: 5,
1695            unused_files: 5,
1696            ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
1697        };
1698
1699        save_regression_baseline(&path, dir.path(), Some(&counts), None).unwrap();
1700        let loaded = load_regression_baseline(&path).unwrap();
1701
1702        assert!(loaded.check.is_some());
1703        assert!(loaded.dupes.is_none());
1704        assert_eq!(loaded.check.unwrap().unused_files, 5);
1705    }
1706
1707    #[test]
1708    fn save_creates_parent_directories() {
1709        let dir = tempfile::tempdir().unwrap();
1710        let path = dir.path().join("nested").join("dir").join("baseline.json");
1711        let counts = CheckCounts {
1712            total_issues: 1,
1713            unused_files: 1,
1714            ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
1715        };
1716
1717        save_regression_baseline(&path, dir.path(), Some(&counts), None).unwrap();
1718        assert!(path.exists());
1719    }
1720
1721    #[test]
1722    fn load_nonexistent_file_returns_error() {
1723        let result = load_regression_baseline(Path::new("/tmp/nonexistent-baseline-12345.json"));
1724        assert!(result.is_err());
1725    }
1726
1727    #[test]
1728    fn load_invalid_json_returns_error() {
1729        let dir = tempfile::tempdir().unwrap();
1730        let path = dir.path().join("bad.json");
1731        std::fs::write(&path, "not valid json {{{").unwrap();
1732        let result = load_regression_baseline(&path);
1733        assert!(result.is_err());
1734    }
1735
1736    // ── save_baseline_to_config ────────────────────────────────────
1737
1738    #[test]
1739    fn save_baseline_to_json_config() {
1740        let dir = tempfile::tempdir().unwrap();
1741        let config_path = dir.path().join(".fallowrc.json");
1742        std::fs::write(&config_path, r#"{"entry": ["src/main.ts"]}"#).unwrap();
1743
1744        let counts = CheckCounts {
1745            total_issues: 7,
1746            unused_files: 3,
1747            unused_exports: 4,
1748            ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
1749        };
1750        save_baseline_to_config(&config_path, &counts).unwrap();
1751
1752        let content = std::fs::read_to_string(&config_path).unwrap();
1753        assert!(content.contains("\"regression\""));
1754        assert!(content.contains("\"totalIssues\": 7"));
1755        // Should still be valid JSON
1756        serde_json::from_str::<serde_json::Value>(&content).unwrap();
1757    }
1758
1759    #[test]
1760    fn save_baseline_to_toml_config() {
1761        let dir = tempfile::tempdir().unwrap();
1762        let config_path = dir.path().join("fallow.toml");
1763        std::fs::write(&config_path, "[rules]\nunused-files = \"warn\"\n").unwrap();
1764
1765        let counts = CheckCounts {
1766            total_issues: 7,
1767            unused_files: 3,
1768            unused_exports: 4,
1769            ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
1770        };
1771        save_baseline_to_config(&config_path, &counts).unwrap();
1772
1773        let content = std::fs::read_to_string(&config_path).unwrap();
1774        assert!(content.contains("[regression.baseline]"));
1775        assert!(content.contains("totalIssues = 7"));
1776        assert!(content.contains("[rules]"));
1777    }
1778
1779    #[test]
1780    fn save_baseline_to_nonexistent_json_config() {
1781        let dir = tempfile::tempdir().unwrap();
1782        let config_path = dir.path().join(".fallowrc.json");
1783        // File doesn't exist — should create it from scratch
1784
1785        let counts = CheckCounts {
1786            total_issues: 1,
1787            unused_files: 1,
1788            ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
1789        };
1790        save_baseline_to_config(&config_path, &counts).unwrap();
1791
1792        let content = std::fs::read_to_string(&config_path).unwrap();
1793        assert!(content.contains("\"regression\""));
1794        serde_json::from_str::<serde_json::Value>(&content).unwrap();
1795    }
1796
1797    #[test]
1798    fn save_baseline_to_nonexistent_toml_config() {
1799        let dir = tempfile::tempdir().unwrap();
1800        let config_path = dir.path().join("fallow.toml");
1801
1802        let counts = CheckCounts {
1803            total_issues: 0,
1804            ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
1805        };
1806        save_baseline_to_config(&config_path, &counts).unwrap();
1807
1808        let content = std::fs::read_to_string(&config_path).unwrap();
1809        assert!(content.contains("[regression.baseline]"));
1810        assert!(content.contains("totalIssues = 0"));
1811    }
1812
1813    // ── update_json_regression edge cases ──────────────────────────
1814
1815    #[test]
1816    fn json_insert_with_trailing_comma() {
1817        let config = r#"{
1818  "entry": ["src/main.ts"],
1819}"#;
1820        // Trailing comma — our insertion should still produce reasonable output
1821        let result = update_json_regression(config, &sample_baseline()).unwrap();
1822        assert!(result.contains("\"regression\""));
1823    }
1824
1825    #[test]
1826    fn json_no_closing_brace_returns_error() {
1827        let result = update_json_regression("", &sample_baseline());
1828        assert!(result.is_err());
1829    }
1830
1831    #[test]
1832    fn json_nested_regression_object_replaced_correctly() {
1833        let config = r#"{
1834  "regression": {
1835    "baseline": {
1836      "totalIssues": 99,
1837      "unusedFiles": 10
1838    },
1839    "tolerance": "5%"
1840  },
1841  "entry": ["src/main.ts"]
1842}"#;
1843        let result = update_json_regression(config, &sample_baseline()).unwrap();
1844        assert!(!result.contains("99"));
1845        assert!(result.contains("\"totalIssues\": 5"));
1846        assert!(result.contains("\"entry\""));
1847    }
1848
1849    // ── update_toml_regression edge cases ──────────────────────────
1850
1851    #[test]
1852    fn toml_content_without_trailing_newline() {
1853        let config = "[rules]\nunused-files = \"warn\"";
1854        let result = update_toml_regression(config, &sample_baseline());
1855        assert!(result.contains("[regression.baseline]"));
1856        assert!(result.contains("[rules]"));
1857    }
1858
1859    #[test]
1860    fn toml_replace_section_not_at_end() {
1861        let config = "[regression.baseline]\ntotalIssues = 99\nunusedFiles = 10\n\n[rules]\nunused-files = \"warn\"\n";
1862        let result = update_toml_regression(config, &sample_baseline());
1863        assert!(!result.contains("99"));
1864        assert!(result.contains("totalIssues = 5"));
1865        assert!(result.contains("[rules]"));
1866        assert!(result.contains("unused-files = \"warn\""));
1867    }
1868
1869    #[test]
1870    fn toml_replace_section_at_end() {
1871        let config =
1872            "[rules]\nunused-files = \"warn\"\n\n[regression.baseline]\ntotalIssues = 99\n";
1873        let result = update_toml_regression(config, &sample_baseline());
1874        assert!(!result.contains("99"));
1875        assert!(result.contains("totalIssues = 5"));
1876        assert!(result.contains("[rules]"));
1877    }
1878
1879    // ── find_json_key edge cases ────────────────────────────────────
1880
1881    #[test]
1882    fn find_json_key_multiple_same_keys() {
1883        // Returns the first occurrence
1884        let content = r#"{"foo": 1, "bar": {"foo": 2}}"#;
1885        let pos = find_json_key(content, "foo").unwrap();
1886        assert_eq!(pos, 1);
1887    }
1888
1889    #[test]
1890    fn find_json_key_in_nested_comment_then_real() {
1891        let content = "{\n  // \"entry\": old\n  /* \"entry\": also old */\n  \"entry\": []\n}";
1892        let pos = find_json_key(content, "entry").unwrap();
1893        assert!(content[pos..].starts_with("\"entry\": []"));
1894    }
1895
1896    // ── chrono_now ─────────────────────────────────────────────────
1897
1898    #[test]
1899    fn chrono_now_format() {
1900        let ts = chrono_now();
1901        // Should be ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ
1902        assert_eq!(ts.len(), 20);
1903        assert!(ts.ends_with('Z'));
1904        assert_eq!(&ts[4..5], "-");
1905        assert_eq!(&ts[7..8], "-");
1906        assert_eq!(&ts[10..11], "T");
1907        assert_eq!(&ts[13..14], ":");
1908        assert_eq!(&ts[16..17], ":");
1909    }
1910
1911    // ── print_regression_outcome ────────────────────────────────────
1912
1913    #[test]
1914    fn print_pass_outcome_does_not_panic() {
1915        let outcome = RegressionOutcome::Pass {
1916            baseline_total: 10,
1917            current_total: 8,
1918        };
1919        // Just verify it doesn't panic — output goes to stderr
1920        print_regression_outcome(&outcome);
1921    }
1922
1923    #[test]
1924    fn print_exceeded_outcome_does_not_panic() {
1925        let outcome = RegressionOutcome::Exceeded {
1926            baseline_total: 10,
1927            current_total: 15,
1928            tolerance: Tolerance::Percentage(2.0),
1929            type_deltas: vec![("unused_files", 5), ("unused_exports", -2)],
1930        };
1931        print_regression_outcome(&outcome);
1932    }
1933
1934    #[test]
1935    fn print_exceeded_outcome_absolute_does_not_panic() {
1936        let outcome = RegressionOutcome::Exceeded {
1937            baseline_total: 10,
1938            current_total: 15,
1939            tolerance: Tolerance::Absolute(2),
1940            type_deltas: vec![("unused_files", 3), ("unresolved_imports", 2)],
1941        };
1942        print_regression_outcome(&outcome);
1943    }
1944
1945    #[test]
1946    fn print_skipped_outcome_does_not_panic() {
1947        let outcome = RegressionOutcome::Skipped {
1948            reason: "test reason",
1949        };
1950        print_regression_outcome(&outcome);
1951    }
1952
1953    #[test]
1954    fn print_exceeded_with_empty_deltas_does_not_panic() {
1955        let outcome = RegressionOutcome::Exceeded {
1956            baseline_total: 10,
1957            current_total: 15,
1958            tolerance: Tolerance::Absolute(0),
1959            type_deltas: vec![],
1960        };
1961        print_regression_outcome(&outcome);
1962    }
1963
1964    // ── compare_check_regression ────────────────────────────────────
1965
1966    fn make_opts(
1967        fail: bool,
1968        tolerance: Tolerance,
1969        scoped: bool,
1970        baseline_file: Option<&Path>,
1971    ) -> RegressionOpts<'_> {
1972        RegressionOpts {
1973            fail_on_regression: fail,
1974            tolerance,
1975            regression_baseline_file: baseline_file,
1976            save_target: SaveRegressionTarget::None,
1977            scoped,
1978            quiet: true,
1979        }
1980    }
1981
1982    #[test]
1983    fn compare_returns_none_when_disabled() {
1984        let results = AnalysisResults::default();
1985        let opts = make_opts(false, Tolerance::Absolute(0), false, None);
1986        let config_baseline = fallow_config::RegressionBaseline {
1987            total_issues: 5,
1988            ..Default::default()
1989        };
1990        let outcome = compare_check_regression(&results, &opts, Some(&config_baseline)).unwrap();
1991        assert!(outcome.is_none());
1992    }
1993
1994    #[test]
1995    fn compare_returns_skipped_when_scoped() {
1996        let results = AnalysisResults::default();
1997        let opts = make_opts(true, Tolerance::Absolute(0), true, None);
1998        let config_baseline = fallow_config::RegressionBaseline {
1999            total_issues: 5,
2000            ..Default::default()
2001        };
2002        let outcome = compare_check_regression(&results, &opts, Some(&config_baseline)).unwrap();
2003        assert!(matches!(outcome, Some(RegressionOutcome::Skipped { .. })));
2004    }
2005
2006    #[test]
2007    fn compare_pass_with_config_baseline() {
2008        let results = AnalysisResults::default(); // 0 issues
2009        let opts = make_opts(true, Tolerance::Absolute(0), false, None);
2010        let config_baseline = fallow_config::RegressionBaseline {
2011            total_issues: 0,
2012            ..Default::default()
2013        };
2014        let outcome = compare_check_regression(&results, &opts, Some(&config_baseline)).unwrap();
2015        match outcome {
2016            Some(RegressionOutcome::Pass {
2017                baseline_total,
2018                current_total,
2019            }) => {
2020                assert_eq!(baseline_total, 0);
2021                assert_eq!(current_total, 0);
2022            }
2023            other => panic!("expected Pass, got {other:?}"),
2024        }
2025    }
2026
2027    #[test]
2028    fn compare_exceeded_with_config_baseline() {
2029        let mut results = AnalysisResults::default();
2030        results.unused_files.push(UnusedFile {
2031            path: PathBuf::from("a.ts"),
2032        });
2033        results.unused_files.push(UnusedFile {
2034            path: PathBuf::from("b.ts"),
2035        });
2036        let opts = make_opts(true, Tolerance::Absolute(0), false, None);
2037        let config_baseline = fallow_config::RegressionBaseline {
2038            total_issues: 0,
2039            ..Default::default()
2040        };
2041        let outcome = compare_check_regression(&results, &opts, Some(&config_baseline)).unwrap();
2042        match outcome {
2043            Some(RegressionOutcome::Exceeded {
2044                baseline_total,
2045                current_total,
2046                ..
2047            }) => {
2048                assert_eq!(baseline_total, 0);
2049                assert_eq!(current_total, 2);
2050            }
2051            other => panic!("expected Exceeded, got {other:?}"),
2052        }
2053    }
2054
2055    #[test]
2056    fn compare_pass_within_tolerance() {
2057        let mut results = AnalysisResults::default();
2058        results.unused_files.push(UnusedFile {
2059            path: PathBuf::from("a.ts"),
2060        });
2061        let opts = make_opts(true, Tolerance::Absolute(5), false, None);
2062        let config_baseline = fallow_config::RegressionBaseline {
2063            total_issues: 0,
2064            ..Default::default()
2065        };
2066        let outcome = compare_check_regression(&results, &opts, Some(&config_baseline)).unwrap();
2067        assert!(matches!(outcome, Some(RegressionOutcome::Pass { .. })));
2068    }
2069
2070    #[test]
2071    fn compare_improvement_is_pass() {
2072        // Current has fewer issues than baseline
2073        let results = AnalysisResults::default(); // 0 issues
2074        let opts = make_opts(true, Tolerance::Absolute(0), false, None);
2075        let config_baseline = fallow_config::RegressionBaseline {
2076            total_issues: 10,
2077            unused_files: 5,
2078            unused_exports: 5,
2079            ..Default::default()
2080        };
2081        let outcome = compare_check_regression(&results, &opts, Some(&config_baseline)).unwrap();
2082        match outcome {
2083            Some(RegressionOutcome::Pass {
2084                baseline_total,
2085                current_total,
2086            }) => {
2087                assert_eq!(baseline_total, 10);
2088                assert_eq!(current_total, 0);
2089            }
2090            other => panic!("expected Pass, got {other:?}"),
2091        }
2092    }
2093
2094    #[test]
2095    fn compare_with_file_baseline() {
2096        let dir = tempfile::tempdir().unwrap();
2097        let baseline_path = dir.path().join("baseline.json");
2098
2099        // Save a baseline to file
2100        let counts = CheckCounts {
2101            total_issues: 5,
2102            unused_files: 5,
2103            ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
2104        };
2105        save_regression_baseline(&baseline_path, dir.path(), Some(&counts), None).unwrap();
2106
2107        // Compare with empty results -> pass (improvement)
2108        let results = AnalysisResults::default();
2109        let opts = make_opts(true, Tolerance::Absolute(0), false, Some(&baseline_path));
2110        let outcome = compare_check_regression(&results, &opts, None).unwrap();
2111        assert!(matches!(outcome, Some(RegressionOutcome::Pass { .. })));
2112    }
2113
2114    #[test]
2115    fn compare_file_baseline_missing_check_data_returns_error() {
2116        let dir = tempfile::tempdir().unwrap();
2117        let baseline_path = dir.path().join("baseline.json");
2118
2119        // Save a baseline with no check data (dupes only)
2120        save_regression_baseline(
2121            &baseline_path,
2122            dir.path(),
2123            None,
2124            Some(&DupesCounts {
2125                clone_groups: 1,
2126                duplication_percentage: 1.0,
2127            }),
2128        )
2129        .unwrap();
2130
2131        let results = AnalysisResults::default();
2132        let opts = make_opts(true, Tolerance::Absolute(0), false, Some(&baseline_path));
2133        let outcome = compare_check_regression(&results, &opts, None);
2134        assert!(outcome.is_err());
2135    }
2136
2137    #[test]
2138    fn compare_no_baseline_source_returns_error() {
2139        let results = AnalysisResults::default();
2140        let opts = make_opts(true, Tolerance::Absolute(0), false, None);
2141        let outcome = compare_check_regression(&results, &opts, None);
2142        assert!(outcome.is_err());
2143    }
2144
2145    #[test]
2146    fn compare_exceeded_includes_type_deltas() {
2147        let mut results = AnalysisResults::default();
2148        results.unused_files.push(UnusedFile {
2149            path: PathBuf::from("a.ts"),
2150        });
2151        results.unused_files.push(UnusedFile {
2152            path: PathBuf::from("b.ts"),
2153        });
2154        results.unused_exports.push(UnusedExport {
2155            path: PathBuf::from("c.ts"),
2156            export_name: "foo".into(),
2157            is_type_only: false,
2158            line: 1,
2159            col: 0,
2160            span_start: 0,
2161            is_re_export: false,
2162        });
2163
2164        let opts = make_opts(true, Tolerance::Absolute(0), false, None);
2165        let config_baseline = fallow_config::RegressionBaseline {
2166            total_issues: 0,
2167            ..Default::default()
2168        };
2169        let outcome = compare_check_regression(&results, &opts, Some(&config_baseline)).unwrap();
2170
2171        match outcome {
2172            Some(RegressionOutcome::Exceeded { type_deltas, .. }) => {
2173                assert!(type_deltas.contains(&("unused_files", 2)));
2174                assert!(type_deltas.contains(&("unused_exports", 1)));
2175            }
2176            other => panic!("expected Exceeded, got {other:?}"),
2177        }
2178    }
2179
2180    #[test]
2181    fn compare_with_percentage_tolerance() {
2182        let mut results = AnalysisResults::default();
2183        // Add 1 issue
2184        results.unused_files.push(UnusedFile {
2185            path: PathBuf::from("a.ts"),
2186        });
2187
2188        let opts = make_opts(true, Tolerance::Percentage(50.0), false, None);
2189        // baseline=10, 50% of 10 = 5, delta=1-10=-9 (improvement, should pass)
2190        // Wait, total_issues in config is the baseline for comparison.
2191        // results has 1 issue, baseline has 10 -> improvement -> pass
2192        let config_baseline = fallow_config::RegressionBaseline {
2193            total_issues: 10,
2194            unused_files: 10,
2195            ..Default::default()
2196        };
2197        let outcome = compare_check_regression(&results, &opts, Some(&config_baseline)).unwrap();
2198        assert!(matches!(outcome, Some(RegressionOutcome::Pass { .. })));
2199    }
2200}