Skip to main content

git_slop/
cli.rs

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