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, bail};
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, PromptPackOptions, compare_payload_with_policy, explain_payload,
18    failing_records_in, health_json_payload, plan_payload, render_compare_text,
19    render_explain_text, render_github_annotations, render_json, render_plan_text,
20    render_show_text, sarif_payload, 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    /// Manage named comparison baselines in Git-private runtime storage.
58    Baseline(BaselineArgs),
59    /// Validate or inspect the versioned report contract.
60    Report(ReportArgs),
61    /// Export action-queue findings from an existing schema-5 report as SARIF.
62    Sarif(SarifArgs),
63    /// Render repository health for CI summaries, annotations, or automation.
64    Health(HealthArgs),
65    /// Inspect or migrate effective configuration.
66    Config(ConfigArgs),
67    /// Diagnose repository readiness and optionally write a redacted bundle.
68    Doctor(DoctorArgs),
69    /// List findings, relationships, clusters, or profiles.
70    List(ListArgs),
71    /// Remove old immutable run snapshots according to retention policy.
72    Prune(PruneArgs),
73    /// Inspect or prune the packed token cache.
74    Cache(CacheArgs),
75    /// Generate shell completion source.
76    Completions(CompletionsArgs),
77    /// Generate the roff manual from the live Clap command tree.
78    Man(ManArgs),
79    /// Generate Markdown command reference from the live Clap command tree.
80    Reference(ReferenceArgs),
81    /// Write a self-contained, local, searchable HTML report.
82    Html(HtmlArgs),
83    /// Print version information.
84    Version,
85    /// Print package and source-build provenance.
86    BuildInfo(BuildInfoArgs),
87    /// Print a published JSON Schema for a machine contract.
88    Schema(SchemaArgs),
89}
90
91impl Command {
92    fn name(&self) -> &'static str {
93        match self {
94            Self::Init(_) => "init",
95            Self::Find(_) => "find",
96            Self::Show(_) => "show",
97            Self::Explain(_) => "explain",
98            Self::Plan(_) => "plan",
99            Self::Check(_) => "check",
100            Self::Compare(_) => "compare",
101            Self::Baseline(_) => "baseline",
102            Self::Report(_) => "report",
103            Self::Sarif(_) => "sarif",
104            Self::Health(_) => "health",
105            Self::Config(_) => "config",
106            Self::Doctor(_) => "doctor",
107            Self::List(_) => "list",
108            Self::Prune(_) => "prune",
109            Self::Cache(_) => "cache",
110            Self::Completions(_) => "completions",
111            Self::Man(_) => "man",
112            Self::Reference(_) => "reference",
113            Self::Html(_) => "html",
114            Self::Version => "version",
115            Self::BuildInfo(_) => "build-info",
116            Self::Schema(_) => "schema",
117        }
118    }
119}
120
121#[derive(Debug, Args)]
122struct FindArgs {
123    /// Acknowledge incomplete history and continue in a shallow clone.
124    #[arg(long)]
125    allow_shallow: bool,
126    /// Analyze only this repo-relative path while retaining repository-wide Git evidence.
127    #[arg(long)]
128    scope: Option<String>,
129    /// Permit a scope that selects no tracked paths and emit an empty analysis.
130    #[arg(long)]
131    allow_empty_scope: bool,
132    /// Suppress human progress and report-path messages.
133    #[arg(long)]
134    quiet: bool,
135    /// Suppress phase progress while preserving the final result.
136    #[arg(long)]
137    no_progress: bool,
138    /// Mutable cache/state directory. Relative paths resolve from the repository root.
139    #[arg(long, value_name = "PATH")]
140    state_dir: Option<PathBuf>,
141    /// Report output directory. Relative paths resolve from the repository root.
142    #[arg(long, value_name = "PATH")]
143    output_dir: Option<PathBuf>,
144    /// Disable token-cache reads and writes for an ephemeral scan.
145    #[arg(long)]
146    no_cache: bool,
147    /// Keep disposable state and reports under Git-private storage, without adopting `.slop/`.
148    #[arg(long, conflicts_with_all = ["state_dir", "output_dir"])]
149    ephemeral: bool,
150    /// Deterministically analyze the largest path prefix that fits the memory budget.
151    #[arg(long)]
152    allow_degraded: bool,
153    /// Fixed RFC 3339 analysis clock for reproducible recency and history windows.
154    #[arg(long, value_name = "RFC3339")]
155    as_of: Option<String>,
156    /// Report evidence profile.
157    #[arg(long, value_enum, default_value_t = ReportProfile::Standard)]
158    report_profile: ReportProfile,
159    /// Also write a compressed report beside report.json.
160    #[arg(long, value_enum, default_value_t = ReportCompression::None)]
161    compression: ReportCompression,
162    /// Estimate scope, memory, cache, report size, time, and inodes without scanning.
163    #[arg(long)]
164    estimate_only: bool,
165}
166
167#[derive(Debug, Clone, Copy, ValueEnum)]
168enum ReportProfile {
169    Compact,
170    Standard,
171    FullEvidence,
172}
173
174impl ReportProfile {
175    fn as_str(self) -> &'static str {
176        match self {
177            Self::Compact => "compact",
178            Self::Standard => "standard",
179            Self::FullEvidence => "full_evidence",
180        }
181    }
182}
183
184#[derive(Debug, Clone, Copy, ValueEnum)]
185enum ReportCompression {
186    None,
187    Gzip,
188    Zstd,
189}
190
191impl ReportCompression {
192    fn as_str(self) -> &'static str {
193        match self {
194            Self::None => "none",
195            Self::Gzip => "gzip",
196            Self::Zstd => "zstd",
197        }
198    }
199}
200
201#[derive(Debug, Args)]
202struct BuildInfoArgs {
203    /// Machine-readable build provenance format.
204    #[arg(long, value_enum, default_value_t = BuildInfoFormat::Json)]
205    format: BuildInfoFormat,
206}
207
208#[derive(Debug, Args)]
209#[command(group(
210    ArgGroup::new("mode")
211        .args(["force", "repair", "check"])
212        .multiple(false)
213))]
214struct InitArgs {
215    /// Replace generated files atomically and keep ignored `.bak` recovery copies.
216    #[arg(long)]
217    force: bool,
218    /// Add missing generated ignore rules without replacing repository configuration.
219    #[arg(long)]
220    repair: bool,
221    /// Inspect adoption files without changing the repository.
222    #[arg(long)]
223    check: bool,
224    /// Limit initialization, repair, force, or check to .slop/.gitignore.
225    #[arg(long)]
226    gitignore_only: bool,
227}
228
229#[derive(Debug, Args)]
230struct ShowArgs {
231    /// Repo-relative file or folder path.
232    target_path: String,
233    /// Report path. Defaults to .slop/latest/report.json.
234    #[arg(long)]
235    report: Option<PathBuf>,
236    /// Output format.
237    #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
238    format: DisplayFormat,
239}
240
241#[derive(Debug, Args)]
242#[command(group(
243    ArgGroup::new("selector")
244        .args(["path", "cluster", "relationship", "top"])
245        .multiple(false)
246))]
247struct ExplainArgs {
248    /// Report path. Defaults to .slop/latest/report.json.
249    #[arg(long)]
250    report: Option<PathBuf>,
251    /// Repo-relative file or folder path.
252    #[arg(long)]
253    path: Option<String>,
254    /// Cluster identifier.
255    #[arg(long)]
256    cluster: Option<String>,
257    /// Relationship identifier.
258    #[arg(long)]
259    relationship: Option<String>,
260    /// Explain the top N hotspots from the action queue.
261    #[arg(long)]
262    top: Option<i64>,
263    /// Output format.
264    #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
265    format: DisplayFormat,
266    /// Write a deterministic local-model prompt pack to this directory.
267    #[arg(long)]
268    prompt_pack: Option<PathBuf>,
269    /// Atomically replace an existing prompt-pack directory.
270    #[arg(long, requires = "prompt_pack")]
271    force: bool,
272    /// Include bounded local source/test excerpts, guidance, and verification hints.
273    #[arg(long, requires = "prompt_pack")]
274    include_repository_context: bool,
275    /// Maximum bytes read from each included repository file.
276    #[arg(long, default_value_t = 2048, requires = "include_repository_context")]
277    excerpt_bytes: usize,
278    /// Include local filesystem paths in prompt-pack provenance and commands.
279    #[arg(long, requires = "prompt_pack")]
280    include_local_paths: bool,
281}
282
283#[derive(Debug, Args)]
284#[command(group(
285    ArgGroup::new("selector")
286        .args(["path", "cluster", "relationship"])
287        .required(true)
288        .multiple(false)
289))]
290struct PlanArgs {
291    /// Report path. Defaults to .slop/latest/report.json.
292    #[arg(long)]
293    report: Option<PathBuf>,
294    /// Repo-relative file or folder path.
295    #[arg(long)]
296    path: Option<String>,
297    /// Cluster identifier.
298    #[arg(long)]
299    cluster: Option<String>,
300    /// Relationship identifier.
301    #[arg(long)]
302    relationship: Option<String>,
303    /// Maximum number of bounded maintenance slices to propose.
304    #[arg(long, default_value_t = 3)]
305    max_slices: i64,
306    /// Output format.
307    #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
308    format: DisplayFormat,
309    /// Write a deterministic local-model prompt pack to this directory.
310    #[arg(long)]
311    prompt_pack: Option<PathBuf>,
312    /// Atomically replace an existing prompt-pack directory.
313    #[arg(long, requires = "prompt_pack")]
314    force: bool,
315    /// Include bounded local source/test excerpts, guidance, and verification hints.
316    #[arg(long, requires = "prompt_pack")]
317    include_repository_context: bool,
318    /// Maximum bytes read from each included repository file.
319    #[arg(long, default_value_t = 2048, requires = "include_repository_context")]
320    excerpt_bytes: usize,
321    /// Include local filesystem paths in prompt-pack provenance and commands.
322    #[arg(long, requires = "prompt_pack")]
323    include_local_paths: bool,
324}
325
326#[derive(Debug, Args)]
327struct CheckArgs {
328    /// Report path. Defaults to .slop/latest/report.json.
329    #[arg(long)]
330    report: Option<PathBuf>,
331    /// Override the config default fail threshold for context_band.
332    #[arg(long, value_enum)]
333    fail_on_context_band: Option<ContextBand>,
334    /// Override the config default fail threshold for slop_band.
335    #[arg(long, value_enum)]
336    fail_on_slop_band: Option<SlopBand>,
337    /// Output format, including escaped GitHub workflow commands.
338    #[arg(long, value_enum, default_value_t = CheckFormat::Text)]
339    format: CheckFormat,
340    /// Include complete finding records in JSON output.
341    #[arg(long)]
342    details: bool,
343    /// Include folder records in addition to the versioned file-only gate.
344    #[arg(long)]
345    include_folders: bool,
346    /// Zero-based finding offset used with --details.
347    #[arg(long, default_value_t = 0, requires = "details")]
348    offset: usize,
349    /// Maximum finding records returned with --details.
350    #[arg(long, default_value_t = 1000, requires = "details")]
351    limit: usize,
352    /// Permit policy evaluation when selected inventory records are incomplete.
353    #[arg(long)]
354    allow_incomplete_evidence: bool,
355    /// Evaluate and report the canonical policy result without returning exit 1 for findings.
356    #[arg(long)]
357    evaluate_only: bool,
358    /// Fail when the report does not match current HEAD, worktree, config, scope, or analyzer.
359    #[arg(long)]
360    require_current: bool,
361}
362
363#[derive(Debug, Args)]
364struct CompareArgs {
365    /// Mutable state directory for named baselines. Relative paths resolve from the repository root.
366    #[arg(long, value_name = "PATH")]
367    state_dir: Option<PathBuf>,
368    /// Base report.json path.
369    #[arg(
370        long,
371        required_unless_present_any = ["base_ref", "baseline"],
372        conflicts_with_all = ["base_ref", "baseline"]
373    )]
374    base: Option<PathBuf>,
375    /// Safely resolve and scan this Git revision in an isolated worktree.
376    #[arg(long, conflicts_with_all = ["base", "baseline"])]
377    base_ref: Option<String>,
378    /// Use a named baseline from Git-private runtime storage.
379    #[arg(long, conflicts_with_all = ["base", "base_ref"])]
380    baseline: Option<String>,
381    /// Head report.json path.
382    #[arg(long, default_value = ".slop/latest/report.json")]
383    head: PathBuf,
384    /// Apply the head repository's scope to an isolated --base-ref scan.
385    #[arg(long)]
386    scope: Option<String>,
387    /// Permit incomplete history in an isolated --base-ref scan.
388    #[arg(long)]
389    allow_shallow: bool,
390    /// Permit comparison when selected inventory records are incomplete.
391    #[arg(long)]
392    allow_incomplete_evidence: bool,
393    /// Maximum number of changed files and queue movements to show.
394    #[arg(long, default_value_t = 10)]
395    top: i64,
396    /// Output format.
397    #[arg(long, value_enum, default_value_t = CompareFormat::Text)]
398    format: CompareFormat,
399    /// Detail level for machine output.
400    #[arg(long, value_enum, default_value_t = CompareDetail::Top)]
401    detail: CompareDetail,
402    /// Zero-based record offset for --detail full.
403    #[arg(long, default_value_t = 0)]
404    offset: usize,
405    /// Maximum records per collection for --detail full.
406    #[arg(long, default_value_t = 1000)]
407    limit: usize,
408    /// Compare reports with incompatible identity or analyzer metadata.
409    #[arg(long)]
410    force: bool,
411    /// Include local filesystem report paths in output descriptors.
412    #[arg(long)]
413    include_local_paths: bool,
414    /// Include unchanged file and folder records in bounded compare collections.
415    #[arg(long)]
416    include_unchanged: bool,
417    /// Select which report supplies regression thresholds and evidence-drift policy.
418    #[arg(long, value_enum, default_value_t = PolicySource::Base)]
419    policy_from: PolicySource,
420    /// Exit 1 when an existing file worsens or a newly added file is a finding.
421    #[arg(long)]
422    fail_on_regression: bool,
423}
424
425#[derive(Debug, Args)]
426struct BaselineArgs {
427    /// Mutable state directory. Relative paths resolve from the repository root.
428    #[arg(long, value_name = "PATH", global = true)]
429    state_dir: Option<PathBuf>,
430    #[command(subcommand)]
431    command: BaselineCommand,
432}
433
434#[derive(Debug, Subcommand)]
435enum BaselineCommand {
436    /// Idempotently save a named baseline, failing closed when stored content differs.
437    Ensure {
438        /// Stable baseline name.
439        #[arg(long, default_value = "default")]
440        name: String,
441        /// Report path. Defaults to .slop/latest/report.json.
442        #[arg(long)]
443        report: Option<PathBuf>,
444        /// Explicitly replace a differing stored baseline.
445        #[arg(long)]
446        replace: bool,
447        /// Permit a report produced from a dirty worktree.
448        #[arg(long)]
449        allow_dirty: bool,
450        /// Permit incomplete inventory or history evidence.
451        #[arg(long)]
452        allow_incomplete_evidence: bool,
453        /// Output format.
454        #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
455        format: DisplayFormat,
456    },
457    /// Create a named baseline from a validated report.
458    Create {
459        /// Stable baseline name.
460        #[arg(long, default_value = "default")]
461        name: String,
462        /// Report path. Defaults to .slop/latest/report.json.
463        #[arg(long)]
464        report: Option<PathBuf>,
465        /// Replace an existing named baseline.
466        #[arg(long)]
467        force: bool,
468        /// Permit a report produced from a dirty worktree.
469        #[arg(long)]
470        allow_dirty: bool,
471        /// Permit incomplete inventory or history evidence.
472        #[arg(long)]
473        allow_incomplete_evidence: bool,
474        /// Output format.
475        #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
476        format: DisplayFormat,
477    },
478    /// Replace an existing named baseline from a validated report.
479    Update {
480        /// Stable baseline name.
481        #[arg(long, default_value = "default")]
482        name: String,
483        /// Report path. Defaults to .slop/latest/report.json.
484        #[arg(long)]
485        report: Option<PathBuf>,
486        /// Permit a report produced from a dirty worktree.
487        #[arg(long)]
488        allow_dirty: bool,
489        /// Permit incomplete inventory or history evidence.
490        #[arg(long)]
491        allow_incomplete_evidence: bool,
492        /// Output format.
493        #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
494        format: DisplayFormat,
495    },
496    /// List named baselines with identity and readiness metadata.
497    List {
498        /// Output format.
499        #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
500        format: DisplayFormat,
501    },
502    /// Inspect baseline identity and evidence status.
503    Inspect {
504        /// Stable baseline name.
505        #[arg(long, default_value = "default")]
506        name: String,
507        /// Output format.
508        #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
509        format: DisplayFormat,
510    },
511    /// Validate a named baseline against the current report contract.
512    Validate {
513        /// Stable baseline name.
514        #[arg(long, default_value = "default")]
515        name: String,
516        /// Output format.
517        #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
518        format: DisplayFormat,
519    },
520    /// Remove a named baseline.
521    Remove {
522        /// Stable baseline name.
523        #[arg(long, default_value = "default")]
524        name: String,
525        /// Output format.
526        #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
527        format: DisplayFormat,
528        /// Apply the removal. Without this flag the command is a read-only preview.
529        #[arg(long)]
530        yes: bool,
531    },
532}
533
534#[derive(Debug, Args)]
535struct ReportArgs {
536    #[command(subcommand)]
537    command: ReportCommand,
538}
539
540#[derive(Debug, Subcommand)]
541enum ReportCommand {
542    /// Validate one report against the complete schema-5 contract.
543    Validate {
544        /// Report JSON to validate.
545        #[arg(value_name = "REPORT_JSON", required_unless_present = "report")]
546        path: Option<PathBuf>,
547        /// Report JSON to validate (alias for the positional path).
548        #[arg(long, value_name = "REPORT_JSON", required_unless_present = "path")]
549        report: Option<PathBuf>,
550        /// Accept schema 4 as migration input and validate its normalized schema-5 form.
551        #[arg(long)]
552        allow_legacy: bool,
553        /// Success output format.
554        #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
555        format: DisplayFormat,
556    },
557    /// Migrate a schema-4 report to normalized schema 5.
558    Migrate {
559        /// Legacy report to migrate.
560        #[arg(value_name = "REPORT_JSON")]
561        path: PathBuf,
562        /// Destination for the normalized schema-5 report.
563        #[arg(long, value_name = "PATH")]
564        output: PathBuf,
565    },
566    /// Print the published JSON Schema for report schema 5.
567    Schema,
568}
569
570#[derive(Debug, Args)]
571struct SarifArgs {
572    /// Report path. Defaults to .slop/latest/report.json.
573    #[arg(long)]
574    report: Option<PathBuf>,
575    /// Maximum number of action-queue findings to export.
576    #[arg(long)]
577    top: Option<i64>,
578    /// Export configured policy failures or action-queue intervention candidates.
579    #[arg(long, value_enum, default_value_t = SarifScope::ActionQueue)]
580    scope: SarifScope,
581    /// Optional SARIF output path. Defaults to stdout.
582    #[arg(long)]
583    output: Option<PathBuf>,
584    /// Include the local source report path in SARIF invocation properties.
585    #[arg(long)]
586    include_local_paths: bool,
587}
588
589#[derive(Debug, Args)]
590struct HealthArgs {
591    /// Report path. Defaults to .slop/latest/report.json.
592    #[arg(long)]
593    report: Option<PathBuf>,
594    /// Output suited for a job summary, workflow annotations, or automation.
595    #[arg(long, value_enum, default_value_t = HealthFormat::Text)]
596    format: HealthFormat,
597    /// Maximum number of GitHub workflow annotations to emit.
598    #[arg(long, default_value_t = 10)]
599    max_annotations: usize,
600    /// Fail when the report does not match current HEAD, worktree, config, scope, or analyzer.
601    #[arg(long)]
602    require_current: bool,
603}
604
605#[derive(Debug, Args)]
606struct ConfigArgs {
607    #[command(subcommand)]
608    command: ConfigCommand,
609}
610
611#[derive(Debug, Subcommand)]
612enum ConfigCommand {
613    /// Show configuration; --effective includes defaults.
614    Show {
615        /// Include defaults after applying repository overrides.
616        #[arg(long)]
617        effective: bool,
618    },
619    /// Validate the local configuration.
620    Validate,
621    /// Show only values that differ from defaults.
622    DiffDefaults,
623    /// Rewrite legacy schema configuration as a minimal schema-2 override.
624    Migrate {
625        /// Print the migrated configuration without writing it.
626        #[arg(long)]
627        dry_run: bool,
628        /// Do not retain the existing configuration as config.yaml.bak.
629        #[arg(long)]
630        no_backup: bool,
631    },
632    /// Print the supported configuration schema as JSON.
633    Schema,
634}
635
636#[derive(Debug, Args)]
637struct DoctorArgs {
638    /// Write a privacy-safe diagnostic JSON bundle.
639    #[arg(long, num_args = 0..=1, default_missing_value = ".slop/diagnostic-bundle.json")]
640    bundle: Option<PathBuf>,
641    /// Output format.
642    #[arg(long, value_enum, default_value_t = DoctorFormat::Text)]
643    format: DoctorFormat,
644    /// Estimate only this repo-relative scope.
645    #[arg(long)]
646    scope: Option<String>,
647    /// Return exit 2 when the latest report is valid but stale.
648    #[arg(long)]
649    require_current: bool,
650}
651
652#[derive(Debug, Clone, Copy, ValueEnum)]
653enum DoctorFormat {
654    Text,
655    Json,
656}
657
658#[derive(Debug, Args)]
659struct ListArgs {
660    #[command(subcommand)]
661    command: ListCommand,
662}
663
664#[derive(Debug, Subcommand)]
665enum ListCommand {
666    Findings(ListFilterArgs),
667    Relationships(ListFilterArgs),
668    Clusters(ListFilterArgs),
669    Profiles(ListFilterArgs),
670}
671
672#[derive(Debug, Args)]
673struct ListFilterArgs {
674    /// Report path. Defaults to .slop/latest/report.json.
675    #[arg(long)]
676    report: Option<PathBuf>,
677    /// Match a finding path, relationship endpoint, or cluster member.
678    #[arg(long)]
679    path: Option<String>,
680    /// Match an analysis profile.
681    #[arg(long)]
682    profile: Option<String>,
683    /// Match a resolved file language.
684    #[arg(long)]
685    language: Option<String>,
686    /// Match a resolved file classification.
687    #[arg(long, visible_alias = "class")]
688    classification: Option<String>,
689    /// Match a finding severity.
690    #[arg(long)]
691    severity: Option<String>,
692    /// Maximum number of matched records to return.
693    #[arg(long, default_value_t = 50)]
694    top: usize,
695    /// Output format.
696    #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
697    format: DisplayFormat,
698    /// Use a wider terminal layout before truncating fields.
699    #[arg(long)]
700    wide: bool,
701    /// Never truncate terminal fields.
702    #[arg(long)]
703    no_truncate: bool,
704}
705
706#[derive(Debug, Args)]
707struct PruneArgs {
708    /// Number of newest run snapshots to retain; defaults to output.retention_runs.
709    #[arg(long)]
710    keep: Option<usize>,
711    /// Maximum total bytes retained; defaults to output.retention_bytes.
712    #[arg(long)]
713    max_bytes: Option<u64>,
714    /// Explicitly request preview behavior (preview is already the default).
715    #[arg(long, conflicts_with = "yes")]
716    dry_run: bool,
717    /// Apply the selected removals. Without this flag the command is read-only.
718    #[arg(long, conflicts_with = "dry_run")]
719    yes: bool,
720    /// Select text, JSON, or YAML output.
721    #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
722    format: DisplayFormat,
723}
724
725#[derive(Debug, Args)]
726struct CacheArgs {
727    /// Mutable state directory. Defaults to .slop, matching find.
728    #[arg(long, value_name = "PATH", global = true)]
729    state_dir: Option<PathBuf>,
730    #[command(subcommand)]
731    command: CacheCommand,
732}
733
734#[derive(Debug, Subcommand)]
735enum CacheCommand {
736    Status {
737        /// Output format.
738        #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
739        format: DisplayFormat,
740    },
741    Prune {
742        /// Maximum entries to retain.
743        #[arg(long, default_value_t = 10_000)]
744        max_entries: usize,
745        /// Maximum logical payload bytes to retain.
746        #[arg(long, default_value_t = 536_870_912)]
747        max_bytes: u64,
748        /// Preview cache removals without changing the database.
749        #[arg(long)]
750        dry_run: bool,
751        /// Reclaim free database pages after pruning.
752        #[arg(long)]
753        compact: bool,
754        /// Output format.
755        #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
756        format: DisplayFormat,
757    },
758}
759
760#[derive(Debug, Args)]
761struct CompletionsArgs {
762    /// Shell whose completion source should be generated.
763    shell: CompletionShell,
764}
765
766#[derive(Debug, Args)]
767struct ManArgs {
768    /// Destination file. Defaults to stdout.
769    #[arg(long)]
770    output: Option<PathBuf>,
771}
772
773#[derive(Debug, Args)]
774struct ReferenceArgs {
775    /// Index destination. Detailed command pages use the sibling stem directory.
776    /// Without an output, the complete reference is written to stdout.
777    #[arg(long)]
778    output: Option<PathBuf>,
779}
780
781#[derive(Debug, Args)]
782struct HtmlArgs {
783    /// Report path. Defaults to .slop/latest/report.json.
784    #[arg(long)]
785    report: Option<PathBuf>,
786    /// Destination. Defaults to .slop/latest/report.html.
787    #[arg(long)]
788    output: Option<PathBuf>,
789    /// Embed the local source report path in the otherwise portable HTML file.
790    #[arg(long)]
791    include_local_paths: bool,
792}
793
794include!("cli/formats.rs");
795include!("cli/support.rs");
796include!("cli/support/validation.rs");
797include!("cli/init.rs");
798include!("cli/analysis.rs");
799include!("cli/analysis_receipt.rs");
800include!("cli/check.rs");
801include!("cli/baseline_compare.rs");
802include!("cli/reporting.rs");
803include!("cli/doctor.rs");
804include!("cli/listing.rs");
805include!("cli/generation.rs");
806include!("cli/generation/artifacts.rs");
807include!("cli/generation/reference.rs");
808include!("cli/generation/reference/bundle.rs");
809fn execute(repo_root: &Path, command: Command) -> Result<i32> {
810    match command {
811        Command::Init(args) => run_init(repo_root, args),
812        Command::Find(args) => run_find(repo_root, args),
813        Command::Show(args) => run_show(repo_root, args),
814        Command::Explain(args) => run_explain(repo_root, args),
815        Command::Plan(args) => run_plan(repo_root, args),
816        Command::Check(args) => run_check(repo_root, args),
817        Command::Compare(args) => run_compare(repo_root, args),
818        Command::Baseline(args) => run_baseline(repo_root, args),
819        Command::Report(args) => run_report(args),
820        Command::Sarif(args) => run_sarif(repo_root, args),
821        Command::Health(args) => run_health(repo_root, args),
822        Command::Config(args) => run_config(repo_root, args),
823        Command::Doctor(args) => run_doctor(repo_root, args),
824        Command::List(args) => run_list(repo_root, args),
825        Command::Prune(args) => run_prune(repo_root, args),
826        Command::Cache(args) => run_cache(repo_root, args),
827        Command::Completions(args) => run_completions(args),
828        Command::Man(args) => run_man(args),
829        Command::Reference(args) => run_reference(args),
830        Command::Html(args) => run_html(repo_root, args),
831        Command::Version => {
832            println!("{PROJECT_NAME} {VERSION}");
833            Ok(0)
834        }
835        Command::BuildInfo(args) => {
836            match args.format {
837                BuildInfoFormat::Json => {
838                    println!("{}", serde_json::to_string_pretty(&build_info::current())?)
839                }
840            }
841            Ok(0)
842        }
843        Command::Schema(args) => {
844            let rendered = match args.contract {
845                SchemaContract::Report => render_json(&report::schema())?,
846                SchemaContract::Config => render_json(&config::schema())?,
847                contract => {
848                    let source = match contract {
849                        SchemaContract::Compare => include_str!("../schemas/compare-1.json"),
850                        SchemaContract::Explain => include_str!("../schemas/explain-2.json"),
851                        SchemaContract::Plan => include_str!("../schemas/plan-2.json"),
852                        SchemaContract::Sarif => include_str!("../schemas/sarif-1.json"),
853                        SchemaContract::Health => include_str!("../schemas/health-1.json"),
854                        SchemaContract::Check => include_str!("../schemas/check-1.json"),
855                        SchemaContract::Doctor => include_str!("../schemas/doctor-1.json"),
856                        SchemaContract::BuildInfo => include_str!("../schemas/build-info-2.json"),
857                        SchemaContract::ReleaseManifest => {
858                            include_str!("../schemas/release-manifest-3.json")
859                        }
860                        SchemaContract::List => include_str!("../schemas/list-1.json"),
861                        SchemaContract::Show => include_str!("../schemas/show-1.json"),
862                        SchemaContract::PromptManifest => {
863                            include_str!("../schemas/prompt-manifest-1.json")
864                        }
865                        SchemaContract::Error => include_str!("../schemas/error-1.json"),
866                        SchemaContract::FindEstimate => {
867                            include_str!("../schemas/find-estimate-1.json")
868                        }
869                        SchemaContract::CacheStatus => {
870                            include_str!("../schemas/cache-status-1.json")
871                        }
872                        SchemaContract::CachePrune => include_str!("../schemas/cache-prune-1.json"),
873                        SchemaContract::Baseline => include_str!("../schemas/baseline-1.json"),
874                        SchemaContract::Prune => include_str!("../schemas/prune-1.json"),
875                        SchemaContract::CompareNdjson => {
876                            include_str!("../schemas/compare-ndjson-1.json")
877                        }
878                        SchemaContract::Report | SchemaContract::Config => unreachable!(),
879                    };
880                    let value: Value = serde_json::from_str(source)?;
881                    render_json(&value)?
882                }
883            };
884            write_generated_output(args.output.as_deref(), rendered.as_bytes())?;
885            Ok(0)
886        }
887    }
888}
889
890fn command_requires_repository(command: &Command) -> bool {
891    match command {
892        Command::Completions(_)
893        | Command::Man(_)
894        | Command::Reference(_)
895        | Command::Version
896        | Command::BuildInfo(_)
897        | Command::Schema(_)
898        | Command::Report(_)
899        | Command::Config(ConfigArgs {
900            command: ConfigCommand::Schema,
901        }) => false,
902        Command::Show(args) => args.report.is_none(),
903        Command::Compare(args) => args.base_ref.is_some() || args.baseline.is_some(),
904        Command::Baseline(_) => true,
905        Command::Explain(args) => args.report.is_none() || args.include_repository_context,
906        Command::Plan(args) => args.report.is_none() || args.include_repository_context,
907        Command::Check(args) => args.report.is_none() || args.require_current,
908        Command::Sarif(args) => args.report.is_none(),
909        Command::Health(args) => args.report.is_none() || args.require_current,
910        Command::Html(args) => args.report.is_none(),
911        Command::List(ListArgs {
912            command:
913                ListCommand::Findings(args)
914                | ListCommand::Relationships(args)
915                | ListCommand::Clusters(args)
916                | ListCommand::Profiles(args),
917        }) => args.report.is_none(),
918        _ => true,
919    }
920}
921
922pub fn run() -> i32 {
923    let raw_args = std::env::args_os().collect::<Vec<_>>();
924    let requested_error_format = requested_error_format(&raw_args);
925    let parser_command = parser_command_name(&raw_args);
926    let cli = match Cli::try_parse_from(&raw_args) {
927        Ok(cli) => cli,
928        Err(error) => {
929            let code = error.exit_code();
930            if code == 0 || requested_error_format == ErrorFormat::Human {
931                let _ = error.print();
932            } else {
933                let classified =
934                    ClassifiedError::new(ErrorKind::Contract, "parser_error", error.to_string())
935                        .at("/arguments")
936                        .with_details(json!({"clap_kind": format!("{:?}", error.kind())}));
937                render_runtime_error(
938                    requested_error_format,
939                    &classified,
940                    parser_command.as_deref(),
941                );
942            }
943            return code;
944        }
945    };
946    let error_format = cli.error_format;
947    let command_name = cli.command.name();
948    let repo_root = if command_requires_repository(&cli.command) {
949        match git::resolve_repo_root_from(cli.repo.as_deref()) {
950            Ok(root) => root,
951            Err(error) => {
952                render_runtime_error(
953                    error_format,
954                    &ClassifiedError::new(
955                        ErrorKind::Repository,
956                        "repository_not_found",
957                        format!("{error:#}"),
958                    ),
959                    Some(command_name),
960                );
961                return 3;
962            }
963        }
964    } else {
965        PathBuf::new()
966    };
967    match execute(&repo_root, cli.command) {
968        Ok(code) => code,
969        Err(error) => {
970            let classified = error.downcast_ref::<ClassifiedError>();
971            let fallback;
972            let classified = if let Some(classified) = classified {
973                classified
974            } else {
975                fallback = if error.downcast_ref::<std::io::Error>().is_some() {
976                    ClassifiedError::new(ErrorKind::Io, "io_failure", format!("{error:#}"))
977                } else {
978                    ClassifiedError::new(
979                        ErrorKind::Repository,
980                        "operation_failed",
981                        format!("{error:#}"),
982                    )
983                };
984                &fallback
985            };
986            render_runtime_error(error_format, classified, Some(command_name));
987            classified.kind.exit_code()
988        }
989    }
990}
991
992fn requested_error_format(args: &[std::ffi::OsString]) -> ErrorFormat {
993    args.iter()
994        .filter_map(|arg| arg.to_str())
995        .enumerate()
996        .find_map(|(index, arg)| {
997            if arg == "--error-format" {
998                args.get(index + 1).and_then(|value| value.to_str())
999            } else {
1000                arg.strip_prefix("--error-format=")
1001            }
1002        })
1003        .filter(|value| *value == "json")
1004        .map_or(ErrorFormat::Human, |_| ErrorFormat::Json)
1005}
1006
1007fn parser_command_name(args: &[std::ffi::OsString]) -> Option<String> {
1008    const COMMANDS: &[&str] = &[
1009        "init",
1010        "find",
1011        "show",
1012        "explain",
1013        "plan",
1014        "check",
1015        "compare",
1016        "baseline",
1017        "report",
1018        "sarif",
1019        "health",
1020        "config",
1021        "doctor",
1022        "list",
1023        "prune",
1024        "cache",
1025        "completions",
1026        "man",
1027        "reference",
1028        "html",
1029        "version",
1030        "build-info",
1031        "schema",
1032    ];
1033    args.iter()
1034        .skip(1)
1035        .filter_map(|arg| arg.to_str())
1036        .find(|arg| COMMANDS.contains(arg))
1037        .map(str::to_string)
1038}
1039
1040fn render_runtime_error(format: ErrorFormat, error: &ClassifiedError, command: Option<&str>) {
1041    match format {
1042        ErrorFormat::Human => eprintln!("{}", error.message),
1043        ErrorFormat::Json => eprintln!(
1044            "{}",
1045            serde_json::to_string(&json!({
1046                "schema_version": 1,
1047                "error": {
1048                    "kind": error.kind,
1049                    "code": error.code,
1050                    "pointer": error.pointer,
1051                    "message": error.message,
1052                    "details": error.details,
1053                    "command": command,
1054                    "exit_code": error.kind.exit_code()
1055                }
1056            }))
1057            .unwrap_or_else(|_| {
1058                "{\"schema_version\":1,\"error\":{\"code\":\"serialization_failed\"}}".to_string()
1059            })
1060        ),
1061    }
1062}
1063
1064#[cfg(test)]
1065mod tests {
1066    use clap::CommandFactory;
1067
1068    use super::Cli;
1069
1070    fn assert_descriptions(command: &clap::Command, path: &str) {
1071        for argument in command.get_arguments() {
1072            assert!(
1073                argument
1074                    .get_help()
1075                    .is_some_and(|help| !help.to_string().trim().is_empty()),
1076                "{path} argument {} has no generated reference description",
1077                argument.get_id()
1078            );
1079        }
1080        for subcommand in command.get_subcommands() {
1081            assert_descriptions(subcommand, &format!("{path} {}", subcommand.get_name()));
1082        }
1083    }
1084
1085    #[test]
1086    fn generated_reference_has_no_blank_argument_descriptions() {
1087        assert_descriptions(&Cli::command(), "git-slop");
1088    }
1089}