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