Skip to main content

fallow_cli/regression/
baseline.rs

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