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