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    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    /// Include local filesystem report paths in output descriptors.
413    #[arg(long)]
414    include_local_paths: bool,
415    /// Select which report supplies regression thresholds and evidence-drift policy.
416    #[arg(long, value_enum, default_value_t = PolicySource::Base)]
417    policy_from: PolicySource,
418    /// Exit 1 when an existing file worsens or a newly added file is a finding.
419    #[arg(long)]
420    fail_on_regression: bool,
421}
422
423#[derive(Debug, Args)]
424struct BaselineArgs {
425    #[command(subcommand)]
426    command: BaselineCommand,
427}
428
429#[derive(Debug, Subcommand)]
430enum BaselineCommand {
431    /// Create a named baseline from a validated report.
432    Create {
433        /// Stable baseline name.
434        #[arg(long, default_value = "default")]
435        name: String,
436        /// Report path. Defaults to .slop/latest/report.json.
437        #[arg(long)]
438        report: Option<PathBuf>,
439        /// Replace an existing named baseline.
440        #[arg(long)]
441        force: bool,
442        /// Permit a report produced from a dirty worktree.
443        #[arg(long)]
444        allow_dirty: bool,
445        /// Permit incomplete inventory or history evidence.
446        #[arg(long)]
447        allow_incomplete_evidence: bool,
448        /// Output format.
449        #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
450        format: DisplayFormat,
451    },
452    /// Replace an existing named baseline from a validated report.
453    Update {
454        /// Stable baseline name.
455        #[arg(long, default_value = "default")]
456        name: String,
457        /// Report path. Defaults to .slop/latest/report.json.
458        #[arg(long)]
459        report: Option<PathBuf>,
460        /// Permit a report produced from a dirty worktree.
461        #[arg(long)]
462        allow_dirty: bool,
463        /// Permit incomplete inventory or history evidence.
464        #[arg(long)]
465        allow_incomplete_evidence: bool,
466        /// Output format.
467        #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
468        format: DisplayFormat,
469    },
470    /// List named baselines with identity and readiness metadata.
471    List {
472        /// Output format.
473        #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
474        format: DisplayFormat,
475    },
476    /// Inspect baseline identity and evidence status.
477    Inspect {
478        /// Stable baseline name.
479        #[arg(long, default_value = "default")]
480        name: String,
481        /// Output format.
482        #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
483        format: DisplayFormat,
484    },
485    /// Validate a named baseline against the current report contract.
486    Validate {
487        /// Stable baseline name.
488        #[arg(long, default_value = "default")]
489        name: String,
490        /// Output format.
491        #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
492        format: DisplayFormat,
493    },
494    /// Remove a named baseline.
495    Remove {
496        /// Stable baseline name.
497        #[arg(long, default_value = "default")]
498        name: String,
499        /// Output format.
500        #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
501        format: DisplayFormat,
502    },
503}
504
505#[derive(Debug, Args)]
506struct ReportArgs {
507    #[command(subcommand)]
508    command: ReportCommand,
509}
510
511#[derive(Debug, Subcommand)]
512enum ReportCommand {
513    /// Validate one report against the complete schema-5 contract.
514    Validate {
515        /// Report JSON to validate.
516        #[arg(value_name = "REPORT_JSON")]
517        path: PathBuf,
518        /// Accept schema 4 as migration input and validate its normalized schema-5 form.
519        #[arg(long)]
520        allow_legacy: bool,
521    },
522    /// Migrate a schema-4 report to normalized schema 5.
523    Migrate {
524        /// Legacy report to migrate.
525        #[arg(value_name = "REPORT_JSON")]
526        path: PathBuf,
527        /// Destination for the normalized schema-5 report.
528        #[arg(long, value_name = "PATH")]
529        output: PathBuf,
530    },
531    /// Print the published JSON Schema for report schema 5.
532    Schema,
533}
534
535#[derive(Debug, Args)]
536struct SarifArgs {
537    /// Report path. Defaults to .slop/latest/report.json.
538    #[arg(long)]
539    report: Option<PathBuf>,
540    /// Maximum number of action-queue findings to export.
541    #[arg(long)]
542    top: Option<i64>,
543    /// Optional SARIF output path. Defaults to stdout.
544    #[arg(long)]
545    output: Option<PathBuf>,
546}
547
548#[derive(Debug, Args)]
549struct HealthArgs {
550    /// Report path. Defaults to .slop/latest/report.json.
551    #[arg(long)]
552    report: Option<PathBuf>,
553    /// Output suited for a job summary, workflow annotations, or automation.
554    #[arg(long, value_enum, default_value_t = HealthFormat::Text)]
555    format: HealthFormat,
556    /// Maximum number of GitHub workflow annotations to emit.
557    #[arg(long, default_value_t = 10)]
558    max_annotations: usize,
559}
560
561#[derive(Debug, Args)]
562struct ConfigArgs {
563    #[command(subcommand)]
564    command: ConfigCommand,
565}
566
567#[derive(Debug, Subcommand)]
568enum ConfigCommand {
569    /// Show configuration; --effective includes defaults.
570    Show {
571        /// Include defaults after applying repository overrides.
572        #[arg(long)]
573        effective: bool,
574    },
575    /// Validate the local configuration.
576    Validate,
577    /// Show only values that differ from defaults.
578    DiffDefaults,
579    /// Rewrite legacy schema configuration as a minimal schema-2 override.
580    Migrate,
581    /// Print the supported configuration schema as JSON.
582    Schema,
583}
584
585#[derive(Debug, Args)]
586struct DoctorArgs {
587    /// Write a privacy-safe diagnostic JSON bundle.
588    #[arg(long, num_args = 0..=1, default_missing_value = ".slop/diagnostic-bundle.json")]
589    bundle: Option<PathBuf>,
590    /// Output format.
591    #[arg(long, value_enum, default_value_t = DoctorFormat::Text)]
592    format: DoctorFormat,
593    /// Estimate only this repo-relative scope.
594    #[arg(long)]
595    scope: Option<String>,
596}
597
598#[derive(Debug, Clone, Copy, ValueEnum)]
599enum DoctorFormat {
600    Text,
601    Json,
602}
603
604#[derive(Debug, Args)]
605struct ListArgs {
606    #[command(subcommand)]
607    command: ListCommand,
608}
609
610#[derive(Debug, Subcommand)]
611enum ListCommand {
612    Findings(ListFilterArgs),
613    Relationships(ListFilterArgs),
614    Clusters(ListFilterArgs),
615    Profiles(ListFilterArgs),
616}
617
618#[derive(Debug, Args)]
619struct ListFilterArgs {
620    /// Report path. Defaults to .slop/latest/report.json.
621    #[arg(long)]
622    report: Option<PathBuf>,
623    /// Match a finding path, relationship endpoint, or cluster member.
624    #[arg(long)]
625    path: Option<String>,
626    /// Match an analysis profile.
627    #[arg(long)]
628    profile: Option<String>,
629    /// Match a resolved file language.
630    #[arg(long)]
631    language: Option<String>,
632    /// Match a resolved file classification.
633    #[arg(long, visible_alias = "class")]
634    classification: Option<String>,
635    /// Match a finding severity.
636    #[arg(long)]
637    severity: Option<String>,
638    /// Maximum number of matched records to return.
639    #[arg(long, default_value_t = 50)]
640    top: usize,
641    /// Output format.
642    #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
643    format: DisplayFormat,
644    /// Use a wider terminal layout before truncating fields.
645    #[arg(long)]
646    wide: bool,
647    /// Never truncate terminal fields.
648    #[arg(long)]
649    no_truncate: bool,
650}
651
652#[derive(Debug, Args)]
653struct PruneArgs {
654    /// Number of newest run snapshots to retain; defaults to output.retention_runs.
655    #[arg(long)]
656    keep: Option<usize>,
657    /// Maximum total bytes retained; defaults to output.retention_bytes.
658    #[arg(long)]
659    max_bytes: Option<u64>,
660    /// Print removals without changing files.
661    #[arg(long)]
662    dry_run: bool,
663    /// Select text, JSON, or YAML output.
664    #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
665    format: DisplayFormat,
666}
667
668#[derive(Debug, Args)]
669struct CacheArgs {
670    /// Mutable state directory. Defaults to Git-private runtime storage.
671    #[arg(long, value_name = "PATH")]
672    state_dir: Option<PathBuf>,
673    #[command(subcommand)]
674    command: CacheCommand,
675}
676
677#[derive(Debug, Subcommand)]
678enum CacheCommand {
679    Status {
680        /// Output format.
681        #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
682        format: DisplayFormat,
683    },
684    Prune {
685        /// Maximum entries to retain.
686        #[arg(long, default_value_t = 10_000)]
687        max_entries: usize,
688        /// Maximum logical payload bytes to retain.
689        #[arg(long, default_value_t = 536_870_912)]
690        max_bytes: u64,
691        /// Preview cache removals without changing the database.
692        #[arg(long)]
693        dry_run: bool,
694        /// Reclaim free database pages after pruning.
695        #[arg(long)]
696        compact: bool,
697        /// Output format.
698        #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
699        format: DisplayFormat,
700    },
701}
702
703#[derive(Debug, Args)]
704struct CompletionsArgs {
705    /// Shell whose completion source should be generated.
706    shell: CompletionShell,
707}
708
709#[derive(Debug, Args)]
710struct ManArgs {
711    /// Destination file. Defaults to stdout.
712    #[arg(long)]
713    output: Option<PathBuf>,
714}
715
716#[derive(Debug, Args)]
717struct ReferenceArgs {
718    /// Destination file. Defaults to stdout.
719    #[arg(long)]
720    output: Option<PathBuf>,
721}
722
723#[derive(Debug, Args)]
724struct HtmlArgs {
725    /// Report path. Defaults to .slop/latest/report.json.
726    #[arg(long)]
727    report: Option<PathBuf>,
728    /// Destination. Defaults to .slop/latest/report.html.
729    #[arg(long)]
730    output: Option<PathBuf>,
731}
732
733#[derive(Debug, Clone, Copy, ValueEnum)]
734enum CompletionShell {
735    Bash,
736    Zsh,
737    Fish,
738    Powershell,
739    Nushell,
740}
741
742#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
743enum DisplayFormat {
744    Text,
745    Json,
746    Yaml,
747}
748
749#[derive(Debug, Clone, Copy, ValueEnum)]
750enum CompareFormat {
751    Text,
752    Json,
753    Yaml,
754    Ndjson,
755}
756
757#[derive(Debug, Clone, Copy, ValueEnum)]
758enum CompareDetail {
759    Summary,
760    Top,
761    Full,
762}
763
764#[derive(Debug, Clone, Copy, ValueEnum)]
765enum HealthFormat {
766    Text,
767    Markdown,
768    Github,
769    Json,
770}
771
772#[derive(Debug, Clone, Copy, ValueEnum)]
773enum BuildInfoFormat {
774    Json,
775}
776
777#[derive(Debug, Clone, Copy, ValueEnum)]
778enum CheckFormat {
779    Text,
780    Json,
781    Github,
782}
783
784#[derive(Debug, Clone, Copy, ValueEnum)]
785enum PolicySource {
786    Base,
787    Head,
788}
789
790impl PolicySource {
791    fn as_str(self) -> &'static str {
792        match self {
793            Self::Base => "base",
794            Self::Head => "head",
795        }
796    }
797}
798
799#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
800enum ErrorFormat {
801    Human,
802    Json,
803}
804
805#[derive(Debug, Clone, Copy, ValueEnum)]
806enum ContextBand {
807    Compact,
808    Healthy,
809    Warning,
810    Critical,
811}
812
813impl ContextBand {
814    fn as_str(self) -> &'static str {
815        match self {
816            Self::Compact => "compact",
817            Self::Healthy => "healthy",
818            Self::Warning => "warning",
819            Self::Critical => "critical",
820        }
821    }
822}
823
824#[derive(Debug, Clone, Copy, ValueEnum)]
825enum SlopBand {
826    Low,
827    Moderate,
828    High,
829    Critical,
830}
831
832impl SlopBand {
833    fn as_str(self) -> &'static str {
834        match self {
835            Self::Low => "low",
836            Self::Moderate => "moderate",
837            Self::High => "high",
838            Self::Critical => "critical",
839        }
840    }
841}
842
843include!("cli/support.rs");
844include!("cli/analysis.rs");
845include!("cli/check.rs");
846include!("cli/baseline_compare.rs");
847include!("cli/reporting.rs");
848include!("cli/listing.rs");
849include!("cli/generation.rs");
850fn execute(repo_root: &Path, command: Command) -> Result<i32> {
851    match command {
852        Command::Init(args) => run_init(repo_root, args),
853        Command::Find(args) => run_find(repo_root, args),
854        Command::Show(args) => run_show(repo_root, args),
855        Command::Explain(args) => run_explain(repo_root, args),
856        Command::Plan(args) => run_plan(repo_root, args),
857        Command::Check(args) => run_check(repo_root, args),
858        Command::Compare(args) => run_compare(repo_root, args),
859        Command::Baseline(args) => run_baseline(repo_root, args),
860        Command::Report(args) => run_report(args),
861        Command::Sarif(args) => run_sarif(repo_root, args),
862        Command::Health(args) => run_health(repo_root, args),
863        Command::Config(args) => run_config(repo_root, args),
864        Command::Doctor(args) => run_doctor(repo_root, args),
865        Command::List(args) => run_list(repo_root, args),
866        Command::Prune(args) => run_prune(repo_root, args),
867        Command::Cache(args) => run_cache(repo_root, args),
868        Command::Completions(args) => run_completions(args),
869        Command::Man(args) => run_man(args),
870        Command::Reference(args) => run_reference(args),
871        Command::Html(args) => run_html(repo_root, args),
872        Command::Version => {
873            println!("{PROJECT_NAME} {VERSION}");
874            Ok(0)
875        }
876        Command::BuildInfo(args) => {
877            match args.format {
878                BuildInfoFormat::Json => {
879                    println!("{}", serde_json::to_string_pretty(&build_info::current())?)
880                }
881            }
882            Ok(0)
883        }
884        Command::Schema(args) => {
885            let rendered = match args.contract {
886                SchemaContract::Report => render_json(&report::schema())?,
887                SchemaContract::Config => render_json(&config::schema())?,
888                contract => {
889                    let source = match contract {
890                        SchemaContract::Compare => include_str!("../schemas/compare-1.json"),
891                        SchemaContract::Explain => include_str!("../schemas/explain-2.json"),
892                        SchemaContract::Plan => include_str!("../schemas/plan-2.json"),
893                        SchemaContract::Sarif => include_str!("../schemas/sarif-1.json"),
894                        SchemaContract::Health => include_str!("../schemas/health-1.json"),
895                        SchemaContract::Check => include_str!("../schemas/check-1.json"),
896                        SchemaContract::Doctor => include_str!("../schemas/doctor-1.json"),
897                        SchemaContract::BuildInfo => include_str!("../schemas/build-info-1.json"),
898                        SchemaContract::List => include_str!("../schemas/list-1.json"),
899                        SchemaContract::Show => include_str!("../schemas/show-1.json"),
900                        SchemaContract::PromptManifest => {
901                            include_str!("../schemas/prompt-manifest-1.json")
902                        }
903                        SchemaContract::Error => include_str!("../schemas/error-1.json"),
904                        SchemaContract::FindEstimate => {
905                            include_str!("../schemas/find-estimate-1.json")
906                        }
907                        SchemaContract::CacheStatus => {
908                            include_str!("../schemas/cache-status-1.json")
909                        }
910                        SchemaContract::CachePrune => include_str!("../schemas/cache-prune-1.json"),
911                        SchemaContract::Prune => include_str!("../schemas/prune-1.json"),
912                        SchemaContract::CompareNdjson => {
913                            include_str!("../schemas/compare-ndjson-1.json")
914                        }
915                        SchemaContract::Report | SchemaContract::Config => unreachable!(),
916                    };
917                    let value: Value = serde_json::from_str(source)?;
918                    render_json(&value)?
919                }
920            };
921            write_generated_output(args.output.as_deref(), rendered.as_bytes())?;
922            Ok(0)
923        }
924    }
925}
926
927fn command_requires_repository(command: &Command) -> bool {
928    match command {
929        Command::Completions(_)
930        | Command::Man(_)
931        | Command::Reference(_)
932        | Command::Version
933        | Command::BuildInfo(_)
934        | Command::Schema(_)
935        | Command::Report(_)
936        | Command::Config(ConfigArgs {
937            command: ConfigCommand::Schema,
938        }) => false,
939        Command::Show(args) => args.report.is_none(),
940        Command::Compare(args) => args.base_ref.is_some() || args.baseline.is_some(),
941        Command::Baseline(_) => true,
942        Command::Explain(args) => args.report.is_none() || args.include_repository_context,
943        Command::Plan(args) => args.report.is_none() || args.include_repository_context,
944        Command::Check(args) => args.report.is_none(),
945        Command::Sarif(args) => args.report.is_none(),
946        Command::Health(args) => args.report.is_none(),
947        Command::Html(args) => args.report.is_none(),
948        Command::List(ListArgs {
949            command:
950                ListCommand::Findings(args)
951                | ListCommand::Relationships(args)
952                | ListCommand::Clusters(args)
953                | ListCommand::Profiles(args),
954        }) => args.report.is_none(),
955        _ => true,
956    }
957}
958
959pub fn run() -> i32 {
960    let raw_args = std::env::args_os().collect::<Vec<_>>();
961    let requested_error_format = requested_error_format(&raw_args);
962    let parser_command = parser_command_name(&raw_args);
963    let cli = match Cli::try_parse_from(&raw_args) {
964        Ok(cli) => cli,
965        Err(error) => {
966            let code = error.exit_code();
967            if code == 0 || requested_error_format == ErrorFormat::Human {
968                let _ = error.print();
969            } else {
970                let classified =
971                    ClassifiedError::new(ErrorKind::Contract, "parser_error", error.to_string())
972                        .at("/arguments")
973                        .with_details(json!({"clap_kind": format!("{:?}", error.kind())}));
974                render_runtime_error(
975                    requested_error_format,
976                    &classified,
977                    parser_command.as_deref(),
978                );
979            }
980            return code;
981        }
982    };
983    let error_format = cli.error_format;
984    let command_name = cli.command.name();
985    let repo_root = if command_requires_repository(&cli.command) {
986        match git::resolve_repo_root_from(cli.repo.as_deref()) {
987            Ok(root) => root,
988            Err(error) => {
989                render_runtime_error(
990                    error_format,
991                    &ClassifiedError::new(
992                        ErrorKind::Repository,
993                        "repository_not_found",
994                        format!("{error:#}"),
995                    ),
996                    Some(command_name),
997                );
998                return 3;
999            }
1000        }
1001    } else {
1002        PathBuf::new()
1003    };
1004    match execute(&repo_root, cli.command) {
1005        Ok(code) => code,
1006        Err(error) => {
1007            let classified = error.downcast_ref::<ClassifiedError>();
1008            let fallback;
1009            let classified = if let Some(classified) = classified {
1010                classified
1011            } else {
1012                fallback = if error.downcast_ref::<std::io::Error>().is_some() {
1013                    ClassifiedError::new(ErrorKind::Io, "io_failure", format!("{error:#}"))
1014                } else {
1015                    ClassifiedError::new(
1016                        ErrorKind::Repository,
1017                        "operation_failed",
1018                        format!("{error:#}"),
1019                    )
1020                };
1021                &fallback
1022            };
1023            render_runtime_error(error_format, classified, Some(command_name));
1024            classified.kind.exit_code()
1025        }
1026    }
1027}
1028
1029fn requested_error_format(args: &[std::ffi::OsString]) -> ErrorFormat {
1030    args.iter()
1031        .filter_map(|arg| arg.to_str())
1032        .enumerate()
1033        .find_map(|(index, arg)| {
1034            if arg == "--error-format" {
1035                args.get(index + 1).and_then(|value| value.to_str())
1036            } else {
1037                arg.strip_prefix("--error-format=")
1038            }
1039        })
1040        .filter(|value| *value == "json")
1041        .map_or(ErrorFormat::Human, |_| ErrorFormat::Json)
1042}
1043
1044fn parser_command_name(args: &[std::ffi::OsString]) -> Option<String> {
1045    const COMMANDS: &[&str] = &[
1046        "init",
1047        "find",
1048        "show",
1049        "explain",
1050        "plan",
1051        "check",
1052        "compare",
1053        "baseline",
1054        "report",
1055        "sarif",
1056        "health",
1057        "config",
1058        "doctor",
1059        "list",
1060        "prune",
1061        "cache",
1062        "completions",
1063        "man",
1064        "reference",
1065        "html",
1066        "version",
1067        "build-info",
1068        "schema",
1069    ];
1070    args.iter()
1071        .skip(1)
1072        .filter_map(|arg| arg.to_str())
1073        .find(|arg| COMMANDS.contains(arg))
1074        .map(str::to_string)
1075}
1076
1077fn render_runtime_error(format: ErrorFormat, error: &ClassifiedError, command: Option<&str>) {
1078    match format {
1079        ErrorFormat::Human => eprintln!("{}", error.message),
1080        ErrorFormat::Json => eprintln!(
1081            "{}",
1082            serde_json::to_string(&json!({
1083                "schema_version": 1,
1084                "error": {
1085                    "kind": error.kind,
1086                    "code": error.code,
1087                    "pointer": error.pointer,
1088                    "message": error.message,
1089                    "details": error.details,
1090                    "command": command,
1091                    "exit_code": error.kind.exit_code()
1092                }
1093            }))
1094            .unwrap_or_else(|_| {
1095                "{\"schema_version\":1,\"error\":{\"code\":\"serialization_failed\"}}".to_string()
1096            })
1097        ),
1098    }
1099}
1100
1101#[cfg(test)]
1102mod tests {
1103    use clap::CommandFactory;
1104
1105    use super::Cli;
1106
1107    fn assert_descriptions(command: &clap::Command, path: &str) {
1108        for argument in command.get_arguments() {
1109            assert!(
1110                argument
1111                    .get_help()
1112                    .is_some_and(|help| !help.to_string().trim().is_empty()),
1113                "{path} argument {} has no generated reference description",
1114                argument.get_id()
1115            );
1116        }
1117        for subcommand in command.get_subcommands() {
1118            assert_descriptions(subcommand, &format!("{path} {}", subcommand.get_name()));
1119        }
1120    }
1121
1122    #[test]
1123    fn generated_reference_has_no_blank_argument_descriptions() {
1124        assert_descriptions(&Cli::command(), "git-slop");
1125    }
1126}