Skip to main content

git_slop/
cli.rs

1use std::fs;
2use std::io::{IsTerminal, Read, Write};
3use std::path::{Component, Path, PathBuf};
4
5use anyhow::{Context, Result, bail};
6use clap::{ArgGroup, Args, CommandFactory, Parser, Subcommand, ValueEnum};
7use clap_complete::{Shell, generate};
8use serde_json::{Value, json};
9use sha2::Digest;
10
11use crate::build_info;
12use crate::config;
13use crate::error::{ClassifiedError, ErrorKind};
14use crate::health;
15use crate::report;
16use crate::report_ops::{
17    ExplainSelector, PlanSelector, PromptPackOptions, compare_payload_with_policy, explain_payload,
18    failing_records_in, health_json_payload, plan_payload, render_compare_text,
19    render_explain_summary_text, render_explain_text, render_github_annotations, render_json,
20    render_plan_text, 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    after_help = "QUICK START:\n  git slop find                       Run a safe first scan\n  git slop health                     Review repository health\n  git slop list interventions         Review maintenance candidates\n  git slop html                       Build an interactive local report\n  git slop init                       Adopt durable reports and ignore rules",
29    version = VERSION
30)]
31struct Cli {
32    /// Repository or path inside a repository to analyze.
33    #[arg(long, global = true, value_name = "PATH")]
34    repo: Option<PathBuf>,
35    /// Render runtime errors as human text or stable JSON.
36    #[arg(long, global = true, value_enum, default_value_t = ErrorFormat::Human)]
37    error_format: ErrorFormat,
38    #[command(subcommand)]
39    command: Command,
40}
41
42#[derive(Debug, Subcommand)]
43enum Command {
44    /// Scaffold .slop/ config, ignore rules, and state directories.
45    Init(InitArgs),
46    /// Scan the repository and generate hotspot reports.
47    Find(FindArgs),
48    /// Show metrics and reasons for one file or folder.
49    Show(ShowArgs),
50    /// Explain why selected hotspots or structural findings are expensive.
51    Explain(ExplainArgs),
52    /// Propose bounded maintenance slices from the current detector report.
53    Plan(PlanArgs),
54    /// Manage declarative policy packs used only by the optional advisor.
55    Policy(PolicyArgs),
56    /// Build provider-free policy context or validate an existing advice artifact.
57    Advise(Box<AdviseArgs>),
58    /// Evaluate an existing report against CI thresholds.
59    Check(CheckArgs),
60    /// Compare two existing schema-5 reports without rerunning the detector.
61    Compare(CompareArgs),
62    /// Manage named comparison baselines in Git-private runtime storage.
63    Baseline(BaselineArgs),
64    /// Validate or inspect the versioned report contract.
65    Report(ReportArgs),
66    /// Export action-queue findings from an existing schema-5 report as SARIF.
67    Sarif(SarifArgs),
68    /// Render repository health for CI summaries, annotations, or automation.
69    Health(HealthArgs),
70    /// Inspect or migrate effective configuration.
71    Config(ConfigArgs),
72    /// Diagnose repository readiness and optionally write a redacted bundle.
73    Doctor(DoctorArgs),
74    /// List findings, relationships, clusters, or profiles.
75    List(ListArgs),
76    /// Remove old immutable run snapshots according to retention policy.
77    Prune(PruneArgs),
78    /// Inspect or prune the packed token cache.
79    Cache(CacheArgs),
80    /// Generate shell completion source.
81    Completions(CompletionsArgs),
82    /// Generate the roff manual from the live Clap command tree.
83    Man(ManArgs),
84    /// Generate Markdown command reference from the live Clap command tree.
85    Reference(ReferenceArgs),
86    /// Write a self-contained, local, searchable HTML report.
87    Html(HtmlArgs),
88    /// Print version information.
89    Version,
90    /// Print package and source-build provenance.
91    BuildInfo(BuildInfoArgs),
92    /// Print a published JSON Schema for a machine contract.
93    Schema(SchemaArgs),
94}
95
96impl Command {
97    fn name(&self) -> &'static str {
98        match self {
99            Self::Init(_) => "init",
100            Self::Find(_) => "find",
101            Self::Show(_) => "show",
102            Self::Explain(_) => "explain",
103            Self::Plan(_) => "plan",
104            Self::Policy(_) => "policy",
105            Self::Advise(_) => "advise",
106            Self::Check(_) => "check",
107            Self::Compare(_) => "compare",
108            Self::Baseline(_) => "baseline",
109            Self::Report(_) => "report",
110            Self::Sarif(_) => "sarif",
111            Self::Health(_) => "health",
112            Self::Config(_) => "config",
113            Self::Doctor(_) => "doctor",
114            Self::List(_) => "list",
115            Self::Prune(_) => "prune",
116            Self::Cache(_) => "cache",
117            Self::Completions(_) => "completions",
118            Self::Man(_) => "man",
119            Self::Reference(_) => "reference",
120            Self::Html(_) => "html",
121            Self::Version => "version",
122            Self::BuildInfo(_) => "build-info",
123            Self::Schema(_) => "schema",
124        }
125    }
126}
127
128#[derive(Debug, Args)]
129struct FindArgs {
130    /// Acknowledge incomplete history and continue in a shallow clone.
131    #[arg(long)]
132    allow_shallow: bool,
133    /// Analyze only this repo-relative path while retaining repository-wide Git evidence.
134    #[arg(long)]
135    scope: Option<String>,
136    /// Permit a scope that selects no tracked paths and emit an empty analysis.
137    #[arg(long)]
138    allow_empty_scope: bool,
139    /// Suppress human progress and report-path messages.
140    #[arg(long)]
141    quiet: bool,
142    /// Suppress phase progress while preserving the final result.
143    #[arg(long)]
144    no_progress: bool,
145    /// Mutable cache/state directory. Relative paths resolve from the repository root.
146    #[arg(long, value_name = "PATH")]
147    state_dir: Option<PathBuf>,
148    /// Report output directory. Relative paths resolve from the repository root.
149    #[arg(long, value_name = "PATH")]
150    output_dir: Option<PathBuf>,
151    /// Disable token-cache reads and writes for an ephemeral scan.
152    #[arg(long)]
153    no_cache: bool,
154    /// Keep disposable state and reports under Git-private storage, without adopting `.slop/`.
155    #[arg(long, conflicts_with_all = ["state_dir", "output_dir", "persist_unadopted"])]
156    ephemeral: bool,
157    /// Explicitly allow persistent `.slop/` output before repository adoption.
158    #[arg(long, conflicts_with = "ephemeral")]
159    persist_unadopted: bool,
160    /// Deterministically analyze the largest path prefix that fits the memory budget.
161    #[arg(long)]
162    allow_degraded: bool,
163    /// Fixed RFC 3339 analysis clock for reproducible recency and history windows.
164    #[arg(long, value_name = "RFC3339")]
165    as_of: Option<String>,
166    /// Report evidence profile.
167    #[arg(long, value_enum, default_value_t = ReportProfile::Standard)]
168    report_profile: ReportProfile,
169    /// Also write a compressed report beside report.json.
170    #[arg(long, value_enum, default_value_t = ReportCompression::None)]
171    compression: ReportCompression,
172    /// Estimate scope, memory, cache, report size, time, and inodes without scanning.
173    #[arg(long)]
174    estimate_only: bool,
175    /// Estimate output format. Defaults to text on a terminal and JSON when piped.
176    #[arg(long, value_enum, requires = "estimate_only")]
177    format: Option<DisplayFormat>,
178}
179
180#[derive(Debug, Clone, Copy, ValueEnum)]
181enum ReportProfile {
182    Compact,
183    Standard,
184    FullEvidence,
185}
186
187impl ReportProfile {
188    fn as_str(self) -> &'static str {
189        match self {
190            Self::Compact => "compact",
191            Self::Standard => "standard",
192            Self::FullEvidence => "full_evidence",
193        }
194    }
195}
196
197#[derive(Debug, Clone, Copy, ValueEnum)]
198enum ReportCompression {
199    None,
200    Gzip,
201    Zstd,
202}
203
204impl ReportCompression {
205    fn as_str(self) -> &'static str {
206        match self {
207            Self::None => "none",
208            Self::Gzip => "gzip",
209            Self::Zstd => "zstd",
210        }
211    }
212}
213
214#[derive(Debug, Args)]
215struct BuildInfoArgs {
216    /// Machine-readable build provenance format.
217    #[arg(long, value_enum, default_value_t = BuildInfoFormat::Json)]
218    format: BuildInfoFormat,
219}
220
221#[derive(Debug, Args)]
222#[command(group(
223    ArgGroup::new("mode")
224        .args(["force", "repair", "check"])
225        .multiple(false)
226))]
227struct InitArgs {
228    /// Replace generated files atomically and keep ignored `.bak` recovery copies.
229    #[arg(long)]
230    force: bool,
231    /// Add missing generated ignore rules without replacing repository configuration.
232    #[arg(long)]
233    repair: bool,
234    /// Inspect adoption files without changing the repository.
235    #[arg(long)]
236    check: bool,
237    /// Limit initialization, repair, force, or check to .slop/.gitignore.
238    #[arg(long)]
239    gitignore_only: bool,
240    /// Output format. JSON also makes parser and runtime errors machine-readable.
241    #[arg(long, value_enum, default_value_t = InitFormat::Text)]
242    format: InitFormat,
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
246enum InitFormat {
247    Text,
248    Json,
249}
250
251#[derive(Debug, Args)]
252struct ShowArgs {
253    /// Repo-relative file or folder path.
254    target_path: String,
255    /// Report path. Defaults to the durable latest report, then the Git-private first-run report.
256    #[arg(long)]
257    report: Option<PathBuf>,
258    /// Fail when the report does not match current HEAD, worktree, config, scope, or analyzer.
259    #[arg(long)]
260    require_current: bool,
261    /// Output format.
262    #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
263    format: DisplayFormat,
264}
265
266#[derive(Debug, Args)]
267#[command(group(
268    ArgGroup::new("selector")
269        .args(["path", "cluster", "relationship", "top"])
270        .multiple(false)
271))]
272struct ExplainArgs {
273    /// Report path. Defaults to the durable latest report, then the Git-private first-run report.
274    #[arg(long)]
275    report: Option<PathBuf>,
276    /// Fail when the report does not match current HEAD, worktree, config, scope, or analyzer.
277    #[arg(long)]
278    require_current: bool,
279    /// Repo-relative file or folder path.
280    #[arg(long)]
281    path: Option<String>,
282    /// Cluster identifier.
283    #[arg(long)]
284    cluster: Option<String>,
285    /// Relationship identifier.
286    #[arg(long)]
287    relationship: Option<String>,
288    /// Explain the top N hotspots from the action queue.
289    #[arg(long)]
290    top: Option<i64>,
291    /// Output format.
292    #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
293    format: DisplayFormat,
294    /// Include raw normalized metrics, provenance, and bounded supporting evidence in text output.
295    #[arg(long)]
296    verbose: bool,
297    /// Write a deterministic local-model prompt pack to this directory.
298    #[arg(long)]
299    prompt_pack: Option<PathBuf>,
300    /// Atomically replace an existing prompt-pack directory.
301    #[arg(long, requires = "prompt_pack")]
302    force: bool,
303    /// Include bounded local source/test excerpts, guidance, and verification hints.
304    #[arg(long, requires = "prompt_pack")]
305    include_repository_context: bool,
306    /// Maximum bytes read from each included repository file.
307    #[arg(long, default_value_t = 2048, requires = "include_repository_context")]
308    excerpt_bytes: usize,
309    /// Include local filesystem paths in prompt-pack provenance and commands.
310    #[arg(long, requires = "prompt_pack")]
311    include_local_paths: bool,
312}
313
314#[derive(Debug, Args)]
315#[command(group(
316    ArgGroup::new("selector")
317        .args(["path", "cluster", "relationship"])
318        .required(true)
319        .multiple(false)
320))]
321struct PlanArgs {
322    /// Report path. Defaults to the durable latest report, then the Git-private first-run report.
323    #[arg(long)]
324    report: Option<PathBuf>,
325    /// Fail when the report does not match current HEAD, worktree, config, scope, or analyzer.
326    #[arg(long)]
327    require_current: bool,
328    /// Repo-relative file or folder path.
329    #[arg(long)]
330    path: Option<String>,
331    /// Cluster identifier.
332    #[arg(long)]
333    cluster: Option<String>,
334    /// Relationship identifier.
335    #[arg(long)]
336    relationship: Option<String>,
337    /// Maximum number of bounded maintenance slices to propose.
338    #[arg(long, default_value_t = 3)]
339    max_slices: i64,
340    /// Output format.
341    #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
342    format: DisplayFormat,
343    /// Write a deterministic local-model prompt pack to this directory.
344    #[arg(long)]
345    prompt_pack: Option<PathBuf>,
346    /// Atomically replace an existing prompt-pack directory.
347    #[arg(long, requires = "prompt_pack")]
348    force: bool,
349    /// Include bounded local source/test excerpts, guidance, and verification hints.
350    #[arg(long, requires = "prompt_pack")]
351    include_repository_context: bool,
352    /// Maximum bytes read from each included repository file.
353    #[arg(long, default_value_t = 2048, requires = "include_repository_context")]
354    excerpt_bytes: usize,
355    /// Include local filesystem paths in prompt-pack provenance and commands.
356    #[arg(long, requires = "prompt_pack")]
357    include_local_paths: bool,
358}
359
360#[derive(Debug, Args)]
361struct CheckArgs {
362    /// Report path. Defaults to the durable latest report, then the Git-private first-run report.
363    #[arg(long)]
364    report: Option<PathBuf>,
365    /// Override the config default fail threshold for context_band.
366    #[arg(long, value_enum)]
367    fail_on_context_band: Option<ContextBand>,
368    /// Override the config default fail threshold for slop_band.
369    #[arg(long, value_enum)]
370    fail_on_slop_band: Option<SlopBand>,
371    /// Output format, including escaped GitHub workflow commands.
372    #[arg(long, value_enum, default_value_t = CheckFormat::Text)]
373    format: CheckFormat,
374    /// Include complete finding records in JSON output.
375    #[arg(long)]
376    details: bool,
377    /// Include folder records in addition to the versioned file-only gate.
378    #[arg(long)]
379    include_folders: bool,
380    /// Zero-based finding offset used with --details.
381    #[arg(long, default_value_t = 0, requires = "details")]
382    offset: usize,
383    /// Maximum finding records returned with --details.
384    #[arg(long, default_value_t = 1000, requires = "details")]
385    limit: usize,
386    /// Permit policy evaluation when selected inventory records are incomplete.
387    #[arg(long)]
388    allow_incomplete_evidence: bool,
389    /// Evaluate and report the canonical policy result without returning exit 1 for findings.
390    #[arg(long)]
391    evaluate_only: bool,
392    /// Fail when the report does not match current HEAD, worktree, config, scope, or analyzer.
393    #[arg(long)]
394    require_current: bool,
395}
396
397#[derive(Debug, Args)]
398struct CompareArgs {
399    /// Mutable state directory for named baselines. Relative paths resolve from the repository root.
400    #[arg(long, value_name = "PATH")]
401    state_dir: Option<PathBuf>,
402    /// Base report.json path.
403    #[arg(
404        long,
405        required_unless_present_any = ["base_ref", "baseline"],
406        conflicts_with_all = ["base_ref", "baseline"]
407    )]
408    base: Option<PathBuf>,
409    /// Safely resolve and scan this Git revision in an isolated worktree.
410    #[arg(long, conflicts_with_all = ["base", "baseline"])]
411    base_ref: Option<String>,
412    /// Use a named baseline from Git-private runtime storage.
413    #[arg(long, conflicts_with_all = ["base", "base_ref"])]
414    baseline: Option<String>,
415    /// Head report.json path.
416    #[arg(long, default_value = ".slop/latest/report.json")]
417    head: PathBuf,
418    /// Apply the head repository's scope to an isolated --base-ref scan.
419    #[arg(long)]
420    scope: Option<String>,
421    /// Permit incomplete history in an isolated --base-ref scan.
422    #[arg(long)]
423    allow_shallow: bool,
424    /// Permit comparison when selected inventory records are incomplete.
425    #[arg(long)]
426    allow_incomplete_evidence: bool,
427    /// Maximum number of changed files and queue movements to show.
428    #[arg(long, default_value_t = 10)]
429    top: i64,
430    /// Output format.
431    #[arg(long, value_enum, default_value_t = CompareFormat::Text)]
432    format: CompareFormat,
433    /// Detail level for machine output.
434    #[arg(long, value_enum, default_value_t = CompareDetail::Top)]
435    detail: CompareDetail,
436    /// Zero-based record offset for --detail full.
437    #[arg(long, default_value_t = 0)]
438    offset: usize,
439    /// Maximum records per collection for --detail full.
440    #[arg(long, default_value_t = 1000)]
441    limit: usize,
442    /// Compare reports with incompatible identity or analyzer metadata.
443    #[arg(long)]
444    force: bool,
445    /// Include local filesystem report paths in output descriptors.
446    #[arg(long)]
447    include_local_paths: bool,
448    /// Include unchanged file and folder records in bounded compare collections.
449    #[arg(long)]
450    include_unchanged: bool,
451    /// Select which report supplies regression thresholds and evidence-drift policy.
452    #[arg(long, value_enum, default_value_t = PolicySource::Base)]
453    policy_from: PolicySource,
454    /// Exit 1 when an existing file worsens or a newly added file is a finding.
455    #[arg(long)]
456    fail_on_regression: bool,
457}
458
459#[derive(Debug, Args)]
460struct BaselineArgs {
461    /// Mutable state directory. Relative paths resolve from the repository root.
462    #[arg(long, value_name = "PATH", global = true)]
463    state_dir: Option<PathBuf>,
464    #[command(subcommand)]
465    command: BaselineCommand,
466}
467
468#[derive(Debug, Subcommand)]
469enum BaselineCommand {
470    /// Idempotently save a named baseline, failing closed when stored content differs.
471    Ensure {
472        /// Stable baseline name.
473        #[arg(long, default_value = "default")]
474        name: String,
475        /// Report path. Defaults to the durable latest report, then the Git-private first-run report.
476        #[arg(long)]
477        report: Option<PathBuf>,
478        /// Fail when the report does not match current HEAD, worktree, config, scope, or analyzer.
479        #[arg(long)]
480        require_current: bool,
481        /// Explicitly replace a differing stored baseline.
482        #[arg(long)]
483        replace: bool,
484        /// Permit a report produced from a dirty worktree.
485        #[arg(long)]
486        allow_dirty: bool,
487        /// Permit incomplete inventory or history evidence.
488        #[arg(long)]
489        allow_incomplete_evidence: bool,
490        /// Output format.
491        #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
492        format: DisplayFormat,
493    },
494    /// Create a named baseline from a validated report.
495    Create {
496        /// Stable baseline name.
497        #[arg(long, default_value = "default")]
498        name: String,
499        /// Report path. Defaults to the durable latest report, then the Git-private first-run report.
500        #[arg(long)]
501        report: Option<PathBuf>,
502        /// Fail when the report does not match current HEAD, worktree, config, scope, or analyzer.
503        #[arg(long)]
504        require_current: bool,
505        /// Replace an existing named baseline.
506        #[arg(long)]
507        force: bool,
508        /// Permit a report produced from a dirty worktree.
509        #[arg(long)]
510        allow_dirty: bool,
511        /// Permit incomplete inventory or history evidence.
512        #[arg(long)]
513        allow_incomplete_evidence: bool,
514        /// Output format.
515        #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
516        format: DisplayFormat,
517    },
518    /// Replace an existing named baseline from a validated report.
519    Update {
520        /// Stable baseline name.
521        #[arg(long, default_value = "default")]
522        name: String,
523        /// Report path. Defaults to the durable latest report, then the Git-private first-run report.
524        #[arg(long)]
525        report: Option<PathBuf>,
526        /// Fail when the report does not match current HEAD, worktree, config, scope, or analyzer.
527        #[arg(long)]
528        require_current: bool,
529        /// Permit a report produced from a dirty worktree.
530        #[arg(long)]
531        allow_dirty: bool,
532        /// Permit incomplete inventory or history evidence.
533        #[arg(long)]
534        allow_incomplete_evidence: bool,
535        /// Output format.
536        #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
537        format: DisplayFormat,
538    },
539    /// List named baselines with identity and readiness metadata.
540    List {
541        /// Output format.
542        #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
543        format: DisplayFormat,
544    },
545    /// Inspect baseline identity and evidence status.
546    Inspect {
547        /// Stable baseline name.
548        #[arg(long, default_value = "default")]
549        name: String,
550        /// Output format.
551        #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
552        format: DisplayFormat,
553    },
554    /// Validate a named baseline against the current report contract.
555    Validate {
556        /// Stable baseline name.
557        #[arg(long, default_value = "default")]
558        name: String,
559        /// Output format.
560        #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
561        format: DisplayFormat,
562    },
563    /// Remove a named baseline.
564    Remove {
565        /// Stable baseline name.
566        #[arg(long, default_value = "default")]
567        name: String,
568        /// Output format.
569        #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
570        format: DisplayFormat,
571        /// Apply the removal. Without this flag the command is a read-only preview.
572        #[arg(long)]
573        yes: bool,
574    },
575}
576
577#[derive(Debug, Args)]
578struct ReportArgs {
579    #[command(subcommand)]
580    command: ReportCommand,
581}
582
583#[derive(Debug, Subcommand)]
584enum ReportCommand {
585    /// Validate one report against the complete schema-5 contract.
586    Validate {
587        /// Report JSON to validate.
588        #[arg(value_name = "REPORT_JSON", required_unless_present = "report")]
589        path: Option<PathBuf>,
590        /// Report JSON to validate (alias for the positional path).
591        #[arg(long, value_name = "REPORT_JSON", required_unless_present = "path")]
592        report: Option<PathBuf>,
593        /// Accept schema 4 as migration input and validate its normalized schema-5 form.
594        #[arg(long)]
595        allow_legacy: bool,
596        /// Success output format.
597        #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
598        format: DisplayFormat,
599    },
600    /// Migrate a schema-4 report to normalized schema 5.
601    Migrate {
602        /// Legacy report to migrate.
603        #[arg(value_name = "REPORT_JSON")]
604        path: PathBuf,
605        /// Destination for the normalized schema-5 report.
606        #[arg(long, value_name = "PATH")]
607        output: PathBuf,
608    },
609    /// Print the published JSON Schema for report schema 5.
610    Schema,
611}
612
613#[derive(Debug, Args)]
614struct SarifArgs {
615    /// Report path. Defaults to the durable latest report, then the Git-private first-run report.
616    #[arg(long)]
617    report: Option<PathBuf>,
618    /// Fail when the report does not match current HEAD, worktree, config, scope, or analyzer.
619    #[arg(long)]
620    require_current: bool,
621    /// Maximum number of action-queue findings to export.
622    #[arg(long)]
623    top: Option<i64>,
624    /// Export configured policy failures or action-queue intervention candidates.
625    #[arg(long, value_enum, default_value_t = SarifScope::ActionQueue)]
626    scope: SarifScope,
627    /// Optional SARIF output path. Defaults to stdout.
628    #[arg(long)]
629    output: Option<PathBuf>,
630    /// Include the local source report path in SARIF invocation properties.
631    #[arg(long)]
632    include_local_paths: bool,
633}
634
635#[derive(Debug, Args)]
636struct HealthArgs {
637    /// Report path. Defaults to the durable latest report, then the Git-private first-run report.
638    #[arg(long)]
639    report: Option<PathBuf>,
640    /// Output suited for a job summary, workflow annotations, or automation.
641    #[arg(long, value_enum, default_value_t = HealthFormat::Text)]
642    format: HealthFormat,
643    /// Maximum number of GitHub workflow annotations to emit.
644    #[arg(long, default_value_t = 10)]
645    max_annotations: usize,
646    /// Fail when the report does not match current HEAD, worktree, config, scope, or analyzer.
647    #[arg(long)]
648    require_current: bool,
649}
650
651#[derive(Debug, Args)]
652struct ConfigArgs {
653    #[command(subcommand)]
654    command: ConfigCommand,
655}
656
657#[derive(Debug, Subcommand)]
658enum ConfigCommand {
659    /// Show configuration; --effective includes defaults.
660    Show {
661        /// Include defaults after applying repository overrides.
662        #[arg(long)]
663        effective: bool,
664    },
665    /// Validate the local configuration.
666    Validate,
667    /// Show only values that differ from defaults.
668    DiffDefaults,
669    /// Rewrite legacy schema configuration as a minimal schema-2 override.
670    Migrate {
671        /// Print the migrated configuration without writing it.
672        #[arg(long)]
673        dry_run: bool,
674        /// Do not retain the existing configuration as config.yaml.bak.
675        #[arg(long)]
676        no_backup: bool,
677    },
678    /// Print the supported configuration schema as JSON.
679    Schema,
680}
681
682#[derive(Debug, Args)]
683struct DoctorArgs {
684    /// Write a privacy-safe diagnostic JSON bundle; defaults to the active state root.
685    #[arg(long, num_args = 0..=1, default_missing_value = "__git_slop_active_state_bundle__")]
686    bundle: Option<PathBuf>,
687    /// Output format.
688    #[arg(long, value_enum, default_value_t = DoctorFormat::Text)]
689    format: DoctorFormat,
690    /// Estimate only this repo-relative scope.
691    #[arg(long)]
692    scope: Option<String>,
693    /// Return exit 2 when the latest report is valid but stale.
694    #[arg(long)]
695    require_current: bool,
696}
697
698#[derive(Debug, Clone, Copy, ValueEnum)]
699enum DoctorFormat {
700    Text,
701    Json,
702}
703
704include!("cli/list_args.rs");
705
706#[derive(Debug, Args)]
707struct PruneArgs {
708    /// Mutable state directory. Defaults to the same active root as find.
709    #[arg(long, value_name = "PATH")]
710    state_dir: Option<PathBuf>,
711    /// Number of newest run snapshots to retain; defaults to output.retention_runs.
712    #[arg(long)]
713    keep: Option<usize>,
714    /// Maximum total bytes retained; defaults to output.retention_bytes.
715    #[arg(long)]
716    max_bytes: Option<u64>,
717    /// Explicitly request preview behavior (preview is already the default).
718    #[arg(long, conflicts_with = "yes")]
719    dry_run: bool,
720    /// Apply the selected removals. Without this flag the command is read-only.
721    #[arg(long, conflicts_with = "dry_run")]
722    yes: bool,
723    /// Select text, JSON, or YAML output.
724    #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
725    format: DisplayFormat,
726}
727
728include!("cli/cache_args.rs");
729include!("cli/policy_args.rs");
730include!("cli/advice_args.rs");
731
732#[derive(Debug, Args)]
733struct CompletionsArgs {
734    /// Shell whose completion source should be generated.
735    shell: CompletionShell,
736}
737
738#[derive(Debug, Args)]
739struct ManArgs {
740    /// Destination file. Defaults to stdout.
741    #[arg(long)]
742    output: Option<PathBuf>,
743}
744
745#[derive(Debug, Args)]
746struct ReferenceArgs {
747    /// Index destination. Detailed command pages use the sibling stem directory.
748    /// Without an output, the complete reference is written to stdout.
749    #[arg(long)]
750    output: Option<PathBuf>,
751}
752
753#[derive(Debug, Args)]
754struct HtmlArgs {
755    /// Report path. Defaults to the durable latest report, then the Git-private first-run report.
756    #[arg(long)]
757    report: Option<PathBuf>,
758    /// Fail when the report does not match current HEAD, worktree, config, scope, or analyzer.
759    #[arg(long)]
760    require_current: bool,
761    /// Destination. Defaults beside the selected report.
762    #[arg(long)]
763    output: Option<PathBuf>,
764    /// Embed the local source report path in the otherwise portable HTML file.
765    #[arg(long)]
766    include_local_paths: bool,
767    /// Serve the report temporarily over a loopback-only HTTP endpoint.
768    #[arg(long)]
769    serve: bool,
770    /// Open the temporary loopback URL in the system browser.
771    #[arg(long, requires = "serve")]
772    open: bool,
773    /// Maximum lifetime of the temporary loopback server.
774    #[arg(long, default_value_t = 120, value_parser = clap::value_parser!(u64).range(1..=3600), requires = "serve")]
775    serve_seconds: u64,
776}
777
778include!("cli/formats.rs");
779include!("cli/support.rs");
780include!("cli/support/validation.rs");
781include!("cli/init.rs");
782include!("cli/analysis.rs");
783include!("cli/analysis/reporting.rs");
784include!("cli/analysis_receipt.rs");
785include!("cli/check.rs");
786include!("cli/baseline_compare.rs");
787include!("cli/reporting.rs");
788include!("cli/doctor/support.rs");
789include!("cli/doctor.rs");
790include!("cli/listing.rs");
791include!("cli/policy_cmd.rs");
792include!("cli/advice_cmd.rs");
793include!("cli/generation/server.rs");
794include!("cli/generation.rs");
795include!("cli/generation/artifacts.rs");
796include!("cli/generation/reference.rs");
797include!("cli/generation/reference/pages.rs");
798include!("cli/generation/reference/bundle.rs");
799include!("cli/entry.rs");
800
801#[cfg(test)]
802mod tests {
803    use clap::CommandFactory;
804
805    use super::Cli;
806
807    fn assert_descriptions(command: &clap::Command, path: &str) {
808        for argument in command.get_arguments() {
809            assert!(
810                argument
811                    .get_help()
812                    .is_some_and(|help| !help.to_string().trim().is_empty()),
813                "{path} argument {} has no generated reference description",
814                argument.get_id()
815            );
816        }
817        for subcommand in command.get_subcommands() {
818            assert_descriptions(subcommand, &format!("{path} {}", subcommand.get_name()));
819        }
820    }
821
822    #[test]
823    fn generated_reference_has_no_blank_argument_descriptions() {
824        assert_descriptions(&Cli::command(), "git-slop");
825    }
826}