Skip to main content

git_slop/
cli.rs

1use std::fs;
2use std::io::IsTerminal;
3use std::path::{Component, Path, PathBuf};
4
5use anyhow::{Context, Result};
6use clap::{ArgGroup, Args, CommandFactory, Parser, Subcommand, ValueEnum};
7use clap_complete::{Shell, generate};
8use serde_json::{Value, json};
9use sha2::Digest;
10
11use crate::build_info;
12use crate::config;
13use crate::health;
14use crate::report;
15use crate::report_ops::{
16    ExplainSelector, PlanSelector, compare_payload_with_force, explain_payload, failing_records,
17    health_json_payload, plan_payload, render_compare_text, render_explain_text,
18    render_github_annotations, render_json, render_plan_text, render_show_text, sarif_payload,
19    show_payload, write_prompt_pack,
20};
21use crate::{PROJECT_NAME, VERSION, analyze, git};
22
23#[derive(Debug, Parser)]
24#[command(
25    name = "git-slop",
26    about = "Find the files that cost too much context.",
27    version = VERSION
28)]
29struct Cli {
30    /// Repository or path inside a repository to analyze.
31    #[arg(long, global = true, value_name = "PATH")]
32    repo: Option<PathBuf>,
33    #[command(subcommand)]
34    command: Command,
35}
36
37#[derive(Debug, Subcommand)]
38enum Command {
39    /// Scaffold .slop/ config, ignore rules, and state directories.
40    Init(InitArgs),
41    /// Scan the repository and generate hotspot reports.
42    Find(FindArgs),
43    /// Show metrics and reasons for one file or folder.
44    Show(ShowArgs),
45    /// Explain why selected hotspots or structural findings are expensive.
46    Explain(ExplainArgs),
47    /// Propose bounded maintenance slices from the current detector report.
48    Plan(PlanArgs),
49    /// Evaluate an existing report against CI thresholds.
50    Check(CheckArgs),
51    /// Compare two existing schema-4 reports without rerunning the detector.
52    Compare(CompareArgs),
53    /// Validate or inspect the versioned report contract.
54    Report(ReportArgs),
55    /// Export action-queue findings from an existing schema-4 report as SARIF.
56    Sarif(SarifArgs),
57    /// Render repository health for CI summaries, annotations, or automation.
58    Health(HealthArgs),
59    /// Inspect or migrate effective configuration.
60    Config(ConfigArgs),
61    /// Diagnose repository readiness and optionally write a redacted bundle.
62    Doctor(DoctorArgs),
63    /// List findings, relationships, clusters, or profiles.
64    List(ListArgs),
65    /// Remove old immutable run snapshots according to retention policy.
66    Prune(PruneArgs),
67    /// Generate shell completion source.
68    Completions(CompletionsArgs),
69    /// Write a self-contained, local, searchable HTML report.
70    Html(HtmlArgs),
71    /// Print version information.
72    Version,
73    /// Print package and source-build provenance.
74    BuildInfo(BuildInfoArgs),
75}
76
77#[derive(Debug, Args)]
78struct FindArgs {
79    /// Acknowledge incomplete history and continue in a shallow clone.
80    #[arg(long)]
81    allow_shallow: bool,
82    /// Analyze only this repo-relative path while retaining repository-wide Git evidence.
83    #[arg(long)]
84    scope: Option<String>,
85    /// Permit a scope that selects no tracked paths and emit an empty analysis.
86    #[arg(long)]
87    allow_empty_scope: bool,
88    /// Suppress human progress and report-path messages.
89    #[arg(long)]
90    quiet: bool,
91    /// Suppress phase progress while preserving the final result.
92    #[arg(long)]
93    no_progress: bool,
94}
95
96#[derive(Debug, Args)]
97struct BuildInfoArgs {
98    #[arg(long, value_enum, default_value_t = BuildInfoFormat::Json)]
99    format: BuildInfoFormat,
100}
101
102#[derive(Debug, Args)]
103struct InitArgs {
104    /// Overwrite generated config files.
105    #[arg(long)]
106    force: bool,
107}
108
109#[derive(Debug, Args)]
110struct ShowArgs {
111    /// Repo-relative file or folder path.
112    target_path: String,
113    /// Report path. Defaults to .slop/latest/report.json.
114    #[arg(long)]
115    report: Option<PathBuf>,
116    #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
117    format: DisplayFormat,
118}
119
120#[derive(Debug, Args)]
121#[command(group(
122    ArgGroup::new("selector")
123        .args(["path", "cluster", "relationship", "top"])
124        .multiple(false)
125))]
126struct ExplainArgs {
127    /// Report path. Defaults to .slop/latest/report.json.
128    #[arg(long)]
129    report: Option<PathBuf>,
130    /// Repo-relative file or folder path.
131    #[arg(long)]
132    path: Option<String>,
133    /// Cluster identifier.
134    #[arg(long)]
135    cluster: Option<String>,
136    /// Relationship identifier.
137    #[arg(long)]
138    relationship: Option<String>,
139    /// Explain the top N hotspots from the action queue.
140    #[arg(long)]
141    top: Option<i64>,
142    #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
143    format: DisplayFormat,
144    /// Write a deterministic local-model prompt pack to this directory.
145    #[arg(long)]
146    prompt_pack: Option<PathBuf>,
147}
148
149#[derive(Debug, Args)]
150#[command(group(
151    ArgGroup::new("selector")
152        .args(["path", "cluster", "relationship"])
153        .required(true)
154        .multiple(false)
155))]
156struct PlanArgs {
157    /// Report path. Defaults to .slop/latest/report.json.
158    #[arg(long)]
159    report: Option<PathBuf>,
160    /// Repo-relative file or folder path.
161    #[arg(long)]
162    path: Option<String>,
163    /// Cluster identifier.
164    #[arg(long)]
165    cluster: Option<String>,
166    /// Relationship identifier.
167    #[arg(long)]
168    relationship: Option<String>,
169    /// Maximum number of bounded maintenance slices to propose.
170    #[arg(long, default_value_t = 3)]
171    max_slices: i64,
172    #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
173    format: DisplayFormat,
174    /// Write a deterministic local-model prompt pack to this directory.
175    #[arg(long)]
176    prompt_pack: Option<PathBuf>,
177}
178
179#[derive(Debug, Args)]
180struct CheckArgs {
181    /// Report path. Defaults to .slop/latest/report.json.
182    #[arg(long)]
183    report: Option<PathBuf>,
184    /// Override the config default fail threshold for context_band.
185    #[arg(long, value_enum)]
186    fail_on_context_band: Option<ContextBand>,
187    /// Override the config default fail threshold for slop_band.
188    #[arg(long, value_enum)]
189    fail_on_slop_band: Option<SlopBand>,
190    #[arg(long, value_enum, default_value_t = CheckFormat::Text)]
191    format: CheckFormat,
192    /// Include complete finding records in JSON output.
193    #[arg(long)]
194    details: bool,
195}
196
197#[derive(Debug, Args)]
198struct CompareArgs {
199    /// Base report.json path.
200    #[arg(long)]
201    base: PathBuf,
202    /// Head report.json path.
203    #[arg(long)]
204    head: PathBuf,
205    /// Maximum number of changed files and queue movements to show.
206    #[arg(long, default_value_t = 10)]
207    top: i64,
208    #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
209    format: DisplayFormat,
210    /// Compare reports with incompatible identity or analyzer metadata.
211    #[arg(long)]
212    force: bool,
213    /// Exit 1 when an existing file worsens or a newly added file is a finding.
214    #[arg(long)]
215    fail_on_regression: bool,
216}
217
218#[derive(Debug, Args)]
219struct ReportArgs {
220    #[command(subcommand)]
221    command: ReportCommand,
222}
223
224#[derive(Debug, Subcommand)]
225enum ReportCommand {
226    /// Validate one report against the complete schema-4 contract.
227    Validate {
228        #[arg(value_name = "REPORT_JSON")]
229        path: PathBuf,
230    },
231    /// Print the published JSON Schema for report schema 4.
232    Schema,
233}
234
235#[derive(Debug, Args)]
236struct SarifArgs {
237    /// Report path. Defaults to .slop/latest/report.json.
238    #[arg(long)]
239    report: Option<PathBuf>,
240    /// Maximum number of action-queue findings to export.
241    #[arg(long)]
242    top: Option<i64>,
243    /// Optional SARIF output path. Defaults to stdout.
244    #[arg(long)]
245    output: Option<PathBuf>,
246}
247
248#[derive(Debug, Args)]
249struct HealthArgs {
250    /// Report path. Defaults to .slop/latest/report.json.
251    #[arg(long)]
252    report: Option<PathBuf>,
253    /// Output suited for a job summary, workflow annotations, or automation.
254    #[arg(long, value_enum, default_value_t = HealthFormat::Text)]
255    format: HealthFormat,
256    /// Maximum number of GitHub workflow annotations to emit.
257    #[arg(long, default_value_t = 10)]
258    max_annotations: usize,
259}
260
261#[derive(Debug, Args)]
262struct ConfigArgs {
263    #[command(subcommand)]
264    command: ConfigCommand,
265}
266
267#[derive(Debug, Subcommand)]
268enum ConfigCommand {
269    /// Show configuration; --effective includes defaults.
270    Show {
271        #[arg(long)]
272        effective: bool,
273    },
274    /// Validate the local configuration.
275    Validate,
276    /// Show only values that differ from defaults.
277    DiffDefaults,
278    /// Rewrite legacy schema configuration as a minimal schema-2 override.
279    Migrate,
280    /// Print the supported configuration schema as JSON.
281    Schema,
282}
283
284#[derive(Debug, Args)]
285struct DoctorArgs {
286    /// Write a privacy-safe diagnostic JSON bundle.
287    #[arg(long, num_args = 0..=1, default_missing_value = ".slop/diagnostic-bundle.json")]
288    bundle: Option<PathBuf>,
289    #[arg(long, value_enum, default_value_t = DoctorFormat::Text)]
290    format: DoctorFormat,
291}
292
293#[derive(Debug, Clone, Copy, ValueEnum)]
294enum DoctorFormat {
295    Text,
296    Json,
297}
298
299#[derive(Debug, Args)]
300struct ListArgs {
301    #[command(subcommand)]
302    command: ListCommand,
303}
304
305#[derive(Debug, Subcommand)]
306enum ListCommand {
307    Findings(ListFilterArgs),
308    Relationships(ListFilterArgs),
309    Clusters(ListFilterArgs),
310    Profiles(ListFilterArgs),
311}
312
313#[derive(Debug, Args)]
314struct ListFilterArgs {
315    #[arg(long)]
316    report: Option<PathBuf>,
317    #[arg(long)]
318    path: Option<String>,
319    #[arg(long)]
320    profile: Option<String>,
321    #[arg(long)]
322    language: Option<String>,
323    #[arg(long, visible_alias = "class")]
324    classification: Option<String>,
325    #[arg(long)]
326    severity: Option<String>,
327    #[arg(long, default_value_t = 50)]
328    top: usize,
329    #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
330    format: DisplayFormat,
331}
332
333#[derive(Debug, Args)]
334struct PruneArgs {
335    /// Number of newest run snapshots to retain; defaults to output.retention_runs.
336    #[arg(long)]
337    keep: Option<usize>,
338    /// Print removals without changing files.
339    #[arg(long)]
340    dry_run: bool,
341}
342
343#[derive(Debug, Args)]
344struct CompletionsArgs {
345    shell: CompletionShell,
346}
347
348#[derive(Debug, Args)]
349struct HtmlArgs {
350    /// Report path. Defaults to .slop/latest/report.json.
351    #[arg(long)]
352    report: Option<PathBuf>,
353    /// Destination. Defaults to .slop/latest/report.html.
354    #[arg(long)]
355    output: Option<PathBuf>,
356}
357
358#[derive(Debug, Clone, Copy, ValueEnum)]
359enum CompletionShell {
360    Bash,
361    Zsh,
362    Fish,
363    Powershell,
364    Nushell,
365}
366
367#[derive(Debug, Clone, Copy, ValueEnum)]
368enum DisplayFormat {
369    Text,
370    Json,
371    Yaml,
372}
373
374#[derive(Debug, Clone, Copy, ValueEnum)]
375enum HealthFormat {
376    Text,
377    Markdown,
378    Github,
379    Json,
380}
381
382#[derive(Debug, Clone, Copy, ValueEnum)]
383enum BuildInfoFormat {
384    Json,
385}
386
387#[derive(Debug, Clone, Copy, ValueEnum)]
388enum CheckFormat {
389    Text,
390    Json,
391    Github,
392}
393
394#[derive(Debug, Clone, Copy, ValueEnum)]
395enum ContextBand {
396    Compact,
397    Healthy,
398    Warning,
399    Critical,
400}
401
402impl ContextBand {
403    fn as_str(self) -> &'static str {
404        match self {
405            Self::Compact => "compact",
406            Self::Healthy => "healthy",
407            Self::Warning => "warning",
408            Self::Critical => "critical",
409        }
410    }
411}
412
413#[derive(Debug, Clone, Copy, ValueEnum)]
414enum SlopBand {
415    Low,
416    Moderate,
417    High,
418    Critical,
419}
420
421impl SlopBand {
422    fn as_str(self) -> &'static str {
423        match self {
424            Self::Low => "low",
425            Self::Moderate => "moderate",
426            Self::High => "high",
427            Self::Critical => "critical",
428        }
429    }
430}
431
432fn print_text(value: &str) {
433    if value.is_empty() {
434        return;
435    }
436    print!("{value}");
437    if !value.ends_with('\n') {
438        println!();
439    }
440}
441
442fn safe_terminal(value: &str) -> String {
443    value
444        .chars()
445        .filter(|character| *character == '\n' || *character == '\t' || !character.is_control())
446        .collect()
447}
448
449fn relative_display(path: &Path, root: &Path) -> String {
450    path.strip_prefix(root)
451        .unwrap_or(path)
452        .to_string_lossy()
453        .replace('\\', "/")
454}
455
456fn default_report_path(repo_root: &Path) -> PathBuf {
457    config::latest_dir(repo_root).join("report.json")
458}
459
460fn load_report_at(path: &Path) -> Result<Option<Value>> {
461    if !path.exists() {
462        return Ok(None);
463    }
464    report::load_report(path).map(Some)
465}
466
467fn load_default_report(
468    repo_root: &Path,
469    explicit_report: Option<&Path>,
470) -> Result<Option<(Value, PathBuf)>> {
471    let path = explicit_report
472        .map(Path::to_path_buf)
473        .unwrap_or_else(|| default_report_path(repo_root));
474    Ok(load_report_at(&path)?.map(|report| (report, path)))
475}
476
477fn report_or_missing(
478    repo_root: &Path,
479    explicit_report: Option<&Path>,
480) -> Result<Result<(Value, PathBuf), i32>> {
481    let fallback = explicit_report
482        .map(Path::to_path_buf)
483        .unwrap_or_else(|| default_report_path(repo_root));
484    let loaded = match load_default_report(repo_root, explicit_report) {
485        Ok(loaded) => loaded,
486        Err(error) => {
487            eprintln!("{error:#}");
488            return Ok(Err(2));
489        }
490    };
491    Ok(match loaded {
492        Some(loaded) => Ok(loaded),
493        None => {
494            eprintln!(
495                "Report not found: {}\nRun `git slop find` to generate it.",
496                fallback.display()
497            );
498            Err(2)
499        }
500    })
501}
502
503fn lexical_normalize(path: &Path) -> PathBuf {
504    let mut result = PathBuf::new();
505    for component in path.components() {
506        match component {
507            Component::CurDir => {}
508            Component::ParentDir => {
509                if !result.pop() {
510                    result.push(component.as_os_str());
511                }
512            }
513            _ => result.push(component.as_os_str()),
514        }
515    }
516    result
517}
518
519fn selector_path(repo_root: &Path, input: &str) -> String {
520    let candidate = if Path::new(input).is_absolute() {
521        lexical_normalize(Path::new(input))
522    } else {
523        lexical_normalize(&repo_root.join(input))
524    };
525    candidate
526        .strip_prefix(repo_root)
527        .ok()
528        .map(|path| path.to_string_lossy().replace('\\', "/"))
529        .unwrap_or_else(|| {
530            let trimmed = input.trim();
531            if trimmed.is_empty() {
532                ".".to_string()
533            } else {
534                trimmed.to_string()
535            }
536        })
537}
538
539fn usage_error(error: impl std::fmt::Display) -> Result<i32> {
540    eprintln!("{error}");
541    Ok(2)
542}
543
544fn ensure_prompt_pack_target(path: &Path) -> Result<Result<(), i32>> {
545    if path.exists() && !path.is_dir() {
546        eprintln!("Prompt pack path is not a directory: {}", path.display());
547        Ok(Err(2))
548    } else {
549        Ok(Ok(()))
550    }
551}
552
553fn run_init(repo_root: &Path, args: InitArgs) -> Result<i32> {
554    let result = config::initialize(repo_root, args.force)?;
555    println!(
556        "Initialized {} ({}).",
557        relative_display(&config::config_path(repo_root), repo_root),
558        result.config
559    );
560    println!(
561        "Initialized {} ({}).",
562        relative_display(&config::slop_dir(repo_root).join(".gitignore"), repo_root),
563        result.gitignore
564    );
565    println!("Ensured .slop/latest/, .slop/runs/, and .slop/cache/ exist.");
566    Ok(0)
567}
568
569fn run_find(repo_root: &Path, args: FindArgs) -> Result<i32> {
570    let result = analyze::run_find_with_options(
571        repo_root,
572        &analyze::FindOptions {
573            allow_shallow: args.allow_shallow,
574            scope: args.scope,
575            progress: !args.quiet && !args.no_progress && std::io::stderr().is_terminal(),
576            allow_empty_scope: args.allow_empty_scope,
577        },
578    )?;
579    if args.quiet {
580        return Ok(0);
581    }
582    print_text(&result.terminal);
583    println!("Wrote report to {}.", result.report_json.display());
584    if result.report_yaml.exists() {
585        println!("Wrote YAML report to {}.", result.report_yaml.display());
586    }
587    println!("Wrote summary to {}.", result.summary_md.display());
588    println!(
589        "Wrote repository health summary to {}.",
590        result.health_md.display()
591    );
592    Ok(0)
593}
594
595fn run_show(repo_root: &Path, args: ShowArgs) -> Result<i32> {
596    let (loaded, report_path) = match report_or_missing(repo_root, args.report.as_deref())? {
597        Ok(value) => value,
598        Err(code) => return Ok(code),
599    };
600    let target = selector_path(repo_root, &args.target_path);
601    let Some(payload) = show_payload(&loaded, &target) else {
602        eprintln!(
603            "No record found for '{}' in {}.",
604            args.target_path,
605            report_path.display()
606        );
607        return Ok(2);
608    };
609    match args.format {
610        DisplayFormat::Json => print_text(&render_json(&payload)?),
611        DisplayFormat::Text => print_text(&render_show_text(&payload)),
612        DisplayFormat::Yaml => print_text(&serde_yaml::to_string(&payload)?),
613    }
614    Ok(0)
615}
616
617fn explain_selector(args: &ExplainArgs, repo_root: &Path) -> Result<ExplainSelector, i32> {
618    if let Some(path) = &args.path {
619        Ok(ExplainSelector::Path(selector_path(repo_root, path)))
620    } else if let Some(id) = &args.cluster {
621        Ok(ExplainSelector::Cluster(id.clone()))
622    } else if let Some(id) = &args.relationship {
623        Ok(ExplainSelector::Relationship(id.clone()))
624    } else {
625        let count = args.top.unwrap_or(5);
626        match usize::try_from(count).ok().filter(|count| *count > 0) {
627            Some(count) => Ok(ExplainSelector::Top(count)),
628            None => Err(2),
629        }
630    }
631}
632
633fn run_explain(repo_root: &Path, args: ExplainArgs) -> Result<i32> {
634    let (loaded, _) = match report_or_missing(repo_root, args.report.as_deref())? {
635        Ok(value) => value,
636        Err(code) => return Ok(code),
637    };
638    let selector = match explain_selector(&args, repo_root) {
639        Ok(selector) => selector,
640        Err(code) => {
641            eprintln!("--top must be greater than zero.");
642            return Ok(code);
643        }
644    };
645    let payload = match explain_payload(&loaded, Some(selector)) {
646        Ok(payload) => payload,
647        Err(error) => return usage_error(error),
648    };
649    if let Some(output_dir) = args.prompt_pack.as_deref() {
650        if let Err(code) = ensure_prompt_pack_target(output_dir)? {
651            return Ok(code);
652        }
653        write_prompt_pack("explain", &payload, &loaded, output_dir)?;
654    }
655    match args.format {
656        DisplayFormat::Json => print_text(&render_json(&payload)?),
657        DisplayFormat::Text => print_text(&render_explain_text(&payload)),
658        DisplayFormat::Yaml => print_text(&serde_yaml::to_string(&payload)?),
659    }
660    Ok(0)
661}
662
663fn plan_selector(args: &PlanArgs, repo_root: &Path) -> PlanSelector {
664    if let Some(path) = &args.path {
665        PlanSelector::Path(selector_path(repo_root, path))
666    } else if let Some(id) = &args.cluster {
667        PlanSelector::Cluster(id.clone())
668    } else {
669        PlanSelector::Relationship(args.relationship.clone().unwrap_or_default())
670    }
671}
672
673fn run_plan(repo_root: &Path, args: PlanArgs) -> Result<i32> {
674    let (loaded, _) = match report_or_missing(repo_root, args.report.as_deref())? {
675        Ok(value) => value,
676        Err(code) => return Ok(code),
677    };
678    let Some(max_slices) = usize::try_from(args.max_slices)
679        .ok()
680        .filter(|count| *count > 0)
681    else {
682        eprintln!("--max-slices must be greater than zero.");
683        return Ok(2);
684    };
685    let payload = match plan_payload(&loaded, plan_selector(&args, repo_root), max_slices) {
686        Ok(payload) => payload,
687        Err(error) => return usage_error(error),
688    };
689    if let Some(output_dir) = args.prompt_pack.as_deref() {
690        if let Err(code) = ensure_prompt_pack_target(output_dir)? {
691            return Ok(code);
692        }
693        write_prompt_pack("plan", &payload, &loaded, output_dir)?;
694    }
695    match args.format {
696        DisplayFormat::Json => print_text(&render_json(&payload)?),
697        DisplayFormat::Text => print_text(&render_plan_text(&payload)),
698        DisplayFormat::Yaml => print_text(&serde_yaml::to_string(&payload)?),
699    }
700    Ok(0)
701}
702
703fn run_check(repo_root: &Path, args: CheckArgs) -> Result<i32> {
704    let (loaded, _) = match report_or_missing(repo_root, args.report.as_deref())? {
705        Ok(value) => value,
706        Err(code) => return Ok(code),
707    };
708    let loaded_config = loaded
709        .get("config")
710        .cloned()
711        .unwrap_or_else(config::default_config);
712    let context_band = args
713        .fail_on_context_band
714        .map(ContextBand::as_str)
715        .or_else(|| {
716            loaded_config
717                .pointer("/check/fail_on_context_band")
718                .and_then(Value::as_str)
719        })
720        .unwrap_or("critical");
721    let slop_band = args
722        .fail_on_slop_band
723        .map(SlopBand::as_str)
724        .or_else(|| {
725            loaded_config
726                .pointer("/check/fail_on_slop_band")
727                .and_then(Value::as_str)
728        })
729        .unwrap_or("critical");
730    let failures = failing_records(&loaded, Some(context_band), Some(slop_band));
731    if !matches!(args.format, CheckFormat::Text) {
732        match args.format {
733            CheckFormat::Json => {
734                let mut payload = json!({
735                    "schema_version": 1,
736                    "command": "check",
737                    "report": {"schema_version": loaded.get("schema_version"), "analyzer": loaded.get("analyzer"), "repo": loaded.get("repo"), "scope": loaded.get("scope")},
738                    "boundary": {"context_band": context_band, "slop_band": slop_band},
739                    "passed": failures.is_empty(),
740                    "finding_count": failures.len(),
741                    "details_included": args.details,
742                });
743                if args.details {
744                    payload["findings"] = json!(failures);
745                }
746                print_text(&render_json(&payload)?);
747            }
748            CheckFormat::Github => {
749                for failure in &failures {
750                    let path = safe_terminal(
751                        failure
752                            .get("path")
753                            .and_then(Value::as_str)
754                            .unwrap_or_default(),
755                    );
756                    println!(
757                        "::error file={}::Git Slop context={} slop={} score={}",
758                        path.replace('%', "%25").replace(',', "%2C"),
759                        failure
760                            .get("context_band")
761                            .and_then(Value::as_str)
762                            .unwrap_or_default(),
763                        failure
764                            .get("slop_band")
765                            .and_then(Value::as_str)
766                            .unwrap_or_default(),
767                        failure.get("slop_score").unwrap_or(&Value::Null)
768                    );
769                }
770            }
771            CheckFormat::Text => {}
772        }
773        return Ok(if failures.is_empty() { 0 } else { 1 });
774    }
775    if failures.is_empty() {
776        println!(
777            "Check passed: no file records met or exceeded context={context_band} or slop={slop_band}."
778        );
779        return Ok(0);
780    }
781    println!(
782        "Check failed: {} file records met or exceeded context={context_band} or slop={slop_band}.",
783        failures.len()
784    );
785    for failure in failures.iter().take(10) {
786        println!(
787            "- {} (slop={}, context={}, slop_score={})",
788            failure
789                .get("path")
790                .and_then(Value::as_str)
791                .unwrap_or_default(),
792            failure
793                .get("slop_band")
794                .and_then(Value::as_str)
795                .unwrap_or_default(),
796            failure
797                .get("context_band")
798                .and_then(Value::as_str)
799                .unwrap_or_default(),
800            failure
801                .get("slop_score")
802                .map(ToString::to_string)
803                .unwrap_or_else(|| "null".to_string()),
804        );
805    }
806    Ok(1)
807}
808
809fn run_compare(args: CompareArgs) -> Result<i32> {
810    let Some(base_report) = (match load_report_at(&args.base) {
811        Ok(report) => report,
812        Err(error) => {
813            eprintln!("{error:#}");
814            return Ok(2);
815        }
816    }) else {
817        eprintln!("Report not found: {}", args.base.display());
818        return Ok(2);
819    };
820    let Some(head_report) = (match load_report_at(&args.head) {
821        Ok(report) => report,
822        Err(error) => {
823            eprintln!("{error:#}");
824            return Ok(2);
825        }
826    }) else {
827        eprintln!("Report not found: {}", args.head.display());
828        return Ok(2);
829    };
830    let Some(top) = usize::try_from(args.top).ok().filter(|count| *count > 0) else {
831        eprintln!("--top must be greater than zero.");
832        return Ok(2);
833    };
834    let payload = match compare_payload_with_force(
835        &base_report,
836        &head_report,
837        Some(&args.base.to_string_lossy()),
838        Some(&args.head.to_string_lossy()),
839        top,
840        args.force,
841    ) {
842        Ok(payload) => payload,
843        Err(error) => return usage_error(error),
844    };
845    match args.format {
846        DisplayFormat::Json => print_text(&render_json(&payload)?),
847        DisplayFormat::Text => print_text(&render_compare_text(&payload, top)),
848        DisplayFormat::Yaml => print_text(&serde_yaml::to_string(&payload)?),
849    }
850    let regressions = payload
851        .pointer("/summary/regression_count")
852        .and_then(Value::as_u64)
853        .unwrap_or_default();
854    Ok(if args.fail_on_regression && regressions > 0 {
855        1
856    } else {
857        0
858    })
859}
860
861fn run_report(args: ReportArgs) -> Result<i32> {
862    match args.command {
863        ReportCommand::Validate { path } => match report::load_report(&path) {
864            Ok(value) => {
865                println!(
866                    "Report is valid: {} (schema {}).",
867                    path.display(),
868                    value["schema_version"]
869                );
870                Ok(0)
871            }
872            Err(error) => {
873                eprintln!("{error:#}");
874                Ok(2)
875            }
876        },
877        ReportCommand::Schema => {
878            print_text(&render_json(&report::schema())?);
879            Ok(0)
880        }
881    }
882}
883
884fn run_sarif(repo_root: &Path, args: SarifArgs) -> Result<i32> {
885    let (loaded, report_path) = match report_or_missing(repo_root, args.report.as_deref())? {
886        Ok(value) => value,
887        Err(code) => return Ok(code),
888    };
889    let top = match args.top {
890        None => None,
891        Some(value) => match usize::try_from(value).ok().filter(|count| *count > 0) {
892            Some(value) => Some(value),
893            None => {
894                eprintln!("--top must be greater than zero.");
895                return Ok(2);
896            }
897        },
898    };
899    let payload = match sarif_payload(&loaded, Some(&report_path.to_string_lossy()), top) {
900        Ok(payload) => payload,
901        Err(error) => return usage_error(error),
902    };
903    let rendered = render_json(&payload)?;
904    if let Some(output) = args.output {
905        if let Some(parent) = output
906            .parent()
907            .filter(|parent| !parent.as_os_str().is_empty())
908        {
909            fs::create_dir_all(parent)
910                .with_context(|| format!("failed to create {}", parent.display()))?;
911        }
912        fs::write(&output, rendered)
913            .with_context(|| format!("failed to write {}", output.display()))?;
914        println!("Wrote SARIF report to {}.", output.display());
915    } else {
916        print_text(&rendered);
917    }
918    Ok(0)
919}
920
921fn run_health(repo_root: &Path, args: HealthArgs) -> Result<i32> {
922    let (mut loaded, _) = match report_or_missing(repo_root, args.report.as_deref())? {
923        Ok(value) => value,
924        Err(code) => return Ok(code),
925    };
926    let rollup = match health::health_rollup_from_report(&loaded) {
927        Ok(rollup) => rollup,
928        Err(error) => return usage_error(error),
929    };
930    let mut health_value = serde_json::to_value(rollup)?;
931    if let (Some(existing), Some(derived)) = (
932        loaded.get("health").and_then(Value::as_object),
933        health_value.as_object_mut(),
934    ) {
935        for (key, value) in existing {
936            derived.entry(key.clone()).or_insert_with(|| value.clone());
937        }
938    }
939    if let Some(object) = loaded.as_object_mut() {
940        object.insert("health".to_string(), health_value);
941    }
942    match args.format {
943        HealthFormat::Text => print_text(&report::render_terminal(&loaded)),
944        HealthFormat::Markdown => {
945            let rendered = match health::render_health_from_report(&loaded) {
946                Ok(rendered) => rendered,
947                Err(error) => return usage_error(error),
948            };
949            print_text(&rendered);
950        }
951        HealthFormat::Github => {
952            print_text(&render_github_annotations(&loaded, args.max_annotations));
953        }
954        HealthFormat::Json => {
955            print_text(&render_json(&health_json_payload(&loaded))?);
956        }
957    }
958    Ok(0)
959}
960
961fn diff_values(current: &Value, defaults: &Value) -> Value {
962    match (current, defaults) {
963        (Value::Object(current), Value::Object(defaults)) => {
964            let mut result = serde_json::Map::new();
965            for (key, value) in current {
966                if matches!(key.as_str(), "tokenizer" | "context_bands") {
967                    continue;
968                }
969                let difference = defaults
970                    .get(key)
971                    .map_or_else(|| value.clone(), |default| diff_values(value, default));
972                if !difference.is_null()
973                    && !difference
974                        .as_object()
975                        .is_some_and(serde_json::Map::is_empty)
976                {
977                    result.insert(key.clone(), difference);
978                }
979            }
980            Value::Object(result)
981        }
982        _ if current == defaults => Value::Null,
983        _ => current.clone(),
984    }
985}
986
987fn run_config(repo_root: &Path, args: ConfigArgs) -> Result<i32> {
988    match args.command {
989        ConfigCommand::Show { effective } => {
990            if effective {
991                print_text(&serde_yaml::to_string(&config::load(repo_root)?)?);
992            } else {
993                let path = config::config_path(repo_root);
994                if path.exists() {
995                    print_text(&fs::read_to_string(path)?);
996                } else {
997                    print_text(config::MINIMAL_CONFIG);
998                }
999            }
1000        }
1001        ConfigCommand::Validate => {
1002            config::load(repo_root)?;
1003            let path = config::config_path(repo_root);
1004            if path.exists() {
1005                println!("Configuration is valid: {}", path.display());
1006            } else {
1007                println!(
1008                    "Configuration is valid: built-in defaults ({} is absent).",
1009                    path.display()
1010                );
1011            }
1012        }
1013        ConfigCommand::DiffDefaults => {
1014            let diff = diff_values(&config::load(repo_root)?, &config::default_config());
1015            print_text(&serde_yaml::to_string(&diff)?);
1016        }
1017        ConfigCommand::Migrate => {
1018            let effective = config::load(repo_root)?;
1019            let mut diff = diff_values(&effective, &config::default_config());
1020            if let Some(object) = diff.as_object_mut() {
1021                object.insert("schema_version".into(), json!(2));
1022            }
1023            config::ensure_state_dirs(repo_root)?;
1024            fs::write(
1025                config::config_path(repo_root),
1026                serde_yaml::to_string(&diff)?,
1027            )?;
1028            println!(
1029                "Migrated {} to schema 2.",
1030                config::config_path(repo_root).display()
1031            );
1032        }
1033        ConfigCommand::Schema => print_text(&render_json(&config::schema())?),
1034    }
1035    Ok(0)
1036}
1037
1038fn run_doctor(repo_root: &Path, args: DoctorArgs) -> Result<i32> {
1039    let repo = git::repo_metadata(repo_root)?;
1040    let config_result = config::load(repo_root);
1041    let report_path = default_report_path(repo_root);
1042    let report_status = if report_path.exists() {
1043        match report::load_report(&report_path) {
1044            Ok(_) => "compatible",
1045            Err(_) => "invalid",
1046        }
1047    } else {
1048        "missing"
1049    };
1050    let tracked_paths = git::list_tracked_files(repo_root)?;
1051    let tracked = tracked_paths.len();
1052    let effective_config = config_result
1053        .as_ref()
1054        .cloned()
1055        .unwrap_or_else(|_| config::default_config());
1056    let estimate = crate::estimate::build(repo_root, &tracked_paths, &effective_config);
1057    let diagnostic = json!({
1058        "schema_version": 1,
1059        "command": "doctor",
1060        "status": if config_result.is_err() || report_status == "invalid" { "error" } else { "ready" },
1061        "repository": {"name": repo.repo_name, "branch": repo.branch, "shallow": repo.is_shallow, "detached": repo.detached_head, "clean": repo.worktree_clean},
1062        "config": {"status": if config_result.is_ok() { "valid" } else { "invalid" }, "path": config::config_path(repo_root)},
1063        "report": {"status": report_status, "path": report_path},
1064        "estimate": estimate,
1065        "recovery": {
1066            "config": "Run git slop config validate, then correct the reported key or run git slop config migrate.",
1067            "report": "Run git slop find to replace a missing or incompatible latest report.",
1068            "shallow": "Fetch full history or rerun find with --allow-shallow to acknowledge incomplete evidence."
1069        }
1070    });
1071    if matches!(args.format, DoctorFormat::Json) {
1072        print_text(&render_json(&diagnostic)?);
1073    } else {
1074        println!("Git Slop doctor");
1075        println!("- git: available");
1076        println!("- repository: {}", repo.repo_name);
1077        println!(
1078            "- branch: {}",
1079            repo.branch.as_deref().unwrap_or(if repo.detached_head {
1080                "detached HEAD"
1081            } else {
1082                "unborn"
1083            })
1084        );
1085        println!(
1086            "- history: {}",
1087            if repo.is_shallow {
1088                "shallow (incomplete)"
1089            } else {
1090                "complete"
1091            }
1092        );
1093        println!(
1094            "- worktree: {} (staged={}, modified={}, untracked={})",
1095            if repo.worktree_clean {
1096                "clean"
1097            } else {
1098                "dirty"
1099            },
1100            repo.staged_change_count,
1101            repo.modified_tracked_file_count,
1102            repo.untracked_file_count
1103        );
1104        println!(
1105            "- config: {}",
1106            if config_result.is_ok() {
1107                "valid"
1108            } else {
1109                "invalid"
1110            }
1111        );
1112        println!("- report: {report_status}");
1113        println!(
1114            "- preflight: {tracked} tracked files; peak memory ~{} MiB; cache ~{} MiB; report ~{} MiB; time ~{}s; inodes ~{}",
1115            estimate.estimated_peak_memory_bytes.div_ceil(1024 * 1024),
1116            estimate.estimated_cache_bytes.div_ceil(1024 * 1024),
1117            estimate.estimated_report_bytes.div_ceil(1024 * 1024),
1118            estimate.estimated_seconds,
1119            estimate.estimated_inode_count,
1120        );
1121        if repo.is_shallow {
1122            println!("- recovery: fetch full history, or explicitly use --allow-shallow");
1123        }
1124        if config_result.is_err() {
1125            println!("- recovery: run `git slop config validate` and correct the reported key");
1126        }
1127        if report_status != "compatible" {
1128            println!("- recovery: run `git slop find` to produce a compatible latest report");
1129        }
1130    }
1131    if let Some(output) = args.bundle {
1132        let output = if output.is_absolute() {
1133            output
1134        } else {
1135            repo_root.join(output)
1136        };
1137        if let Some(parent) = output.parent() {
1138            fs::create_dir_all(parent)?;
1139        }
1140        let config_digest = config_result
1141            .as_ref()
1142            .ok()
1143            .and_then(|value| serde_json::to_vec(value).ok())
1144            .map(|bytes| hex::encode(sha2::Sha256::digest(bytes)));
1145        let payload = json!({
1146            "schema_version": 1,
1147            "git_slop_version": VERSION,
1148            "repository": {"name": repo.repo_name, "shallow": repo.is_shallow, "detached": repo.detached_head, "clean": repo.worktree_clean, "staged": repo.staged_change_count, "modified": repo.modified_tracked_file_count, "untracked_count": repo.untracked_file_count},
1149            "config_digest": config_digest,
1150            "report_status": report_status,
1151            "estimate": estimate,
1152            "privacy": {"source_included": false, "raw_tokens_included": false, "absolute_paths_included": false, "author_identities_included": false, "credentials_included": false}
1153        });
1154        fs::write(&output, render_json(&payload)?)?;
1155        println!("Wrote redacted diagnostic bundle to {}.", output.display());
1156    }
1157    Ok(if config_result.is_err() || report_status == "invalid" {
1158        2
1159    } else {
1160        0
1161    })
1162}
1163
1164fn matches_list_filter(item: &Value, args: &ListFilterArgs) -> bool {
1165    args.path.as_ref().is_none_or(|path| {
1166        item.get("path")
1167            .and_then(Value::as_str)
1168            .is_some_and(|value| value.starts_with(path))
1169    }) && args
1170        .profile
1171        .as_ref()
1172        .is_none_or(|value| item.get("profile").and_then(Value::as_str) == Some(value))
1173        && args
1174            .language
1175            .as_ref()
1176            .is_none_or(|value| item.get("language").and_then(Value::as_str) == Some(value))
1177        && args.classification.as_ref().is_none_or(|value| {
1178            item.get("classification")
1179                .or_else(|| item.get("class"))
1180                .and_then(Value::as_str)
1181                == Some(value)
1182        })
1183        && args
1184            .severity
1185            .as_ref()
1186            .is_none_or(|value| item.get("severity").and_then(Value::as_str) == Some(value))
1187}
1188
1189fn run_list(repo_root: &Path, args: ListArgs) -> Result<i32> {
1190    let filter = match &args.command {
1191        ListCommand::Findings(v)
1192        | ListCommand::Relationships(v)
1193        | ListCommand::Clusters(v)
1194        | ListCommand::Profiles(v) => v,
1195    };
1196    let (loaded, _) = match report_or_missing(repo_root, filter.report.as_deref())? {
1197        Ok(value) => value,
1198        Err(code) => return Ok(code),
1199    };
1200    let mut values = match &args.command {
1201        ListCommand::Findings(_) => loaded
1202            .pointer("/health/findings")
1203            .and_then(Value::as_array)
1204            .cloned()
1205            .unwrap_or_default(),
1206        ListCommand::Relationships(_) => loaded
1207            .get("relationships")
1208            .and_then(Value::as_object)
1209            .into_iter()
1210            .flat_map(|map| map.values())
1211            .filter_map(Value::as_array)
1212            .flatten()
1213            .cloned()
1214            .collect(),
1215        ListCommand::Clusters(_) => loaded
1216            .get("clusters")
1217            .and_then(Value::as_object)
1218            .into_iter()
1219            .flat_map(|map| map.values())
1220            .filter_map(Value::as_array)
1221            .flatten()
1222            .cloned()
1223            .collect(),
1224        ListCommand::Profiles(_) => loaded
1225            .pointer("/health/profile_rollups")
1226            .and_then(Value::as_array)
1227            .cloned()
1228            .unwrap_or_default(),
1229    };
1230    values.retain(|item| matches_list_filter(item, filter));
1231    let total = values.len();
1232    values.truncate(filter.top);
1233    let returned = values.len();
1234    match filter.format {
1235        DisplayFormat::Json => print_text(&render_json(&json!({
1236            "schema_version": 1,
1237            "command": "list",
1238            "kind": match &args.command {
1239                ListCommand::Findings(_) => "findings",
1240                ListCommand::Relationships(_) => "relationships",
1241                ListCommand::Clusters(_) => "clusters",
1242                ListCommand::Profiles(_) => "profiles",
1243            },
1244            "items": values,
1245            "collection": {"total": total, "returned": returned, "limit": filter.top, "truncated": returned < total}
1246        }))?),
1247        DisplayFormat::Yaml => print_text(&serde_yaml::to_string(&values)?),
1248        DisplayFormat::Text => {
1249            println!(
1250                "{:<48}  {:<24}  {:<10}  {:>8}",
1251                "PATH OR NAME", "KIND", "BAND", "SCORE"
1252            );
1253            println!("{:-<48}  {:-<24}  {:-<10}  {:-<8}", "", "", "", "");
1254            for item in &values {
1255                let label = item
1256                    .get("path")
1257                    .or_else(|| item.get("name"))
1258                    .or_else(|| item.get("id"))
1259                    .and_then(Value::as_str)
1260                    .unwrap_or("-");
1261                let kind = item
1262                    .get("kind")
1263                    .or_else(|| item.get("profile"))
1264                    .and_then(Value::as_str)
1265                    .unwrap_or("-");
1266                let band = item
1267                    .get("severity")
1268                    .or_else(|| item.get("slop_band"))
1269                    .and_then(Value::as_str)
1270                    .unwrap_or("-");
1271                let score = item
1272                    .get("slop_score")
1273                    .or_else(|| item.get("evidence_score"))
1274                    .and_then(Value::as_f64)
1275                    .map_or_else(|| "-".to_string(), |value| format!("{value:.3}"));
1276                println!(
1277                    "{:<48}  {:<24}  {:<10}  {:>8}",
1278                    safe_terminal(label),
1279                    safe_terminal(kind),
1280                    safe_terminal(band),
1281                    score
1282                );
1283            }
1284            println!(
1285                "\nReturned {returned} of {total} matching record(s).{}",
1286                if returned < total {
1287                    " Increase --top to see more."
1288                } else {
1289                    ""
1290                }
1291            );
1292        }
1293    }
1294    Ok(0)
1295}
1296
1297fn run_prune(repo_root: &Path, args: PruneArgs) -> Result<i32> {
1298    let keep = args.keep.unwrap_or_else(|| {
1299        config::pointer_u64(
1300            &config::load(repo_root).unwrap_or_else(|_| config::default_config()),
1301            "/output/retention_runs",
1302            20,
1303        ) as usize
1304    });
1305    let root = config::runs_dir(repo_root);
1306    if !root.exists() {
1307        println!("No run snapshots to prune.");
1308        return Ok(0);
1309    }
1310    let mut runs = fs::read_dir(&root)?
1311        .filter_map(Result::ok)
1312        .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
1313        .collect::<Vec<_>>();
1314    runs.sort_by_key(|entry| std::cmp::Reverse(entry.file_name()));
1315    let remove = runs.into_iter().skip(keep).collect::<Vec<_>>();
1316    for entry in &remove {
1317        println!(
1318            "{} {}",
1319            if args.dry_run {
1320                "Would remove"
1321            } else {
1322                "Removing"
1323            },
1324            entry.path().display()
1325        );
1326        if !args.dry_run {
1327            fs::remove_dir_all(entry.path())?;
1328        }
1329    }
1330    println!(
1331        "{} {} old run snapshot(s); kept {keep}.",
1332        if args.dry_run { "Selected" } else { "Pruned" },
1333        remove.len()
1334    );
1335    Ok(0)
1336}
1337
1338fn run_completions(args: CompletionsArgs) -> Result<i32> {
1339    let mut command = Cli::command();
1340    let mut stdout = std::io::stdout().lock();
1341    match args.shell {
1342        CompletionShell::Bash => generate(Shell::Bash, &mut command, PROJECT_NAME, &mut stdout),
1343        CompletionShell::Zsh => generate(Shell::Zsh, &mut command, PROJECT_NAME, &mut stdout),
1344        CompletionShell::Fish => generate(Shell::Fish, &mut command, PROJECT_NAME, &mut stdout),
1345        CompletionShell::Powershell => {
1346            generate(Shell::PowerShell, &mut command, PROJECT_NAME, &mut stdout)
1347        }
1348        CompletionShell::Nushell => generate(
1349            clap_complete_nushell::Nushell,
1350            &mut command,
1351            PROJECT_NAME,
1352            &mut stdout,
1353        ),
1354    }
1355    Ok(0)
1356}
1357
1358fn run_html(repo_root: &Path, args: HtmlArgs) -> Result<i32> {
1359    let (loaded, _) = match report_or_missing(repo_root, args.report.as_deref())? {
1360        Ok(value) => value,
1361        Err(code) => return Ok(code),
1362    };
1363    let output = args
1364        .output
1365        .unwrap_or_else(|| config::latest_dir(repo_root).join("report.html"));
1366    if let Some(parent) = output.parent() {
1367        fs::create_dir_all(parent)?;
1368    }
1369    let payload = serde_json::to_string(&loaded)?.replace("</", "<\\/");
1370    let html = format!(
1371        r#"<!doctype html>
1372<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
1373<title>Git Slop local report</title><style>
1374:root {{ color-scheme: light dark; font: 15px system-ui,sans-serif }} body {{ margin: 2rem; max-width: 1100px }}
1375input,select {{ padding:.55rem; margin:0 .5rem .75rem 0 }} table {{ width:100%; border-collapse:collapse }}
1376th,td {{ text-align:left; padding:.5rem; border-bottom:1px solid #8885 }} th {{ cursor:pointer }}
1377code {{ overflow-wrap:anywhere }} details {{ margin:1rem 0 }} .muted {{ opacity:.7 }} .sr {{ position:absolute;left:-10000px }}
1378</style></head><body><h1>Git Slop local report</h1><p id="descriptor" class="muted"></p>
1379<label for="query" class="sr">Search paths</label><input id="query" type="search" placeholder="Search paths"><label for="profile" class="sr">Profile</label><select id="profile"><option value="">All profiles</option></select>
1380<label for="severity" class="sr">Maintenance band</label><select id="severity"><option value="">All maintenance bands</option><option>critical</option><option>high</option><option>moderate</option><option>low</option></select>
1381<p id="count" aria-live="polite"></p><button id="previous" type="button">Previous</button><button id="next" type="button">Next</button><table><caption class="sr">Analyzed files</caption><thead><tr><th scope="col" data-key="path">Path</th><th scope="col" data-key="profile">Profile</th><th scope="col" data-key="language">Language</th><th scope="col" data-key="slop_band">Maintenance</th><th scope="col" data-key="context_band">Context</th><th scope="col" data-key="slop_score">Score</th><th scope="col" data-key="tokens">Tokens</th></tr></thead><tbody id="rows"></tbody></table>
1382<details><summary>Relationships</summary><pre id="relationships"></pre></details>
1383<script id="report" type="application/json">{payload}</script><script>
1384const report=JSON.parse(document.getElementById('report').textContent); let sortKey='slop_score', ascending=false, page=0; const pageSize=100;
1385const files=report.files||[]; const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}}[c]));
1386document.getElementById('descriptor').textContent=`${{report.repo?.repo_name||'repository'}} · ${{report.generated_at||'unknown time'}} · schema ${{report.schema_version}}`;
1387const profile=document.getElementById('profile'); [...new Set(files.map(f=>f.profile).filter(Boolean))].sort().forEach(v=>profile.insertAdjacentHTML('beforeend',`<option>${{esc(v)}}</option>`));
1388function render() {{ const q=document.getElementById('query').value.toLowerCase(), p=profile.value, s=document.getElementById('severity').value;
1389 const selected=files.filter(f=>(!q||String(f.path).toLowerCase().includes(q))&&(!p||f.profile===p)&&(!s||f.slop_band===s)).sort((a,b)=>{{const x=a[sortKey],y=b[sortKey]; return (typeof x==='number'?x-y:String(x??'').localeCompare(String(y??'')))*(ascending?1:-1)}});
1390 const pages=Math.max(1,Math.ceil(selected.length/pageSize)); page=Math.min(page,pages-1); const visible=selected.slice(page*pageSize,(page+1)*pageSize);
1391 document.getElementById('count').textContent=`${{selected.length}} of ${{files.length}} files · page ${{page+1}} of ${{pages}}`;
1392 document.getElementById('previous').disabled=page===0; document.getElementById('next').disabled=page+1>=pages;
1393 document.getElementById('rows').innerHTML=visible.map(f=>`<tr><td><code>${{esc(f.path)}}</code></td><td>${{esc(f.profile)}}</td><td>${{esc(f.language)}}</td><td>${{esc(f.slop_band)}}</td><td>${{esc(f.context_band)}}</td><td>${{esc(f.slop_score)}}</td><td>${{esc(f.tokens)}}</td></tr>`).join(''); }}
1394document.querySelectorAll('input,select').forEach(el=>el.addEventListener('input',()=>{{page=0;render()}})); document.querySelectorAll('th').forEach(el=>el.addEventListener('click',()=>{{ascending=sortKey===el.dataset.key?!ascending:false;sortKey=el.dataset.key;page=0;render()}}));
1395document.getElementById('previous').addEventListener('click',()=>{{page=Math.max(0,page-1);render()}}); document.getElementById('next').addEventListener('click',()=>{{page+=1;render()}});
1396const rel=report.relationships||report.overlays?.organization_health?.relationships||{{}}; document.getElementById('relationships').textContent=JSON.stringify(rel,null,2); render();
1397</script></body></html>"#
1398    );
1399    fs::write(&output, html)?;
1400    println!("Wrote local HTML report to {}.", output.display());
1401    Ok(0)
1402}
1403
1404fn execute(repo_root: &Path, command: Command) -> Result<i32> {
1405    match command {
1406        Command::Init(args) => run_init(repo_root, args),
1407        Command::Find(args) => run_find(repo_root, args),
1408        Command::Show(args) => run_show(repo_root, args),
1409        Command::Explain(args) => run_explain(repo_root, args),
1410        Command::Plan(args) => run_plan(repo_root, args),
1411        Command::Check(args) => run_check(repo_root, args),
1412        Command::Compare(args) => run_compare(args),
1413        Command::Report(args) => run_report(args),
1414        Command::Sarif(args) => run_sarif(repo_root, args),
1415        Command::Health(args) => run_health(repo_root, args),
1416        Command::Config(args) => run_config(repo_root, args),
1417        Command::Doctor(args) => run_doctor(repo_root, args),
1418        Command::List(args) => run_list(repo_root, args),
1419        Command::Prune(args) => run_prune(repo_root, args),
1420        Command::Completions(args) => run_completions(args),
1421        Command::Html(args) => run_html(repo_root, args),
1422        Command::Version => {
1423            println!("{PROJECT_NAME} {VERSION}");
1424            Ok(0)
1425        }
1426        Command::BuildInfo(args) => {
1427            match args.format {
1428                BuildInfoFormat::Json => {
1429                    println!("{}", serde_json::to_string_pretty(&build_info::current())?)
1430                }
1431            }
1432            Ok(0)
1433        }
1434    }
1435}
1436
1437fn command_requires_repository(command: &Command) -> bool {
1438    !matches!(
1439        command,
1440        Command::Completions(_)
1441            | Command::Version
1442            | Command::BuildInfo(_)
1443            | Command::Report(_)
1444            | Command::Config(ConfigArgs {
1445                command: ConfigCommand::Schema
1446            })
1447    )
1448}
1449
1450pub fn run() -> i32 {
1451    let cli = match Cli::try_parse() {
1452        Ok(cli) => cli,
1453        Err(error) => {
1454            let code = error.exit_code();
1455            let _ = error.print();
1456            return code;
1457        }
1458    };
1459    let repo_root = if command_requires_repository(&cli.command) {
1460        match git::resolve_repo_root_from(cli.repo.as_deref()) {
1461            Ok(root) => root,
1462            Err(error) => {
1463                eprintln!("{error:#}");
1464                return 3;
1465            }
1466        }
1467    } else {
1468        PathBuf::new()
1469    };
1470    match execute(&repo_root, cli.command) {
1471        Ok(code) => code,
1472        Err(error) => {
1473            let rendered = format!("{error:#}");
1474            eprintln!("{rendered}");
1475            if rendered.contains("memory_budget_mb") || rendered.contains("analysis bounded") {
1476                4
1477            } else if rendered.contains("config")
1478                || rendered.contains("schema")
1479                || rendered.contains("unsupported tokenization")
1480            {
1481                2
1482            } else {
1483                3
1484            }
1485        }
1486    }
1487}