Skip to main content

git_slop/
cli.rs

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