Skip to main content

git_slop/
cli.rs

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