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::error::{ClassifiedError, ErrorKind};
14use crate::health;
15use crate::report;
16use crate::report_ops::{
17    ExplainSelector, PlanSelector, compare_payload_with_force, explain_payload, failing_records,
18    health_json_payload, plan_payload, render_compare_text, render_explain_text,
19    render_github_annotations, render_json, render_plan_text, render_show_text, sarif_payload,
20    show_payload, write_prompt_pack,
21};
22use crate::{PROJECT_NAME, VERSION, analyze, git};
23
24#[derive(Debug, Parser)]
25#[command(
26    name = "git-slop",
27    about = "Find the files that cost too much context.",
28    version = VERSION
29)]
30struct Cli {
31    /// Repository or path inside a repository to analyze.
32    #[arg(long, global = true, value_name = "PATH")]
33    repo: Option<PathBuf>,
34    /// Render runtime errors as human text or stable JSON.
35    #[arg(long, global = true, value_enum, default_value_t = ErrorFormat::Human)]
36    error_format: ErrorFormat,
37    #[command(subcommand)]
38    command: Command,
39}
40
41#[derive(Debug, Subcommand)]
42enum Command {
43    /// Scaffold .slop/ config, ignore rules, and state directories.
44    Init(InitArgs),
45    /// Scan the repository and generate hotspot reports.
46    Find(FindArgs),
47    /// Show metrics and reasons for one file or folder.
48    Show(ShowArgs),
49    /// Explain why selected hotspots or structural findings are expensive.
50    Explain(ExplainArgs),
51    /// Propose bounded maintenance slices from the current detector report.
52    Plan(PlanArgs),
53    /// Evaluate an existing report against CI thresholds.
54    Check(CheckArgs),
55    /// Compare two existing schema-5 reports without rerunning the detector.
56    Compare(CompareArgs),
57    /// Validate or inspect the versioned report contract.
58    Report(ReportArgs),
59    /// Export action-queue findings from an existing schema-5 report as SARIF.
60    Sarif(SarifArgs),
61    /// Render repository health for CI summaries, annotations, or automation.
62    Health(HealthArgs),
63    /// Inspect or migrate effective configuration.
64    Config(ConfigArgs),
65    /// Diagnose repository readiness and optionally write a redacted bundle.
66    Doctor(DoctorArgs),
67    /// List findings, relationships, clusters, or profiles.
68    List(ListArgs),
69    /// Remove old immutable run snapshots according to retention policy.
70    Prune(PruneArgs),
71    /// Inspect or prune the packed token cache.
72    Cache(CacheArgs),
73    /// Generate shell completion source.
74    Completions(CompletionsArgs),
75    /// Generate the roff manual from the live Clap command tree.
76    Man(ManArgs),
77    /// Generate Markdown command reference from the live Clap command tree.
78    Reference(ReferenceArgs),
79    /// Write a self-contained, local, searchable HTML report.
80    Html(HtmlArgs),
81    /// Print version information.
82    Version,
83    /// Print package and source-build provenance.
84    BuildInfo(BuildInfoArgs),
85    /// Print a published JSON Schema for a machine contract.
86    Schema(SchemaArgs),
87}
88
89#[derive(Debug, Args)]
90struct SchemaArgs {
91    #[arg(value_enum)]
92    contract: SchemaContract,
93}
94
95#[derive(Debug, Clone, Copy, ValueEnum)]
96enum SchemaContract {
97    Report,
98    Config,
99    Compare,
100    Explain,
101    Plan,
102    Sarif,
103    Health,
104    Check,
105    Doctor,
106    BuildInfo,
107    List,
108    Show,
109    PromptManifest,
110}
111
112#[derive(Debug, Args)]
113struct FindArgs {
114    /// Acknowledge incomplete history and continue in a shallow clone.
115    #[arg(long)]
116    allow_shallow: bool,
117    /// Analyze only this repo-relative path while retaining repository-wide Git evidence.
118    #[arg(long)]
119    scope: Option<String>,
120    /// Permit a scope that selects no tracked paths and emit an empty analysis.
121    #[arg(long)]
122    allow_empty_scope: bool,
123    /// Suppress human progress and report-path messages.
124    #[arg(long)]
125    quiet: bool,
126    /// Suppress phase progress while preserving the final result.
127    #[arg(long)]
128    no_progress: bool,
129    /// Mutable cache/state directory. Relative paths resolve from the repository root.
130    #[arg(long, value_name = "PATH")]
131    state_dir: Option<PathBuf>,
132    /// Report output directory. Relative paths resolve from the repository root.
133    #[arg(long, value_name = "PATH")]
134    output_dir: Option<PathBuf>,
135    /// Disable token-cache reads and writes for an ephemeral scan.
136    #[arg(long)]
137    no_cache: bool,
138    /// Deterministically analyze the largest path prefix that fits the memory budget.
139    #[arg(long)]
140    allow_degraded: bool,
141    /// Report evidence profile.
142    #[arg(long, value_enum, default_value_t = ReportProfile::Standard)]
143    report_profile: ReportProfile,
144    /// Also write a compressed report beside report.json.
145    #[arg(long, value_enum, default_value_t = ReportCompression::None)]
146    compression: ReportCompression,
147    /// Estimate scope, memory, cache, report size, time, and inodes without scanning.
148    #[arg(long)]
149    estimate_only: bool,
150}
151
152#[derive(Debug, Clone, Copy, ValueEnum)]
153enum ReportProfile {
154    Compact,
155    Standard,
156    FullEvidence,
157}
158
159impl ReportProfile {
160    fn as_str(self) -> &'static str {
161        match self {
162            Self::Compact => "compact",
163            Self::Standard => "standard",
164            Self::FullEvidence => "full_evidence",
165        }
166    }
167}
168
169#[derive(Debug, Clone, Copy, ValueEnum)]
170enum ReportCompression {
171    None,
172    Gzip,
173    Zstd,
174}
175
176impl ReportCompression {
177    fn as_str(self) -> &'static str {
178        match self {
179            Self::None => "none",
180            Self::Gzip => "gzip",
181            Self::Zstd => "zstd",
182        }
183    }
184}
185
186#[derive(Debug, Args)]
187struct BuildInfoArgs {
188    #[arg(long, value_enum, default_value_t = BuildInfoFormat::Json)]
189    format: BuildInfoFormat,
190}
191
192#[derive(Debug, Args)]
193struct InitArgs {
194    /// Overwrite generated config files.
195    #[arg(long)]
196    force: bool,
197}
198
199#[derive(Debug, Args)]
200struct ShowArgs {
201    /// Repo-relative file or folder path.
202    target_path: String,
203    /// Report path. Defaults to .slop/latest/report.json.
204    #[arg(long)]
205    report: Option<PathBuf>,
206    #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
207    format: DisplayFormat,
208}
209
210#[derive(Debug, Args)]
211#[command(group(
212    ArgGroup::new("selector")
213        .args(["path", "cluster", "relationship", "top"])
214        .multiple(false)
215))]
216struct ExplainArgs {
217    /// Report path. Defaults to .slop/latest/report.json.
218    #[arg(long)]
219    report: Option<PathBuf>,
220    /// Repo-relative file or folder path.
221    #[arg(long)]
222    path: Option<String>,
223    /// Cluster identifier.
224    #[arg(long)]
225    cluster: Option<String>,
226    /// Relationship identifier.
227    #[arg(long)]
228    relationship: Option<String>,
229    /// Explain the top N hotspots from the action queue.
230    #[arg(long)]
231    top: Option<i64>,
232    #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
233    format: DisplayFormat,
234    /// Write a deterministic local-model prompt pack to this directory.
235    #[arg(long)]
236    prompt_pack: Option<PathBuf>,
237    /// Include bounded local source/test excerpts, guidance, and verification hints.
238    #[arg(long, requires = "prompt_pack")]
239    include_repository_context: bool,
240    /// Maximum bytes read from each included repository file.
241    #[arg(long, default_value_t = 2048, requires = "include_repository_context")]
242    excerpt_bytes: usize,
243}
244
245#[derive(Debug, Args)]
246#[command(group(
247    ArgGroup::new("selector")
248        .args(["path", "cluster", "relationship"])
249        .required(true)
250        .multiple(false)
251))]
252struct PlanArgs {
253    /// Report path. Defaults to .slop/latest/report.json.
254    #[arg(long)]
255    report: Option<PathBuf>,
256    /// Repo-relative file or folder path.
257    #[arg(long)]
258    path: Option<String>,
259    /// Cluster identifier.
260    #[arg(long)]
261    cluster: Option<String>,
262    /// Relationship identifier.
263    #[arg(long)]
264    relationship: Option<String>,
265    /// Maximum number of bounded maintenance slices to propose.
266    #[arg(long, default_value_t = 3)]
267    max_slices: i64,
268    #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
269    format: DisplayFormat,
270    /// Write a deterministic local-model prompt pack to this directory.
271    #[arg(long)]
272    prompt_pack: Option<PathBuf>,
273    /// Include bounded local source/test excerpts, guidance, and verification hints.
274    #[arg(long, requires = "prompt_pack")]
275    include_repository_context: bool,
276    /// Maximum bytes read from each included repository file.
277    #[arg(long, default_value_t = 2048, requires = "include_repository_context")]
278    excerpt_bytes: usize,
279}
280
281#[derive(Debug, Args)]
282struct CheckArgs {
283    /// Report path. Defaults to .slop/latest/report.json.
284    #[arg(long)]
285    report: Option<PathBuf>,
286    /// Override the config default fail threshold for context_band.
287    #[arg(long, value_enum)]
288    fail_on_context_band: Option<ContextBand>,
289    /// Override the config default fail threshold for slop_band.
290    #[arg(long, value_enum)]
291    fail_on_slop_band: Option<SlopBand>,
292    #[arg(long, value_enum, default_value_t = CheckFormat::Text)]
293    format: CheckFormat,
294    /// Include complete finding records in JSON output.
295    #[arg(long)]
296    details: bool,
297}
298
299#[derive(Debug, Args)]
300struct CompareArgs {
301    /// Base report.json path.
302    #[arg(
303        long,
304        required_unless_present = "base_ref",
305        conflicts_with = "base_ref"
306    )]
307    base: Option<PathBuf>,
308    /// Safely resolve and scan this Git revision in an isolated worktree.
309    #[arg(long, conflicts_with = "base")]
310    base_ref: Option<String>,
311    /// Head report.json path.
312    #[arg(long, default_value = ".slop/latest/report.json")]
313    head: PathBuf,
314    /// Apply the head repository's scope to an isolated --base-ref scan.
315    #[arg(long)]
316    scope: Option<String>,
317    /// Permit incomplete history in an isolated --base-ref scan.
318    #[arg(long)]
319    allow_shallow: bool,
320    /// Maximum number of changed files and queue movements to show.
321    #[arg(long, default_value_t = 10)]
322    top: i64,
323    #[arg(long, value_enum, default_value_t = CompareFormat::Text)]
324    format: CompareFormat,
325    /// Detail level for machine output.
326    #[arg(long, value_enum, default_value_t = CompareDetail::Top)]
327    detail: CompareDetail,
328    /// Zero-based record offset for --detail full.
329    #[arg(long, default_value_t = 0)]
330    offset: usize,
331    /// Maximum records per collection for --detail full.
332    #[arg(long, default_value_t = 1000)]
333    limit: usize,
334    /// Compare reports with incompatible identity or analyzer metadata.
335    #[arg(long)]
336    force: bool,
337    /// Exit 1 when an existing file worsens or a newly added file is a finding.
338    #[arg(long)]
339    fail_on_regression: bool,
340}
341
342#[derive(Debug, Args)]
343struct ReportArgs {
344    #[command(subcommand)]
345    command: ReportCommand,
346}
347
348#[derive(Debug, Subcommand)]
349enum ReportCommand {
350    /// Validate one report against the complete schema-5 contract.
351    Validate {
352        #[arg(value_name = "REPORT_JSON")]
353        path: PathBuf,
354        /// Accept schema 4 as migration input and validate its normalized schema-5 form.
355        #[arg(long)]
356        allow_legacy: bool,
357    },
358    /// Migrate a schema-4 report to normalized schema 5.
359    Migrate {
360        #[arg(value_name = "REPORT_JSON")]
361        path: PathBuf,
362        #[arg(long, value_name = "PATH")]
363        output: PathBuf,
364    },
365    /// Print the published JSON Schema for report schema 5.
366    Schema,
367}
368
369#[derive(Debug, Args)]
370struct SarifArgs {
371    /// Report path. Defaults to .slop/latest/report.json.
372    #[arg(long)]
373    report: Option<PathBuf>,
374    /// Maximum number of action-queue findings to export.
375    #[arg(long)]
376    top: Option<i64>,
377    /// Optional SARIF output path. Defaults to stdout.
378    #[arg(long)]
379    output: Option<PathBuf>,
380}
381
382#[derive(Debug, Args)]
383struct HealthArgs {
384    /// Report path. Defaults to .slop/latest/report.json.
385    #[arg(long)]
386    report: Option<PathBuf>,
387    /// Output suited for a job summary, workflow annotations, or automation.
388    #[arg(long, value_enum, default_value_t = HealthFormat::Text)]
389    format: HealthFormat,
390    /// Maximum number of GitHub workflow annotations to emit.
391    #[arg(long, default_value_t = 10)]
392    max_annotations: usize,
393}
394
395#[derive(Debug, Args)]
396struct ConfigArgs {
397    #[command(subcommand)]
398    command: ConfigCommand,
399}
400
401#[derive(Debug, Subcommand)]
402enum ConfigCommand {
403    /// Show configuration; --effective includes defaults.
404    Show {
405        #[arg(long)]
406        effective: bool,
407    },
408    /// Validate the local configuration.
409    Validate,
410    /// Show only values that differ from defaults.
411    DiffDefaults,
412    /// Rewrite legacy schema configuration as a minimal schema-2 override.
413    Migrate,
414    /// Print the supported configuration schema as JSON.
415    Schema,
416}
417
418#[derive(Debug, Args)]
419struct DoctorArgs {
420    /// Write a privacy-safe diagnostic JSON bundle.
421    #[arg(long, num_args = 0..=1, default_missing_value = ".slop/diagnostic-bundle.json")]
422    bundle: Option<PathBuf>,
423    #[arg(long, value_enum, default_value_t = DoctorFormat::Text)]
424    format: DoctorFormat,
425    /// Estimate only this repo-relative scope.
426    #[arg(long)]
427    scope: Option<String>,
428}
429
430#[derive(Debug, Clone, Copy, ValueEnum)]
431enum DoctorFormat {
432    Text,
433    Json,
434}
435
436#[derive(Debug, Args)]
437struct ListArgs {
438    #[command(subcommand)]
439    command: ListCommand,
440}
441
442#[derive(Debug, Subcommand)]
443enum ListCommand {
444    Findings(ListFilterArgs),
445    Relationships(ListFilterArgs),
446    Clusters(ListFilterArgs),
447    Profiles(ListFilterArgs),
448}
449
450#[derive(Debug, Args)]
451struct ListFilterArgs {
452    #[arg(long)]
453    report: Option<PathBuf>,
454    #[arg(long)]
455    path: Option<String>,
456    #[arg(long)]
457    profile: Option<String>,
458    #[arg(long)]
459    language: Option<String>,
460    #[arg(long, visible_alias = "class")]
461    classification: Option<String>,
462    #[arg(long)]
463    severity: Option<String>,
464    #[arg(long, default_value_t = 50)]
465    top: usize,
466    #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
467    format: DisplayFormat,
468    /// Use a wider terminal layout before truncating fields.
469    #[arg(long)]
470    wide: bool,
471    /// Never truncate terminal fields.
472    #[arg(long)]
473    no_truncate: bool,
474}
475
476#[derive(Debug, Args)]
477struct PruneArgs {
478    /// Number of newest run snapshots to retain; defaults to output.retention_runs.
479    #[arg(long)]
480    keep: Option<usize>,
481    /// Maximum total bytes retained; defaults to output.retention_bytes.
482    #[arg(long)]
483    max_bytes: Option<u64>,
484    /// Print removals without changing files.
485    #[arg(long)]
486    dry_run: bool,
487    /// Select text, JSON, or YAML output.
488    #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
489    format: DisplayFormat,
490}
491
492#[derive(Debug, Args)]
493struct CacheArgs {
494    #[arg(long, value_name = "PATH")]
495    state_dir: Option<PathBuf>,
496    #[command(subcommand)]
497    command: CacheCommand,
498}
499
500#[derive(Debug, Subcommand)]
501enum CacheCommand {
502    Status {
503        #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
504        format: DisplayFormat,
505    },
506    Prune {
507        #[arg(long, default_value_t = 10_000)]
508        max_entries: usize,
509        #[arg(long, default_value_t = 536_870_912)]
510        max_bytes: u64,
511        #[arg(long)]
512        dry_run: bool,
513        #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
514        format: DisplayFormat,
515    },
516}
517
518#[derive(Debug, Args)]
519struct CompletionsArgs {
520    shell: CompletionShell,
521}
522
523#[derive(Debug, Args)]
524struct ManArgs {
525    #[arg(long)]
526    output: Option<PathBuf>,
527}
528
529#[derive(Debug, Args)]
530struct ReferenceArgs {
531    #[arg(long)]
532    output: Option<PathBuf>,
533}
534
535#[derive(Debug, Args)]
536struct HtmlArgs {
537    /// Report path. Defaults to .slop/latest/report.json.
538    #[arg(long)]
539    report: Option<PathBuf>,
540    /// Destination. Defaults to .slop/latest/report.html.
541    #[arg(long)]
542    output: Option<PathBuf>,
543}
544
545#[derive(Debug, Clone, Copy, ValueEnum)]
546enum CompletionShell {
547    Bash,
548    Zsh,
549    Fish,
550    Powershell,
551    Nushell,
552}
553
554#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
555enum DisplayFormat {
556    Text,
557    Json,
558    Yaml,
559}
560
561#[derive(Debug, Clone, Copy, ValueEnum)]
562enum CompareFormat {
563    Text,
564    Json,
565    Yaml,
566    Ndjson,
567}
568
569#[derive(Debug, Clone, Copy, ValueEnum)]
570enum CompareDetail {
571    Summary,
572    Top,
573    Full,
574}
575
576#[derive(Debug, Clone, Copy, ValueEnum)]
577enum HealthFormat {
578    Text,
579    Markdown,
580    Github,
581    Json,
582}
583
584#[derive(Debug, Clone, Copy, ValueEnum)]
585enum BuildInfoFormat {
586    Json,
587}
588
589#[derive(Debug, Clone, Copy, ValueEnum)]
590enum CheckFormat {
591    Text,
592    Json,
593    Github,
594}
595
596#[derive(Debug, Clone, Copy, ValueEnum)]
597enum ErrorFormat {
598    Human,
599    Json,
600}
601
602#[derive(Debug, Clone, Copy, ValueEnum)]
603enum ContextBand {
604    Compact,
605    Healthy,
606    Warning,
607    Critical,
608}
609
610impl ContextBand {
611    fn as_str(self) -> &'static str {
612        match self {
613            Self::Compact => "compact",
614            Self::Healthy => "healthy",
615            Self::Warning => "warning",
616            Self::Critical => "critical",
617        }
618    }
619}
620
621#[derive(Debug, Clone, Copy, ValueEnum)]
622enum SlopBand {
623    Low,
624    Moderate,
625    High,
626    Critical,
627}
628
629impl SlopBand {
630    fn as_str(self) -> &'static str {
631        match self {
632            Self::Low => "low",
633            Self::Moderate => "moderate",
634            Self::High => "high",
635            Self::Critical => "critical",
636        }
637    }
638}
639
640fn print_text(value: &str) {
641    if value.is_empty() {
642        return;
643    }
644    print!("{value}");
645    if !value.ends_with('\n') {
646        println!();
647    }
648}
649
650fn safe_terminal(value: &str) -> String {
651    value
652        .chars()
653        .filter(|character| *character == '\n' || *character == '\t' || !character.is_control())
654        .collect()
655}
656
657fn relative_display(path: &Path, root: &Path) -> String {
658    path.strip_prefix(root)
659        .unwrap_or(path)
660        .to_string_lossy()
661        .replace('\\', "/")
662}
663
664fn default_report_path(repo_root: &Path) -> PathBuf {
665    config::latest_dir(repo_root).join("report.json")
666}
667
668fn load_report_at(path: &Path) -> Result<Option<Value>> {
669    if !path.exists() {
670        return Ok(None);
671    }
672    report::load_report(path).map(Some)
673}
674
675fn load_default_report(
676    repo_root: &Path,
677    explicit_report: Option<&Path>,
678) -> Result<Option<(Value, PathBuf)>> {
679    let path = explicit_report
680        .map(Path::to_path_buf)
681        .unwrap_or_else(|| default_report_path(repo_root));
682    Ok(load_report_at(&path)?.map(|report| (report, path)))
683}
684
685fn report_or_missing(
686    repo_root: &Path,
687    explicit_report: Option<&Path>,
688) -> Result<Result<(Value, PathBuf), i32>> {
689    let fallback = explicit_report
690        .map(Path::to_path_buf)
691        .unwrap_or_else(|| default_report_path(repo_root));
692    let loaded = match load_default_report(repo_root, explicit_report) {
693        Ok(loaded) => loaded,
694        Err(error) => {
695            eprintln!("{error:#}");
696            return Ok(Err(2));
697        }
698    };
699    Ok(match loaded {
700        Some(loaded) => Ok(loaded),
701        None => {
702            eprintln!(
703                "Report not found: {}\nRun `git slop find` to generate it.",
704                fallback.display()
705            );
706            Err(2)
707        }
708    })
709}
710
711fn lexical_normalize(path: &Path) -> PathBuf {
712    let mut result = PathBuf::new();
713    for component in path.components() {
714        match component {
715            Component::CurDir => {}
716            Component::ParentDir => {
717                if !result.pop() {
718                    result.push(component.as_os_str());
719                }
720            }
721            _ => result.push(component.as_os_str()),
722        }
723    }
724    result
725}
726
727fn selector_path(repo_root: &Path, input: &str) -> String {
728    let candidate = if Path::new(input).is_absolute() {
729        lexical_normalize(Path::new(input))
730    } else {
731        lexical_normalize(&repo_root.join(input))
732    };
733    candidate
734        .strip_prefix(repo_root)
735        .ok()
736        .map(|path| path.to_string_lossy().replace('\\', "/"))
737        .unwrap_or_else(|| {
738            let trimmed = input.trim();
739            if trimmed.is_empty() {
740                ".".to_string()
741            } else {
742                trimmed.to_string()
743            }
744        })
745}
746
747fn usage_error(error: impl std::fmt::Display) -> Result<i32> {
748    eprintln!("{error}");
749    Ok(2)
750}
751
752fn ensure_prompt_pack_target(path: &Path) -> Result<Result<(), i32>> {
753    if path.exists() && !path.is_dir() {
754        eprintln!("Prompt pack path is not a directory: {}", path.display());
755        Ok(Err(2))
756    } else {
757        Ok(Ok(()))
758    }
759}
760
761fn run_init(repo_root: &Path, args: InitArgs) -> Result<i32> {
762    let result = config::initialize(repo_root, args.force)?;
763    println!(
764        "Initialized {} ({}).",
765        relative_display(&config::config_path(repo_root), repo_root),
766        result.config
767    );
768    println!(
769        "Initialized {} ({}).",
770        relative_display(&config::slop_dir(repo_root).join(".gitignore"), repo_root),
771        result.gitignore
772    );
773    println!("Ensured .slop/latest/, .slop/runs/, and .slop/cache/ exist.");
774    Ok(0)
775}
776
777fn run_find(repo_root: &Path, args: FindArgs) -> Result<i32> {
778    if args.estimate_only {
779        let config = config::load(repo_root)?;
780        let scope = args
781            .scope
782            .as_deref()
783            .map(str::trim)
784            .filter(|value| !value.is_empty() && *value != ".");
785        let paths = git::list_tracked_files(repo_root)?
786            .into_iter()
787            .filter(|path| {
788                scope.is_none_or(|scope| path == scope || path.starts_with(&format!("{scope}/")))
789            })
790            .collect::<Vec<_>>();
791        let payload = json!({
792            "schema_version": 1,
793            "command": "find estimate",
794            "scope": scope,
795            "estimate": crate::estimate::build(repo_root, &paths, &config)
796        });
797        print_text(&render_json(&payload)?);
798        return Ok(0);
799    }
800    let result = analyze::run_find_with_options(
801        repo_root,
802        &analyze::FindOptions {
803            allow_shallow: args.allow_shallow,
804            scope: args.scope,
805            progress: !args.quiet && !args.no_progress && std::io::stderr().is_terminal(),
806            allow_empty_scope: args.allow_empty_scope,
807            state_dir: args.state_dir,
808            output_dir: args.output_dir,
809            no_cache: args.no_cache,
810            allow_degraded: args.allow_degraded,
811            report_profile: args.report_profile.as_str().to_string(),
812            compression: args.compression.as_str().to_string(),
813        },
814    )?;
815    if args.quiet {
816        return Ok(0);
817    }
818    print_text(&result.terminal);
819    println!("Wrote report to {}.", result.report_json.display());
820    if result.report_yaml.exists() {
821        println!("Wrote YAML report to {}.", result.report_yaml.display());
822    }
823    println!("Wrote summary to {}.", result.summary_md.display());
824    println!(
825        "Wrote repository health summary to {}.",
826        result.health_md.display()
827    );
828    if let Some(path) = result.compressed_report {
829        println!("Wrote compressed report to {}.", path.display());
830    }
831    Ok(0)
832}
833
834fn run_show(repo_root: &Path, args: ShowArgs) -> Result<i32> {
835    let (loaded, report_path) = match report_or_missing(repo_root, args.report.as_deref())? {
836        Ok(value) => value,
837        Err(code) => return Ok(code),
838    };
839    let target = selector_path(repo_root, &args.target_path);
840    let Some(payload) = show_payload(&loaded, &target) else {
841        eprintln!(
842            "No record found for '{}' in {}.",
843            args.target_path,
844            report_path.display()
845        );
846        return Ok(2);
847    };
848    match args.format {
849        DisplayFormat::Json => print_text(&render_json(&payload)?),
850        DisplayFormat::Text => print_text(&render_show_text(&payload)),
851        DisplayFormat::Yaml => print_text(&serde_yaml::to_string(&payload)?),
852    }
853    Ok(0)
854}
855
856fn explain_selector(args: &ExplainArgs, repo_root: &Path) -> Result<ExplainSelector, i32> {
857    if let Some(path) = &args.path {
858        Ok(ExplainSelector::Path(selector_path(repo_root, path)))
859    } else if let Some(id) = &args.cluster {
860        Ok(ExplainSelector::Cluster(id.clone()))
861    } else if let Some(id) = &args.relationship {
862        Ok(ExplainSelector::Relationship(id.clone()))
863    } else {
864        let count = args.top.unwrap_or(5);
865        match usize::try_from(count).ok().filter(|count| *count > 0) {
866            Some(count) => Ok(ExplainSelector::Top(count)),
867            None => Err(2),
868        }
869    }
870}
871
872fn run_explain(repo_root: &Path, args: ExplainArgs) -> Result<i32> {
873    if args.include_repository_context && !(256..=4096).contains(&args.excerpt_bytes) {
874        eprintln!("--excerpt-bytes must be between 256 and 4096.");
875        return Ok(2);
876    }
877    let (loaded, _) = match report_or_missing(repo_root, args.report.as_deref())? {
878        Ok(value) => value,
879        Err(code) => return Ok(code),
880    };
881    let selector = match explain_selector(&args, repo_root) {
882        Ok(selector) => selector,
883        Err(code) => {
884            eprintln!("--top must be greater than zero.");
885            return Ok(code);
886        }
887    };
888    let payload = match explain_payload(&loaded, Some(selector)) {
889        Ok(payload) => payload,
890        Err(error) => return usage_error(error),
891    };
892    if let Some(output_dir) = args.prompt_pack.as_deref() {
893        if let Err(code) = ensure_prompt_pack_target(output_dir)? {
894            return Ok(code);
895        }
896        write_prompt_pack(
897            "explain",
898            &payload,
899            &loaded,
900            output_dir,
901            args.include_repository_context.then_some(repo_root),
902            args.excerpt_bytes,
903        )?;
904    }
905    match args.format {
906        DisplayFormat::Json => print_text(&render_json(&payload)?),
907        DisplayFormat::Text => print_text(&render_explain_text(&payload)),
908        DisplayFormat::Yaml => print_text(&serde_yaml::to_string(&payload)?),
909    }
910    Ok(0)
911}
912
913fn plan_selector(args: &PlanArgs, repo_root: &Path) -> PlanSelector {
914    if let Some(path) = &args.path {
915        PlanSelector::Path(selector_path(repo_root, path))
916    } else if let Some(id) = &args.cluster {
917        PlanSelector::Cluster(id.clone())
918    } else {
919        PlanSelector::Relationship(args.relationship.clone().unwrap_or_default())
920    }
921}
922
923fn run_plan(repo_root: &Path, args: PlanArgs) -> Result<i32> {
924    if args.include_repository_context && !(256..=4096).contains(&args.excerpt_bytes) {
925        eprintln!("--excerpt-bytes must be between 256 and 4096.");
926        return Ok(2);
927    }
928    let (loaded, _) = match report_or_missing(repo_root, args.report.as_deref())? {
929        Ok(value) => value,
930        Err(code) => return Ok(code),
931    };
932    let Some(max_slices) = usize::try_from(args.max_slices)
933        .ok()
934        .filter(|count| *count > 0)
935    else {
936        eprintln!("--max-slices must be greater than zero.");
937        return Ok(2);
938    };
939    let payload = match plan_payload(&loaded, plan_selector(&args, repo_root), max_slices) {
940        Ok(payload) => payload,
941        Err(error) => return usage_error(error),
942    };
943    if let Some(output_dir) = args.prompt_pack.as_deref() {
944        if let Err(code) = ensure_prompt_pack_target(output_dir)? {
945            return Ok(code);
946        }
947        write_prompt_pack(
948            "plan",
949            &payload,
950            &loaded,
951            output_dir,
952            args.include_repository_context.then_some(repo_root),
953            args.excerpt_bytes,
954        )?;
955    }
956    match args.format {
957        DisplayFormat::Json => print_text(&render_json(&payload)?),
958        DisplayFormat::Text => print_text(&render_plan_text(&payload)),
959        DisplayFormat::Yaml => print_text(&serde_yaml::to_string(&payload)?),
960    }
961    Ok(0)
962}
963
964fn run_check(repo_root: &Path, args: CheckArgs) -> Result<i32> {
965    let (loaded, _) = match report_or_missing(repo_root, args.report.as_deref())? {
966        Ok(value) => value,
967        Err(code) => return Ok(code),
968    };
969    let loaded_config = loaded
970        .get("config")
971        .cloned()
972        .unwrap_or_else(config::default_config);
973    let context_band = args
974        .fail_on_context_band
975        .map(ContextBand::as_str)
976        .or_else(|| {
977            loaded_config
978                .pointer("/check/fail_on_context_band")
979                .and_then(Value::as_str)
980        })
981        .unwrap_or("critical");
982    let slop_band = args
983        .fail_on_slop_band
984        .map(SlopBand::as_str)
985        .or_else(|| {
986            loaded_config
987                .pointer("/check/fail_on_slop_band")
988                .and_then(Value::as_str)
989        })
990        .unwrap_or("critical");
991    let failures = failing_records(&loaded, Some(context_band), Some(slop_band));
992    if !matches!(args.format, CheckFormat::Text) {
993        match args.format {
994            CheckFormat::Json => {
995                let mut payload = json!({
996                    "schema_version": 1,
997                    "command": "check",
998                    "report": {"schema_version": loaded.get("schema_version"), "analyzer": loaded.get("analyzer"), "repo": loaded.get("repo"), "scope": loaded.get("scope")},
999                    "boundary": {"context_band": context_band, "slop_band": slop_band},
1000                    "passed": failures.is_empty(),
1001                    "finding_count": failures.len(),
1002                    "details_included": args.details,
1003                });
1004                if args.details {
1005                    payload["findings"] = json!(failures);
1006                }
1007                print_text(&render_json(&payload)?);
1008            }
1009            CheckFormat::Github => {
1010                for failure in &failures {
1011                    let path = safe_terminal(
1012                        failure
1013                            .get("path")
1014                            .and_then(Value::as_str)
1015                            .unwrap_or_default(),
1016                    );
1017                    println!(
1018                        "::error file={}::Git Slop context={} slop={} score={}",
1019                        path.replace('%', "%25").replace(',', "%2C"),
1020                        failure
1021                            .get("context_band")
1022                            .and_then(Value::as_str)
1023                            .unwrap_or_default(),
1024                        failure
1025                            .get("slop_band")
1026                            .and_then(Value::as_str)
1027                            .unwrap_or_default(),
1028                        failure.get("slop_score").unwrap_or(&Value::Null)
1029                    );
1030                }
1031            }
1032            CheckFormat::Text => {}
1033        }
1034        return Ok(if failures.is_empty() { 0 } else { 1 });
1035    }
1036    if failures.is_empty() {
1037        println!(
1038            "Check passed: no file records met or exceeded context={context_band} or slop={slop_band}."
1039        );
1040        return Ok(0);
1041    }
1042    println!(
1043        "Check failed: {} file records met or exceeded context={context_band} or slop={slop_band}.",
1044        failures.len()
1045    );
1046    for failure in failures.iter().take(10) {
1047        println!(
1048            "- {} (slop={}, context={}, slop_score={})",
1049            failure
1050                .get("path")
1051                .and_then(Value::as_str)
1052                .unwrap_or_default(),
1053            failure
1054                .get("slop_band")
1055                .and_then(Value::as_str)
1056                .unwrap_or_default(),
1057            failure
1058                .get("context_band")
1059                .and_then(Value::as_str)
1060                .unwrap_or_default(),
1061            failure
1062                .get("slop_score")
1063                .map(ToString::to_string)
1064                .unwrap_or_else(|| "null".to_string()),
1065        );
1066    }
1067    Ok(1)
1068}
1069
1070fn bounded_compare_output(
1071    payload: &Value,
1072    detail: CompareDetail,
1073    top: usize,
1074    offset: usize,
1075    limit: usize,
1076) -> Result<Value> {
1077    if limit == 0 {
1078        anyhow::bail!("--limit must be greater than zero");
1079    }
1080    let mut bounded = payload.clone();
1081    let cap = match detail {
1082        CompareDetail::Summary => 0,
1083        CompareDetail::Top => top,
1084        CompareDetail::Full => limit,
1085    };
1086    let start = if matches!(detail, CompareDetail::Full) {
1087        offset
1088    } else {
1089        0
1090    };
1091    let mut pagination = serde_json::Map::new();
1092    for key in [
1093        "file_deltas",
1094        "folder_deltas",
1095        "queue_movement",
1096        "overlay_deltas",
1097        "regressions",
1098    ] {
1099        let values = payload
1100            .get(key)
1101            .and_then(Value::as_array)
1102            .cloned()
1103            .unwrap_or_default();
1104        let returned = values
1105            .iter()
1106            .skip(start)
1107            .take(cap)
1108            .cloned()
1109            .collect::<Vec<_>>();
1110        pagination.insert(
1111            key.to_string(),
1112            json!({
1113                "total": values.len(),
1114                "offset": start,
1115                "limit": cap,
1116                "returned": returned.len(),
1117                "has_more": start.saturating_add(returned.len()) < values.len()
1118            }),
1119        );
1120        bounded[key] = json!(returned);
1121    }
1122    bounded["detail"] = json!(match detail {
1123        CompareDetail::Summary => "summary",
1124        CompareDetail::Top => "top",
1125        CompareDetail::Full => "full",
1126    });
1127    bounded["pagination"] = Value::Object(pagination);
1128    Ok(bounded)
1129}
1130
1131fn render_compare_ndjson(payload: &Value) -> Result<String> {
1132    let mut lines = vec![render_json(&json!({
1133        "record_type": "summary",
1134        "schema_version": payload.get("schema_version"),
1135        "summary": payload.get("summary"),
1136        "pagination": payload.get("pagination"),
1137        "baseline_status": payload.get("baseline_status")
1138    }))?];
1139    for (key, record_type) in [
1140        ("file_deltas", "file_delta"),
1141        ("folder_deltas", "folder_delta"),
1142        ("regressions", "regression"),
1143    ] {
1144        for record in payload
1145            .get(key)
1146            .and_then(Value::as_array)
1147            .into_iter()
1148            .flatten()
1149        {
1150            lines.push(render_json(
1151                &json!({"record_type": record_type, "record": record}),
1152            )?);
1153        }
1154    }
1155    Ok(lines.join("\n"))
1156}
1157
1158fn run_compare(repo_root: &Path, args: CompareArgs) -> Result<i32> {
1159    let materialized = if let Some(reference) = args.base_ref.as_deref() {
1160        Some(crate::baseline::MaterializedBaseline::create(
1161            repo_root,
1162            reference,
1163            args.scope.clone(),
1164            args.allow_shallow,
1165        )?)
1166    } else {
1167        None
1168    };
1169    let base_path = args
1170        .base
1171        .as_deref()
1172        .or_else(|| {
1173            materialized
1174                .as_ref()
1175                .map(|value| value.report_path.as_path())
1176        })
1177        .expect("Clap requires --base or --base-ref");
1178    let Some(base_report) = (match load_report_at(base_path) {
1179        Ok(report) => report,
1180        Err(error) => {
1181            eprintln!("{error:#}");
1182            return Ok(2);
1183        }
1184    }) else {
1185        eprintln!("Report not found: {}", base_path.display());
1186        return Ok(2);
1187    };
1188    let Some(head_report) = (match load_report_at(&args.head) {
1189        Ok(report) => report,
1190        Err(error) => {
1191            eprintln!("{error:#}");
1192            return Ok(2);
1193        }
1194    }) else {
1195        eprintln!("Report not found: {}", args.head.display());
1196        return Ok(2);
1197    };
1198    let Some(top) = usize::try_from(args.top).ok().filter(|count| *count > 0) else {
1199        eprintln!("--top must be greater than zero.");
1200        return Ok(2);
1201    };
1202    let payload = match compare_payload_with_force(
1203        &base_report,
1204        &head_report,
1205        Some(&base_path.to_string_lossy()),
1206        Some(&args.head.to_string_lossy()),
1207        top,
1208        args.force,
1209    ) {
1210        Ok(payload) => payload,
1211        Err(error) => return usage_error(error),
1212    };
1213    let output = match bounded_compare_output(&payload, args.detail, top, args.offset, args.limit) {
1214        Ok(output) => output,
1215        Err(error) => return usage_error(error),
1216    };
1217    let mut output = output;
1218    if let Some(materialized) = materialized.as_ref() {
1219        output["baseline_materialization"] = json!({
1220            "reference": args.base_ref,
1221            "resolved_revision": materialized.revision,
1222            "isolated_worktree": true,
1223            "copied_head_config": materialized.copied_head_config,
1224            "cache_disabled": true
1225        });
1226    }
1227    match args.format {
1228        CompareFormat::Json => print_text(&render_json(&output)?),
1229        CompareFormat::Text => print_text(&render_compare_text(&output, top)),
1230        CompareFormat::Yaml => print_text(&serde_yaml::to_string(&output)?),
1231        CompareFormat::Ndjson => print_text(&render_compare_ndjson(&output)?),
1232    }
1233    let regressions = payload
1234        .pointer("/summary/regression_count")
1235        .and_then(Value::as_u64)
1236        .unwrap_or_default();
1237    Ok(if args.fail_on_regression && regressions > 0 {
1238        1
1239    } else {
1240        0
1241    })
1242}
1243
1244fn run_report(args: ReportArgs) -> Result<i32> {
1245    match args.command {
1246        ReportCommand::Validate { path, allow_legacy } => {
1247            match report::load_report_with_legacy(&path, allow_legacy) {
1248                Ok(value) => {
1249                    println!(
1250                        "Report is valid: {} (schema {}).",
1251                        path.display(),
1252                        value["schema_version"]
1253                    );
1254                    Ok(0)
1255                }
1256                Err(error) => {
1257                    eprintln!("{error:#}");
1258                    Ok(2)
1259                }
1260            }
1261        }
1262        ReportCommand::Migrate { path, output } => {
1263            let source = fs::read_to_string(&path)
1264                .with_context(|| format!("failed to read {}", path.display()))?;
1265            let value: Value = serde_json::from_str(&source)
1266                .with_context(|| format!("invalid git-slop report JSON: {}", path.display()))?;
1267            let migrated = report::migrate_legacy_report(value)?;
1268            report::write_json_atomically(&output, &migrated)?;
1269            println!(
1270                "Migrated {} to schema 5 at {}.",
1271                path.display(),
1272                output.display()
1273            );
1274            Ok(0)
1275        }
1276        ReportCommand::Schema => {
1277            print_text(&render_json(&report::schema())?);
1278            Ok(0)
1279        }
1280    }
1281}
1282
1283fn run_sarif(repo_root: &Path, args: SarifArgs) -> Result<i32> {
1284    let (loaded, report_path) = match report_or_missing(repo_root, args.report.as_deref())? {
1285        Ok(value) => value,
1286        Err(code) => return Ok(code),
1287    };
1288    let top = match args.top {
1289        None => None,
1290        Some(value) => match usize::try_from(value).ok().filter(|count| *count > 0) {
1291            Some(value) => Some(value),
1292            None => {
1293                eprintln!("--top must be greater than zero.");
1294                return Ok(2);
1295            }
1296        },
1297    };
1298    let payload = match sarif_payload(&loaded, Some(&report_path.to_string_lossy()), top) {
1299        Ok(payload) => payload,
1300        Err(error) => return usage_error(error),
1301    };
1302    let rendered = render_json(&payload)?;
1303    if let Some(output) = args.output {
1304        if let Some(parent) = output
1305            .parent()
1306            .filter(|parent| !parent.as_os_str().is_empty())
1307        {
1308            fs::create_dir_all(parent)
1309                .with_context(|| format!("failed to create {}", parent.display()))?;
1310        }
1311        fs::write(&output, rendered)
1312            .with_context(|| format!("failed to write {}", output.display()))?;
1313        println!("Wrote SARIF report to {}.", output.display());
1314    } else {
1315        print_text(&rendered);
1316    }
1317    Ok(0)
1318}
1319
1320fn run_health(repo_root: &Path, args: HealthArgs) -> Result<i32> {
1321    let (mut loaded, _) = match report_or_missing(repo_root, args.report.as_deref())? {
1322        Ok(value) => value,
1323        Err(code) => return Ok(code),
1324    };
1325    let rollup = match health::health_rollup_from_report(&loaded) {
1326        Ok(rollup) => rollup,
1327        Err(error) => return usage_error(error),
1328    };
1329    let mut health_value = serde_json::to_value(rollup)?;
1330    if let (Some(existing), Some(derived)) = (
1331        loaded.get("health").and_then(Value::as_object),
1332        health_value.as_object_mut(),
1333    ) {
1334        for (key, value) in existing {
1335            derived.entry(key.clone()).or_insert_with(|| value.clone());
1336        }
1337    }
1338    if let Some(object) = loaded.as_object_mut() {
1339        object.insert("health".to_string(), health_value);
1340    }
1341    match args.format {
1342        HealthFormat::Text => print_text(&report::render_terminal(&loaded)),
1343        HealthFormat::Markdown => {
1344            let rendered = match health::render_health_from_report(&loaded) {
1345                Ok(rendered) => rendered,
1346                Err(error) => return usage_error(error),
1347            };
1348            print_text(&rendered);
1349        }
1350        HealthFormat::Github => {
1351            print_text(&render_github_annotations(&loaded, args.max_annotations));
1352        }
1353        HealthFormat::Json => {
1354            print_text(&render_json(&health_json_payload(&loaded))?);
1355        }
1356    }
1357    Ok(0)
1358}
1359
1360fn diff_values(current: &Value, defaults: &Value) -> Value {
1361    match (current, defaults) {
1362        (Value::Object(current), Value::Object(defaults)) => {
1363            let mut result = serde_json::Map::new();
1364            for (key, value) in current {
1365                if matches!(key.as_str(), "tokenizer" | "context_bands") {
1366                    continue;
1367                }
1368                let difference = defaults
1369                    .get(key)
1370                    .map_or_else(|| value.clone(), |default| diff_values(value, default));
1371                if !difference.is_null()
1372                    && !difference
1373                        .as_object()
1374                        .is_some_and(serde_json::Map::is_empty)
1375                {
1376                    result.insert(key.clone(), difference);
1377                }
1378            }
1379            Value::Object(result)
1380        }
1381        _ if current == defaults => Value::Null,
1382        _ => current.clone(),
1383    }
1384}
1385
1386fn run_config(repo_root: &Path, args: ConfigArgs) -> Result<i32> {
1387    match args.command {
1388        ConfigCommand::Show { effective } => {
1389            if effective {
1390                print_text(&serde_yaml::to_string(&config::load(repo_root)?)?);
1391            } else {
1392                let path = config::config_path(repo_root);
1393                if path.exists() {
1394                    print_text(&fs::read_to_string(path)?);
1395                } else {
1396                    print_text(config::MINIMAL_CONFIG);
1397                }
1398            }
1399        }
1400        ConfigCommand::Validate => {
1401            config::load(repo_root)?;
1402            let path = config::config_path(repo_root);
1403            if path.exists() {
1404                println!("Configuration is valid: {}", path.display());
1405            } else {
1406                println!(
1407                    "Configuration is valid: built-in defaults ({} is absent).",
1408                    path.display()
1409                );
1410            }
1411        }
1412        ConfigCommand::DiffDefaults => {
1413            let diff = diff_values(&config::load(repo_root)?, &config::default_config());
1414            print_text(&serde_yaml::to_string(&diff)?);
1415        }
1416        ConfigCommand::Migrate => {
1417            let effective = config::load(repo_root)?;
1418            let mut diff = diff_values(&effective, &config::default_config());
1419            if let Some(object) = diff.as_object_mut() {
1420                object.insert("schema_version".into(), json!(2));
1421            }
1422            config::ensure_state_dirs(repo_root)?;
1423            fs::write(
1424                config::config_path(repo_root),
1425                serde_yaml::to_string(&diff)?,
1426            )?;
1427            println!(
1428                "Migrated {} to schema 2.",
1429                config::config_path(repo_root).display()
1430            );
1431        }
1432        ConfigCommand::Schema => print_text(&render_json(&config::schema())?),
1433    }
1434    Ok(0)
1435}
1436
1437fn run_doctor(repo_root: &Path, args: DoctorArgs) -> Result<i32> {
1438    let repo = git::repo_metadata(repo_root)?;
1439    let config_result = config::load(repo_root);
1440    let config_exists = config::config_path(repo_root).is_file();
1441    let report_path = default_report_path(repo_root);
1442    let report_status = if report_path.exists() {
1443        match report::load_report(&report_path) {
1444            Ok(_) => "compatible",
1445            Err(_) => "invalid",
1446        }
1447    } else {
1448        "missing"
1449    };
1450    let tracked_paths = git::list_tracked_files(repo_root)?
1451        .into_iter()
1452        .filter(|path| {
1453            args.scope
1454                .as_deref()
1455                .is_none_or(|scope| path == scope || path.starts_with(&format!("{scope}/")))
1456        })
1457        .collect::<Vec<_>>();
1458    let tracked = tracked_paths.len();
1459    let effective_config = config_result
1460        .as_ref()
1461        .cloned()
1462        .unwrap_or_else(|_| config::default_config());
1463    let estimate = crate::estimate::build(repo_root, &tracked_paths, &effective_config);
1464    let resource_status = if estimate.estimated_peak_memory_bytes > estimate.memory_budget_bytes {
1465        "over_memory_budget"
1466    } else {
1467        "within_budget"
1468    };
1469    let bundle_path = args.bundle.as_ref().map(|output| {
1470        if output.is_absolute() {
1471            output.clone()
1472        } else {
1473            repo_root.join(output)
1474        }
1475    });
1476    let diagnostic = json!({
1477        "schema_version": 1,
1478        "command": "doctor",
1479        "status": if config_result.is_err() || report_status == "invalid" || resource_status == "over_memory_budget" { "error" } else { "ready" },
1480        "repository": {"name": repo.repo_name, "branch": repo.branch, "shallow": repo.is_shallow, "detached": repo.detached_head, "clean": repo.worktree_clean},
1481        "config": {"status": if config_result.is_err() { "invalid" } else if config_exists { "valid" } else { "using_defaults" }, "path": config::config_path(repo_root)},
1482        "report": {"status": report_status, "path": report_path},
1483        "estimate": estimate,
1484        "resource_status": resource_status,
1485        "bundle_path": bundle_path,
1486        "recovery": {
1487            "config": "Run git slop config validate, then correct the reported key or run git slop config migrate.",
1488            "report": "Run git slop find to replace a missing or incompatible latest report.",
1489            "shallow": "Fetch full history or rerun find with --allow-shallow to acknowledge incomplete evidence."
1490        }
1491    });
1492    if matches!(args.format, DoctorFormat::Json) {
1493        print_text(&render_json(&diagnostic)?);
1494    } else {
1495        println!("Git Slop doctor");
1496        println!("- git: available");
1497        println!("- repository: {}", repo.repo_name);
1498        println!(
1499            "- branch: {}",
1500            repo.branch.as_deref().unwrap_or(if repo.detached_head {
1501                "detached HEAD"
1502            } else {
1503                "unborn"
1504            })
1505        );
1506        println!(
1507            "- history: {}",
1508            if repo.is_shallow {
1509                "shallow (incomplete)"
1510            } else {
1511                "complete"
1512            }
1513        );
1514        println!(
1515            "- worktree: {} (staged={}, modified={}, untracked={})",
1516            if repo.worktree_clean {
1517                "clean"
1518            } else {
1519                "dirty"
1520            },
1521            repo.staged_change_count,
1522            repo.modified_tracked_file_count,
1523            repo.untracked_file_count
1524        );
1525        println!(
1526            "- config: {}",
1527            if config_result.is_ok() {
1528                if config_exists {
1529                    "valid"
1530                } else {
1531                    "using built-in defaults"
1532                }
1533            } else {
1534                "invalid"
1535            }
1536        );
1537        println!("- report: {report_status}");
1538        println!(
1539            "- preflight: {tracked} tracked files; peak memory ~{} MiB; cache ~{} MiB; report ~{} MiB; time ~{}s; inodes ~{}",
1540            estimate.estimated_peak_memory_bytes.div_ceil(1024 * 1024),
1541            estimate.estimated_cache_bytes.div_ceil(1024 * 1024),
1542            estimate.estimated_report_bytes.div_ceil(1024 * 1024),
1543            estimate.estimated_seconds,
1544            estimate.estimated_inode_count,
1545        );
1546        if repo.is_shallow {
1547            println!("- recovery: fetch full history, or explicitly use --allow-shallow");
1548        }
1549        if config_result.is_err() {
1550            println!("- recovery: run `git slop config validate` and correct the reported key");
1551        }
1552        if report_status != "compatible" {
1553            println!("- recovery: run `git slop find` to produce a compatible latest report");
1554        }
1555    }
1556    if let Some(output) = bundle_path {
1557        if let Some(parent) = output.parent() {
1558            fs::create_dir_all(parent)?;
1559        }
1560        let config_digest = config_result
1561            .as_ref()
1562            .ok()
1563            .and_then(|value| serde_json::to_vec(value).ok())
1564            .map(|bytes| hex::encode(sha2::Sha256::digest(bytes)));
1565        let payload = json!({
1566            "schema_version": 1,
1567            "git_slop_version": VERSION,
1568            "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},
1569            "config_digest": config_digest,
1570            "report_status": report_status,
1571            "estimate": estimate,
1572            "privacy": {"source_included": false, "raw_tokens_included": false, "absolute_paths_included": false, "author_identities_included": false, "credentials_included": false}
1573        });
1574        fs::write(&output, render_json(&payload)?)?;
1575        if matches!(args.format, DoctorFormat::Json) {
1576            eprintln!("Wrote redacted diagnostic bundle to {}.", output.display());
1577        } else {
1578            println!("Wrote redacted diagnostic bundle to {}.", output.display());
1579        }
1580    }
1581    Ok(
1582        if config_result.is_err()
1583            || report_status == "invalid"
1584            || resource_status == "over_memory_budget"
1585        {
1586            2
1587        } else {
1588            0
1589        },
1590    )
1591}
1592
1593fn matches_list_filter(item: &Value, args: &ListFilterArgs) -> bool {
1594    args.path.as_ref().is_none_or(|path| {
1595        item.get("path")
1596            .and_then(Value::as_str)
1597            .is_some_and(|value| value.starts_with(path))
1598    }) && args
1599        .profile
1600        .as_ref()
1601        .is_none_or(|value| item.get("profile").and_then(Value::as_str) == Some(value))
1602        && args
1603            .language
1604            .as_ref()
1605            .is_none_or(|value| item.get("language").and_then(Value::as_str) == Some(value))
1606        && args.classification.as_ref().is_none_or(|value| {
1607            item.get("classification")
1608                .or_else(|| item.get("class"))
1609                .and_then(Value::as_str)
1610                == Some(value)
1611        })
1612        && args
1613            .severity
1614            .as_ref()
1615            .is_none_or(|value| item.get("severity").and_then(Value::as_str) == Some(value))
1616}
1617
1618fn terminal_field(value: &str, width: usize, no_truncate: bool) -> String {
1619    let value = safe_terminal(value).replace(['\n', '\t'], " ");
1620    if no_truncate || value.chars().count() <= width {
1621        return value;
1622    }
1623    if width <= 1 {
1624        return "…".to_string();
1625    }
1626    value.chars().take(width - 1).collect::<String>() + "…"
1627}
1628
1629fn run_list(repo_root: &Path, args: ListArgs) -> Result<i32> {
1630    let filter = match &args.command {
1631        ListCommand::Findings(v)
1632        | ListCommand::Relationships(v)
1633        | ListCommand::Clusters(v)
1634        | ListCommand::Profiles(v) => v,
1635    };
1636    let (loaded, _) = match report_or_missing(repo_root, filter.report.as_deref())? {
1637        Ok(value) => value,
1638        Err(code) => return Ok(code),
1639    };
1640    let mut values = match &args.command {
1641        ListCommand::Findings(_) => loaded
1642            .pointer("/health/findings")
1643            .and_then(Value::as_array)
1644            .cloned()
1645            .unwrap_or_default(),
1646        ListCommand::Relationships(_) => loaded
1647            .pointer("/overlays/organization_health/relationships")
1648            .and_then(Value::as_object)
1649            .into_iter()
1650            .flat_map(|map| map.values())
1651            .filter_map(Value::as_array)
1652            .flatten()
1653            .cloned()
1654            .collect(),
1655        ListCommand::Clusters(_) => loaded
1656            .pointer("/overlays/organization_health/clusters")
1657            .and_then(Value::as_object)
1658            .into_iter()
1659            .flat_map(|map| map.values())
1660            .filter_map(Value::as_array)
1661            .flatten()
1662            .cloned()
1663            .collect(),
1664        ListCommand::Profiles(_) => loaded
1665            .pointer("/health/profile_rollups")
1666            .and_then(Value::as_array)
1667            .cloned()
1668            .unwrap_or_default(),
1669    };
1670    values.retain(|item| matches_list_filter(item, filter));
1671    let total = values.len();
1672    values.truncate(filter.top);
1673    let returned = values.len();
1674    match filter.format {
1675        DisplayFormat::Json => print_text(&render_json(&json!({
1676            "schema_version": 1,
1677            "command": "list",
1678            "kind": match &args.command {
1679                ListCommand::Findings(_) => "findings",
1680                ListCommand::Relationships(_) => "relationships",
1681                ListCommand::Clusters(_) => "clusters",
1682                ListCommand::Profiles(_) => "profiles",
1683            },
1684            "items": values,
1685            "collection": {"total": total, "returned": returned, "limit": filter.top, "truncated": returned < total}
1686        }))?),
1687        DisplayFormat::Yaml => print_text(&serde_yaml::to_string(&values)?),
1688        DisplayFormat::Text => {
1689            let terminal_width = std::env::var("COLUMNS")
1690                .ok()
1691                .and_then(|value| value.parse::<usize>().ok())
1692                .unwrap_or(100);
1693            let path_width = if filter.no_truncate {
1694                values
1695                    .iter()
1696                    .filter_map(|item| {
1697                        item.get("path")
1698                            .or_else(|| item.get("name"))
1699                            .or_else(|| item.get("id"))
1700                            .and_then(Value::as_str)
1701                    })
1702                    .map(str::len)
1703                    .max()
1704                    .unwrap_or(24)
1705                    .max(24)
1706            } else if filter.wide {
1707                80
1708            } else {
1709                terminal_width.saturating_sub(59).clamp(24, 48)
1710            };
1711            println!(
1712                "{:<path_width$}  {:<16}  {:<10}  {:<10}  {:<10}  {:>7}",
1713                "PATH OR NAME", "PROFILE", "SEVERITY", "CONTEXT", "SLOP", "SCORE"
1714            );
1715            println!(
1716                "{:-<path_width$}  {:-<16}  {:-<10}  {:-<10}  {:-<10}  {:-<7}",
1717                "", "", "", "", "", ""
1718            );
1719            for item in &values {
1720                let label = item
1721                    .get("path")
1722                    .or_else(|| item.get("name"))
1723                    .or_else(|| item.get("id"))
1724                    .and_then(Value::as_str)
1725                    .unwrap_or("-");
1726                let profile = item
1727                    .get("profile")
1728                    .or_else(|| item.get("kind"))
1729                    .and_then(Value::as_str)
1730                    .unwrap_or("-");
1731                let severity = item.get("severity").and_then(Value::as_str).unwrap_or("-");
1732                let context = item
1733                    .get("context_band")
1734                    .and_then(Value::as_str)
1735                    .unwrap_or("-");
1736                let slop = item.get("slop_band").and_then(Value::as_str).unwrap_or("-");
1737                let score = item
1738                    .get("slop_score")
1739                    .or_else(|| item.get("evidence_score"))
1740                    .and_then(Value::as_f64)
1741                    .map_or_else(|| "-".to_string(), |value| format!("{value:.3}"));
1742                println!(
1743                    "{:<path_width$}  {:<16}  {:<10}  {:<10}  {:<10}  {:>7}",
1744                    terminal_field(label, path_width, filter.no_truncate),
1745                    terminal_field(profile, 16, filter.no_truncate),
1746                    terminal_field(severity, 10, filter.no_truncate),
1747                    terminal_field(context, 10, filter.no_truncate),
1748                    terminal_field(slop, 10, filter.no_truncate),
1749                    score
1750                );
1751            }
1752            println!(
1753                "\nReturned {returned} of {total} matching record(s).{}",
1754                if returned < total {
1755                    " Increase --top to see more."
1756                } else {
1757                    ""
1758                }
1759            );
1760        }
1761    }
1762    Ok(0)
1763}
1764
1765fn run_prune(repo_root: &Path, args: PruneArgs) -> Result<i32> {
1766    let loaded = config::load(repo_root).unwrap_or_else(|_| config::default_config());
1767    let keep = args
1768        .keep
1769        .unwrap_or_else(|| config::pointer_u64(&loaded, "/output/retention_runs", 20) as usize);
1770    let max_bytes = args
1771        .max_bytes
1772        .unwrap_or_else(|| config::pointer_u64(&loaded, "/output/retention_bytes", 2_147_483_648));
1773    let root = config::runs_dir(repo_root);
1774    if !root.exists() {
1775        let payload = json!({
1776            "schema_version": 1,
1777            "command": "prune",
1778            "dry_run": args.dry_run,
1779            "limits": {"max_runs": keep, "max_bytes": max_bytes},
1780            "before": {"runs": 0, "bytes": 0},
1781            "selected": [],
1782            "after": {"runs": 0, "bytes": 0}
1783        });
1784        match args.format {
1785            DisplayFormat::Text => println!("No run snapshots to prune."),
1786            DisplayFormat::Json => print_text(&render_json(&payload)?),
1787            DisplayFormat::Yaml => print_text(&serde_yaml::to_string(&payload)?),
1788        }
1789        return Ok(0);
1790    }
1791    let mut runs = fs::read_dir(&root)?
1792        .filter_map(Result::ok)
1793        .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
1794        .map(|entry| {
1795            let bytes = directory_size(&entry.path())?;
1796            Ok((entry, bytes))
1797        })
1798        .collect::<Result<Vec<_>>>()?;
1799    runs.sort_by_key(|(entry, _)| std::cmp::Reverse(entry.file_name()));
1800    let before_bytes = runs.iter().map(|(_, bytes)| *bytes).sum::<u64>();
1801    let before_runs = runs.len();
1802    let mut retained_bytes = 0u64;
1803    let mut retained_runs = 0usize;
1804    let mut remove = Vec::new();
1805    let mut retention_prefix_exhausted = false;
1806    for (entry, bytes) in runs {
1807        if !retention_prefix_exhausted
1808            && retained_runs < keep
1809            && retained_bytes.saturating_add(bytes) <= max_bytes
1810        {
1811            retained_runs += 1;
1812            retained_bytes = retained_bytes.saturating_add(bytes);
1813        } else {
1814            retention_prefix_exhausted = true;
1815            remove.push((entry, bytes));
1816        }
1817    }
1818    let selected = remove
1819        .iter()
1820        .map(|(entry, bytes)| json!({"path": entry.path(), "bytes": bytes}))
1821        .collect::<Vec<_>>();
1822    if args.format == DisplayFormat::Text {
1823        for (entry, _) in &remove {
1824            println!(
1825                "{} {}",
1826                if args.dry_run {
1827                    "Would remove"
1828                } else {
1829                    "Removing"
1830                },
1831                entry.path().display()
1832            );
1833        }
1834    }
1835    for (entry, _) in &remove {
1836        if !args.dry_run {
1837            fs::remove_dir_all(entry.path())?;
1838        }
1839    }
1840    let removed_bytes = remove.iter().map(|(_, bytes)| *bytes).sum::<u64>();
1841    let payload = json!({
1842        "schema_version": 1,
1843        "command": "prune",
1844        "dry_run": args.dry_run,
1845        "limits": {"max_runs": keep, "max_bytes": max_bytes},
1846        "before": {"runs": before_runs, "bytes": before_bytes},
1847        "selected": selected,
1848        "removed": {"runs": remove.len(), "bytes": removed_bytes},
1849        "after": {"runs": retained_runs, "bytes": retained_bytes, "projected": args.dry_run}
1850    });
1851    match args.format {
1852        DisplayFormat::Text => println!(
1853            "{} {} old run snapshot(s) ({} bytes); retained {} run(s) ({} bytes).",
1854            if args.dry_run { "Selected" } else { "Pruned" },
1855            remove.len(),
1856            removed_bytes,
1857            retained_runs,
1858            retained_bytes
1859        ),
1860        DisplayFormat::Json => print_text(&render_json(&payload)?),
1861        DisplayFormat::Yaml => print_text(&serde_yaml::to_string(&payload)?),
1862    }
1863    Ok(0)
1864}
1865
1866fn directory_size(path: &Path) -> Result<u64> {
1867    let mut total = 0u64;
1868    for entry in fs::read_dir(path)? {
1869        let entry = entry?;
1870        let metadata = fs::symlink_metadata(entry.path())?;
1871        if metadata.is_dir() {
1872            total = total.saturating_add(directory_size(&entry.path())?);
1873        } else {
1874            total = total.saturating_add(metadata.len());
1875        }
1876    }
1877    Ok(total)
1878}
1879
1880fn run_cache(repo_root: &Path, args: CacheArgs) -> Result<i32> {
1881    let state_root = args.state_dir.map_or_else(
1882        || config::slop_dir(repo_root),
1883        |path| {
1884            if path.is_absolute() {
1885                path
1886            } else {
1887                repo_root.join(path)
1888            }
1889        },
1890    );
1891    let (payload, format) = match args.command {
1892        CacheCommand::Status { format } => (crate::cache::status(&state_root)?, format),
1893        CacheCommand::Prune {
1894            max_entries,
1895            max_bytes,
1896            dry_run,
1897            format,
1898        } => (
1899            crate::cache::prune(&state_root, max_entries, max_bytes, dry_run)?,
1900            format,
1901        ),
1902    };
1903    match format {
1904        DisplayFormat::Json => print_text(&render_json(&payload)?),
1905        DisplayFormat::Yaml => print_text(&serde_yaml::to_string(&payload)?),
1906        DisplayFormat::Text => {
1907            if payload["command"] == "cache status" {
1908                println!(
1909                    "Cache {}: {} entries, {} payload bytes, {} database bytes.",
1910                    payload["status"].as_str().unwrap_or("unknown"),
1911                    payload["entries"],
1912                    payload["payload_bytes"],
1913                    payload["database_bytes"]
1914                );
1915            } else {
1916                println!(
1917                    "{} {} cache entries ({} payload bytes).",
1918                    if payload["dry_run"].as_bool().unwrap_or(false) {
1919                        "Would prune"
1920                    } else {
1921                        "Pruned"
1922                    },
1923                    payload["removed_entries"],
1924                    payload["removed_payload_bytes"]
1925                );
1926            }
1927        }
1928    }
1929    Ok(0)
1930}
1931
1932fn run_completions(args: CompletionsArgs) -> Result<i32> {
1933    let mut command = Cli::command();
1934    let mut stdout = std::io::stdout().lock();
1935    match args.shell {
1936        CompletionShell::Bash => generate(Shell::Bash, &mut command, PROJECT_NAME, &mut stdout),
1937        CompletionShell::Zsh => generate(Shell::Zsh, &mut command, PROJECT_NAME, &mut stdout),
1938        CompletionShell::Fish => generate(Shell::Fish, &mut command, PROJECT_NAME, &mut stdout),
1939        CompletionShell::Powershell => {
1940            generate(Shell::PowerShell, &mut command, PROJECT_NAME, &mut stdout)
1941        }
1942        CompletionShell::Nushell => generate(
1943            clap_complete_nushell::Nushell,
1944            &mut command,
1945            PROJECT_NAME,
1946            &mut stdout,
1947        ),
1948    }
1949    Ok(0)
1950}
1951
1952fn write_generated_output(output: Option<&Path>, bytes: &[u8]) -> Result<()> {
1953    if let Some(path) = output {
1954        if let Some(parent) = path.parent() {
1955            fs::create_dir_all(parent)?;
1956        }
1957        fs::write(path, bytes)?;
1958    } else {
1959        use std::io::Write;
1960        std::io::stdout().lock().write_all(bytes)?;
1961    }
1962    Ok(())
1963}
1964
1965fn run_man(args: ManArgs) -> Result<i32> {
1966    let mut bytes = Vec::new();
1967    clap_mangen::Man::new(Cli::command()).render(&mut bytes)?;
1968    let rendered = String::from_utf8(bytes).context("generated manual was not UTF-8")?;
1969    let normalized = format!(
1970        "{}\n",
1971        rendered
1972            .lines()
1973            .map(str::trim_end)
1974            .collect::<Vec<_>>()
1975            .join("\n")
1976    );
1977    write_generated_output(args.output.as_deref(), normalized.as_bytes())?;
1978    Ok(0)
1979}
1980
1981fn markdown_command(command: &clap::Command, path: &str, output: &mut String) {
1982    output.push_str(&format!("## `{path}`\n\n"));
1983    if let Some(about) = command.get_about() {
1984        output.push_str(&format!("{about}\n\n"));
1985    }
1986    let arguments = command.get_arguments().collect::<Vec<_>>();
1987    if !arguments.is_empty() {
1988        output.push_str("| Argument | Description |\n| --- | --- |\n");
1989        for argument in arguments {
1990            let name = argument
1991                .get_long()
1992                .map(|value| format!("--{value}"))
1993                .unwrap_or_else(|| argument.get_id().to_string());
1994            let help = argument
1995                .get_help()
1996                .map(ToString::to_string)
1997                .unwrap_or_default();
1998            output.push_str(&format!("| `{name}` | {} |\n", help.replace('|', "\\|")));
1999        }
2000        output.push('\n');
2001    }
2002    for subcommand in command.get_subcommands() {
2003        markdown_command(
2004            subcommand,
2005            &format!("{path} {}", subcommand.get_name()),
2006            output,
2007        );
2008    }
2009}
2010
2011fn run_reference(args: ReferenceArgs) -> Result<i32> {
2012    let command = Cli::command();
2013    let mut markdown =
2014        "# Git Slop CLI Reference\n\nGenerated from the live Clap command tree.\n\n".to_string();
2015    markdown_command(&command, "git-slop", &mut markdown);
2016    let normalized = format!("{}\n", markdown.trim_end());
2017    write_generated_output(args.output.as_deref(), normalized.as_bytes())?;
2018    Ok(0)
2019}
2020
2021fn run_html(repo_root: &Path, args: HtmlArgs) -> Result<i32> {
2022    let explicit_report = args.report.clone();
2023    let (loaded, report_path) = match report_or_missing(repo_root, args.report.as_deref())? {
2024        Ok(value) => value,
2025        Err(code) => return Ok(code),
2026    };
2027    let output = args.output.unwrap_or_else(|| {
2028        explicit_report
2029            .as_ref()
2030            .and_then(|path| path.parent())
2031            .map_or_else(|| config::latest_dir(repo_root), Path::to_path_buf)
2032            .join("report.html")
2033    });
2034    if let Some(parent) = output.parent() {
2035        fs::create_dir_all(parent)?;
2036    }
2037    let payload = serde_json::to_string(&json!({
2038        "schema_version": loaded.get("schema_version"),
2039        "generated_at": loaded.get("generated_at"),
2040        "analyzed_revision_at": loaded.get("analyzed_revision_at"),
2041        "analyzer": loaded.get("analyzer"),
2042        "repo": loaded.get("repo"),
2043        "scope": loaded.get("scope"),
2044        "config_digests": loaded.pointer("/analyzer/config_digests"),
2045        "collection_metadata": loaded.get("collection_metadata"),
2046        "evidence_completeness": loaded.get("evidence_completeness"),
2047        "files": loaded.get("files"),
2048        "action_queue": loaded.get("action_queue"),
2049        "health": {
2050            "summary": loaded.pointer("/health/summary"),
2051            "findings": loaded.pointer("/health/findings")
2052        },
2053        "organization": {
2054            "relationships": loaded.pointer("/overlays/organization_health/relationships"),
2055            "clusters": loaded.pointer("/overlays/organization_health/clusters")
2056        },
2057        "source_report": report_path
2058    }))?
2059    .replace("</", "<\\/");
2060    let html = format!(
2061        r#"<!doctype html>
2062<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
2063<title>Git Slop local report</title><style>
2064:root {{ color-scheme: light dark; font: 15px system-ui,sans-serif }} body {{ margin: 2rem; max-width: 1100px }}
2065input,select {{ padding:.55rem; margin:0 .5rem .75rem 0 }} table {{ width:100%; border-collapse:collapse }}
2066th,td {{ text-align:left; padding:.5rem; border-bottom:1px solid #8885 }} th button {{ all:unset;cursor:pointer;font-weight:700 }}
2067code {{ overflow-wrap:anywhere }} details {{ margin:1rem 0 }} .muted {{ opacity:.7 }} .sr {{ position:absolute;left:-10000px }}
2068.views button[aria-pressed="true"] {{ font-weight:700;text-decoration:underline }} tr:target {{ outline:2px solid currentColor }}
2069</style></head><body><h1>Git Slop local report</h1><p id="descriptor" class="muted"></p>
2070<nav class="views" aria-label="Report view"><button data-view="files" aria-pressed="true">Files</button> <button data-view="queue" aria-pressed="false">Action queue</button> <button data-view="health" aria-pressed="false">Health findings</button></nav>
2071<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>
2072<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>
2073<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">Git Slop records</caption><thead><tr><th scope="col"><button data-key="path">Path</button></th><th scope="col"><button data-key="profile">Profile</button></th><th scope="col"><button data-key="language">Language</button></th><th scope="col"><button data-key="slop_band">Maintenance</button></th><th scope="col"><button data-key="context_band">Context</button></th><th scope="col"><button data-key="slop_score">Score</button></th><th scope="col"><button data-key="tokens">Tokens</button></th></tr></thead><tbody id="rows"></tbody></table>
2074<details id="file-detail"><summary>Selected record details</summary><pre id="detail"></pre></details>
2075<details><summary>Relationships</summary><pre id="relationships"></pre></details>
2076<script id="report" type="application/json">{payload}</script><script>
2077const report=JSON.parse(document.getElementById('report').textContent), params=new URLSearchParams(location.search); let view=params.get('view')||'files', sortKey=params.get('sort')||'slop_score', ascending=params.get('dir')==='asc', page=Number(params.get('page')||0); const pageSize=100;
2078const files=report.files||[], queue=report.action_queue||[], findings=report.health?.findings||[]; const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}}[c]));
2079document.getElementById('descriptor').textContent=`${{report.repo?.repo_name||'repository'}} · ${{report.generated_at||'unknown time'}} · schema ${{report.schema_version}}`;
2080const profile=document.getElementById('profile'); [...new Set(files.map(f=>f.profile).filter(Boolean))].sort().forEach(v=>profile.insertAdjacentHTML('beforeend',`<option>${{esc(v)}}</option>`));
2081document.getElementById('query').value=params.get('q')||''; profile.value=params.get('profile')||''; document.getElementById('severity').value=params.get('band')||'';
2082function records() {{ return view==='queue'?queue:view==='health'?findings:files }}
2083function syncUrl() {{ const p=new URLSearchParams(); for (const [k,v] of Object.entries({{view,q:document.getElementById('query').value,profile:profile.value,band:document.getElementById('severity').value,sort:sortKey,dir:ascending?'asc':'desc',page}})) if(v!==''&&v!==0)p.set(k,v); history.replaceState(null,'',`${{location.pathname}}?${{p}}${{location.hash}}`) }}
2084function render() {{ const q=document.getElementById('query').value.toLowerCase(), p=profile.value, s=document.getElementById('severity').value, source=records();
2085 document.querySelectorAll('[data-view]').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.view===view)));
2086 const selected=source.filter(f=>(!q||String(f.path||f.id).toLowerCase().includes(q))&&(!p||f.profile===p)&&(!s||(f.slop_band||f.severity)===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)}});
2087 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);
2088 document.getElementById('count').textContent=`${{selected.length}} of ${{source.length}} ${{view.replace('_',' ')}} records · page ${{page+1}} of ${{pages}}`;
2089 document.getElementById('previous').disabled=page===0; document.getElementById('next').disabled=page+1>=pages;
2090 document.getElementById('rows').innerHTML=visible.map((f,i)=>`<tr id="record-${{page*pageSize+i}}" data-index="${{page*pageSize+i}}"><td><button class="record"><code>${{esc(f.path||f.id)}}</code></button></td><td>${{esc(f.profile||f.kind)}}</td><td>${{esc(f.language)}}</td><td>${{esc(f.slop_band||f.severity)}}</td><td>${{esc(f.context_band)}}</td><td>${{esc(f.slop_score||f.evidence_score)}}</td><td>${{esc(f.tokens)}}</td></tr>`).join('');
2091 document.querySelectorAll('.record').forEach((button,i)=>button.addEventListener('click',()=>{{document.getElementById('detail').textContent=JSON.stringify(visible[i],null,2);document.getElementById('file-detail').open=true;location.hash=`record-${{page*pageSize+i}}`}})); syncUrl(); }}
2092document.querySelectorAll('input,select').forEach(el=>el.addEventListener('input',()=>{{page=0;render()}})); document.querySelectorAll('th button').forEach(el=>el.addEventListener('click',()=>{{ascending=sortKey===el.dataset.key?!ascending:true;sortKey=el.dataset.key;page=0;render()}}));
2093document.querySelectorAll('[data-view]').forEach(el=>el.addEventListener('click',()=>{{view=el.dataset.view;page=0;render()}})); document.getElementById('previous').addEventListener('click',()=>{{page=Math.max(0,page-1);render()}}); document.getElementById('next').addEventListener('click',()=>{{page+=1;render()}});
2094document.getElementById('relationships').parentElement.addEventListener('toggle',e=>{{if(e.target.open&&!e.target.dataset.loaded){{e.target.dataset.loaded='true';document.getElementById('relationships').textContent=JSON.stringify(report.organization?.relationships||{{}},null,2)}}}}); render();
2095</script></body></html>"#
2096    );
2097    fs::write(&output, html)?;
2098    println!("Wrote local HTML report to {}.", output.display());
2099    Ok(0)
2100}
2101
2102fn execute(repo_root: &Path, command: Command) -> Result<i32> {
2103    match command {
2104        Command::Init(args) => run_init(repo_root, args),
2105        Command::Find(args) => run_find(repo_root, args),
2106        Command::Show(args) => run_show(repo_root, args),
2107        Command::Explain(args) => run_explain(repo_root, args),
2108        Command::Plan(args) => run_plan(repo_root, args),
2109        Command::Check(args) => run_check(repo_root, args),
2110        Command::Compare(args) => run_compare(repo_root, args),
2111        Command::Report(args) => run_report(args),
2112        Command::Sarif(args) => run_sarif(repo_root, args),
2113        Command::Health(args) => run_health(repo_root, args),
2114        Command::Config(args) => run_config(repo_root, args),
2115        Command::Doctor(args) => run_doctor(repo_root, args),
2116        Command::List(args) => run_list(repo_root, args),
2117        Command::Prune(args) => run_prune(repo_root, args),
2118        Command::Cache(args) => run_cache(repo_root, args),
2119        Command::Completions(args) => run_completions(args),
2120        Command::Man(args) => run_man(args),
2121        Command::Reference(args) => run_reference(args),
2122        Command::Html(args) => run_html(repo_root, args),
2123        Command::Version => {
2124            println!("{PROJECT_NAME} {VERSION}");
2125            Ok(0)
2126        }
2127        Command::BuildInfo(args) => {
2128            match args.format {
2129                BuildInfoFormat::Json => {
2130                    println!("{}", serde_json::to_string_pretty(&build_info::current())?)
2131                }
2132            }
2133            Ok(0)
2134        }
2135        Command::Schema(args) => {
2136            let source = match args.contract {
2137                SchemaContract::Report => {
2138                    return {
2139                        print_text(&render_json(&report::schema())?);
2140                        Ok(0)
2141                    };
2142                }
2143                SchemaContract::Config => {
2144                    return {
2145                        print_text(&render_json(&config::schema())?);
2146                        Ok(0)
2147                    };
2148                }
2149                SchemaContract::Compare => include_str!("../schemas/compare-1.json"),
2150                SchemaContract::Explain => include_str!("../schemas/explain-2.json"),
2151                SchemaContract::Plan => include_str!("../schemas/plan-2.json"),
2152                SchemaContract::Sarif => include_str!("../schemas/sarif-1.json"),
2153                SchemaContract::Health => include_str!("../schemas/health-1.json"),
2154                SchemaContract::Check => include_str!("../schemas/check-1.json"),
2155                SchemaContract::Doctor => include_str!("../schemas/doctor-1.json"),
2156                SchemaContract::BuildInfo => include_str!("../schemas/build-info-1.json"),
2157                SchemaContract::List => include_str!("../schemas/list-1.json"),
2158                SchemaContract::Show => include_str!("../schemas/show-1.json"),
2159                SchemaContract::PromptManifest => {
2160                    include_str!("../schemas/prompt-manifest-1.json")
2161                }
2162            };
2163            let value: Value = serde_json::from_str(source)?;
2164            print_text(&render_json(&value)?);
2165            Ok(0)
2166        }
2167    }
2168}
2169
2170fn command_requires_repository(command: &Command) -> bool {
2171    match command {
2172        Command::Completions(_)
2173        | Command::Man(_)
2174        | Command::Reference(_)
2175        | Command::Version
2176        | Command::BuildInfo(_)
2177        | Command::Schema(_)
2178        | Command::Report(_)
2179        | Command::Config(ConfigArgs {
2180            command: ConfigCommand::Schema,
2181        }) => false,
2182        Command::Show(args) => args.report.is_none(),
2183        Command::Compare(args) => args.base_ref.is_some(),
2184        Command::Explain(args) => args.report.is_none() || args.include_repository_context,
2185        Command::Plan(args) => args.report.is_none() || args.include_repository_context,
2186        Command::Check(args) => args.report.is_none(),
2187        Command::Sarif(args) => args.report.is_none(),
2188        Command::Health(args) => args.report.is_none(),
2189        Command::Html(args) => args.report.is_none(),
2190        Command::List(ListArgs {
2191            command:
2192                ListCommand::Findings(args)
2193                | ListCommand::Relationships(args)
2194                | ListCommand::Clusters(args)
2195                | ListCommand::Profiles(args),
2196        }) => args.report.is_none(),
2197        _ => true,
2198    }
2199}
2200
2201pub fn run() -> i32 {
2202    let cli = match Cli::try_parse() {
2203        Ok(cli) => cli,
2204        Err(error) => {
2205            let code = error.exit_code();
2206            let _ = error.print();
2207            return code;
2208        }
2209    };
2210    let error_format = cli.error_format;
2211    let repo_root = if command_requires_repository(&cli.command) {
2212        match git::resolve_repo_root_from(cli.repo.as_deref()) {
2213            Ok(root) => root,
2214            Err(error) => {
2215                render_runtime_error(
2216                    error_format,
2217                    &ClassifiedError::new(
2218                        ErrorKind::Repository,
2219                        "repository_not_found",
2220                        format!("{error:#}"),
2221                    ),
2222                );
2223                return 3;
2224            }
2225        }
2226    } else {
2227        PathBuf::new()
2228    };
2229    match execute(&repo_root, cli.command) {
2230        Ok(code) => code,
2231        Err(error) => {
2232            let classified = error.downcast_ref::<ClassifiedError>();
2233            let fallback;
2234            let classified = if let Some(classified) = classified {
2235                classified
2236            } else {
2237                fallback = if error.downcast_ref::<std::io::Error>().is_some() {
2238                    ClassifiedError::new(ErrorKind::Io, "io_failure", format!("{error:#}"))
2239                } else {
2240                    ClassifiedError::new(
2241                        ErrorKind::Repository,
2242                        "operation_failed",
2243                        format!("{error:#}"),
2244                    )
2245                };
2246                &fallback
2247            };
2248            render_runtime_error(error_format, classified);
2249            classified.kind.exit_code()
2250        }
2251    }
2252}
2253
2254fn render_runtime_error(format: ErrorFormat, error: &ClassifiedError) {
2255    match format {
2256        ErrorFormat::Human => eprintln!("{}", error.message),
2257        ErrorFormat::Json => eprintln!(
2258            "{}",
2259            serde_json::to_string(&json!({
2260                "schema_version": 1,
2261                "error": {
2262                    "kind": error.kind,
2263                    "code": error.code,
2264                    "pointer": error.pointer,
2265                    "message": error.message
2266                }
2267            }))
2268            .unwrap_or_else(|_| {
2269                "{\"schema_version\":1,\"error\":{\"code\":\"serialization_failed\"}}".to_string()
2270            })
2271        ),
2272    }
2273}