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