Skip to main content

fallow_cli/regression/
baseline.rs

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