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 #[arg(long, global = true, value_name = "PATH")]
34 repo: Option<PathBuf>,
35 #[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 Init(InitArgs),
46 Find(FindArgs),
48 Show(ShowArgs),
50 Explain(ExplainArgs),
52 Plan(PlanArgs),
54 Check(CheckArgs),
56 Compare(CompareArgs),
58 Baseline(BaselineArgs),
60 Report(ReportArgs),
62 Sarif(SarifArgs),
64 Health(HealthArgs),
66 Config(ConfigArgs),
68 Doctor(DoctorArgs),
70 List(ListArgs),
72 Prune(PruneArgs),
74 Cache(CacheArgs),
76 Completions(CompletionsArgs),
78 Man(ManArgs),
80 Reference(ReferenceArgs),
82 Html(HtmlArgs),
84 Version,
86 BuildInfo(BuildInfoArgs),
88 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 #[arg(long)]
126 allow_shallow: bool,
127 #[arg(long)]
129 scope: Option<String>,
130 #[arg(long)]
132 allow_empty_scope: bool,
133 #[arg(long)]
135 quiet: bool,
136 #[arg(long)]
138 no_progress: bool,
139 #[arg(long, value_name = "PATH")]
141 state_dir: Option<PathBuf>,
142 #[arg(long, value_name = "PATH")]
144 output_dir: Option<PathBuf>,
145 #[arg(long)]
147 no_cache: bool,
148 #[arg(long, conflicts_with_all = ["state_dir", "output_dir", "persist_unadopted"])]
150 ephemeral: bool,
151 #[arg(long, conflicts_with = "ephemeral")]
153 persist_unadopted: bool,
154 #[arg(long)]
156 allow_degraded: bool,
157 #[arg(long, value_name = "RFC3339")]
159 as_of: Option<String>,
160 #[arg(long, value_enum, default_value_t = ReportProfile::Standard)]
162 report_profile: ReportProfile,
163 #[arg(long, value_enum, default_value_t = ReportCompression::None)]
165 compression: ReportCompression,
166 #[arg(long)]
168 estimate_only: bool,
169 #[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 #[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 #[arg(long)]
224 force: bool,
225 #[arg(long)]
227 repair: bool,
228 #[arg(long)]
230 check: bool,
231 #[arg(long)]
233 gitignore_only: bool,
234}
235
236#[derive(Debug, Args)]
237struct ShowArgs {
238 target_path: String,
240 #[arg(long)]
242 report: Option<PathBuf>,
243 #[arg(long)]
245 require_current: bool,
246 #[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 #[arg(long)]
260 report: Option<PathBuf>,
261 #[arg(long)]
263 require_current: bool,
264 #[arg(long)]
266 path: Option<String>,
267 #[arg(long)]
269 cluster: Option<String>,
270 #[arg(long)]
272 relationship: Option<String>,
273 #[arg(long)]
275 top: Option<i64>,
276 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
278 format: DisplayFormat,
279 #[arg(long)]
281 prompt_pack: Option<PathBuf>,
282 #[arg(long, requires = "prompt_pack")]
284 force: bool,
285 #[arg(long, requires = "prompt_pack")]
287 include_repository_context: bool,
288 #[arg(long, default_value_t = 2048, requires = "include_repository_context")]
290 excerpt_bytes: usize,
291 #[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 #[arg(long)]
306 report: Option<PathBuf>,
307 #[arg(long)]
309 require_current: bool,
310 #[arg(long)]
312 path: Option<String>,
313 #[arg(long)]
315 cluster: Option<String>,
316 #[arg(long)]
318 relationship: Option<String>,
319 #[arg(long, default_value_t = 3)]
321 max_slices: i64,
322 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
324 format: DisplayFormat,
325 #[arg(long)]
327 prompt_pack: Option<PathBuf>,
328 #[arg(long, requires = "prompt_pack")]
330 force: bool,
331 #[arg(long, requires = "prompt_pack")]
333 include_repository_context: bool,
334 #[arg(long, default_value_t = 2048, requires = "include_repository_context")]
336 excerpt_bytes: usize,
337 #[arg(long, requires = "prompt_pack")]
339 include_local_paths: bool,
340}
341
342#[derive(Debug, Args)]
343struct CheckArgs {
344 #[arg(long)]
346 report: Option<PathBuf>,
347 #[arg(long, value_enum)]
349 fail_on_context_band: Option<ContextBand>,
350 #[arg(long, value_enum)]
352 fail_on_slop_band: Option<SlopBand>,
353 #[arg(long, value_enum, default_value_t = CheckFormat::Text)]
355 format: CheckFormat,
356 #[arg(long)]
358 details: bool,
359 #[arg(long)]
361 include_folders: bool,
362 #[arg(long, default_value_t = 0, requires = "details")]
364 offset: usize,
365 #[arg(long, default_value_t = 1000, requires = "details")]
367 limit: usize,
368 #[arg(long)]
370 allow_incomplete_evidence: bool,
371 #[arg(long)]
373 evaluate_only: bool,
374 #[arg(long)]
376 require_current: bool,
377}
378
379#[derive(Debug, Args)]
380struct CompareArgs {
381 #[arg(long, value_name = "PATH")]
383 state_dir: Option<PathBuf>,
384 #[arg(
386 long,
387 required_unless_present_any = ["base_ref", "baseline"],
388 conflicts_with_all = ["base_ref", "baseline"]
389 )]
390 base: Option<PathBuf>,
391 #[arg(long, conflicts_with_all = ["base", "baseline"])]
393 base_ref: Option<String>,
394 #[arg(long, conflicts_with_all = ["base", "base_ref"])]
396 baseline: Option<String>,
397 #[arg(long, default_value = ".slop/latest/report.json")]
399 head: PathBuf,
400 #[arg(long)]
402 scope: Option<String>,
403 #[arg(long)]
405 allow_shallow: bool,
406 #[arg(long)]
408 allow_incomplete_evidence: bool,
409 #[arg(long, default_value_t = 10)]
411 top: i64,
412 #[arg(long, value_enum, default_value_t = CompareFormat::Text)]
414 format: CompareFormat,
415 #[arg(long, value_enum, default_value_t = CompareDetail::Top)]
417 detail: CompareDetail,
418 #[arg(long, default_value_t = 0)]
420 offset: usize,
421 #[arg(long, default_value_t = 1000)]
423 limit: usize,
424 #[arg(long)]
426 force: bool,
427 #[arg(long)]
429 include_local_paths: bool,
430 #[arg(long)]
432 include_unchanged: bool,
433 #[arg(long, value_enum, default_value_t = PolicySource::Base)]
435 policy_from: PolicySource,
436 #[arg(long)]
438 fail_on_regression: bool,
439}
440
441#[derive(Debug, Args)]
442struct BaselineArgs {
443 #[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 Ensure {
454 #[arg(long, default_value = "default")]
456 name: String,
457 #[arg(long)]
459 report: Option<PathBuf>,
460 #[arg(long)]
462 require_current: bool,
463 #[arg(long)]
465 replace: bool,
466 #[arg(long)]
468 allow_dirty: bool,
469 #[arg(long)]
471 allow_incomplete_evidence: bool,
472 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
474 format: DisplayFormat,
475 },
476 Create {
478 #[arg(long, default_value = "default")]
480 name: String,
481 #[arg(long)]
483 report: Option<PathBuf>,
484 #[arg(long)]
486 require_current: bool,
487 #[arg(long)]
489 force: bool,
490 #[arg(long)]
492 allow_dirty: bool,
493 #[arg(long)]
495 allow_incomplete_evidence: bool,
496 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
498 format: DisplayFormat,
499 },
500 Update {
502 #[arg(long, default_value = "default")]
504 name: String,
505 #[arg(long)]
507 report: Option<PathBuf>,
508 #[arg(long)]
510 require_current: bool,
511 #[arg(long)]
513 allow_dirty: bool,
514 #[arg(long)]
516 allow_incomplete_evidence: bool,
517 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
519 format: DisplayFormat,
520 },
521 List {
523 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
525 format: DisplayFormat,
526 },
527 Inspect {
529 #[arg(long, default_value = "default")]
531 name: String,
532 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
534 format: DisplayFormat,
535 },
536 Validate {
538 #[arg(long, default_value = "default")]
540 name: String,
541 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
543 format: DisplayFormat,
544 },
545 Remove {
547 #[arg(long, default_value = "default")]
549 name: String,
550 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
552 format: DisplayFormat,
553 #[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 {
569 #[arg(value_name = "REPORT_JSON", required_unless_present = "report")]
571 path: Option<PathBuf>,
572 #[arg(long, value_name = "REPORT_JSON", required_unless_present = "path")]
574 report: Option<PathBuf>,
575 #[arg(long)]
577 allow_legacy: bool,
578 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
580 format: DisplayFormat,
581 },
582 Migrate {
584 #[arg(value_name = "REPORT_JSON")]
586 path: PathBuf,
587 #[arg(long, value_name = "PATH")]
589 output: PathBuf,
590 },
591 Schema,
593}
594
595#[derive(Debug, Args)]
596struct SarifArgs {
597 #[arg(long)]
599 report: Option<PathBuf>,
600 #[arg(long)]
602 require_current: bool,
603 #[arg(long)]
605 top: Option<i64>,
606 #[arg(long, value_enum, default_value_t = SarifScope::ActionQueue)]
608 scope: SarifScope,
609 #[arg(long)]
611 output: Option<PathBuf>,
612 #[arg(long)]
614 include_local_paths: bool,
615}
616
617#[derive(Debug, Args)]
618struct HealthArgs {
619 #[arg(long)]
621 report: Option<PathBuf>,
622 #[arg(long, value_enum, default_value_t = HealthFormat::Text)]
624 format: HealthFormat,
625 #[arg(long, default_value_t = 10)]
627 max_annotations: usize,
628 #[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 {
643 #[arg(long)]
645 effective: bool,
646 },
647 Validate,
649 DiffDefaults,
651 Migrate {
653 #[arg(long)]
655 dry_run: bool,
656 #[arg(long)]
658 no_backup: bool,
659 },
660 Schema,
662}
663
664#[derive(Debug, Args)]
665struct DoctorArgs {
666 #[arg(long, num_args = 0..=1, default_missing_value = ".slop/diagnostic-bundle.json")]
668 bundle: Option<PathBuf>,
669 #[arg(long, value_enum, default_value_t = DoctorFormat::Text)]
671 format: DoctorFormat,
672 #[arg(long)]
674 scope: Option<String>,
675 #[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 #[arg(long)]
692 keep: Option<usize>,
693 #[arg(long)]
695 max_bytes: Option<u64>,
696 #[arg(long, conflicts_with = "yes")]
698 dry_run: bool,
699 #[arg(long, conflicts_with = "dry_run")]
701 yes: bool,
702 #[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: CompletionShell,
713}
714
715#[derive(Debug, Args)]
716struct ManArgs {
717 #[arg(long)]
719 output: Option<PathBuf>,
720}
721
722#[derive(Debug, Args)]
723struct ReferenceArgs {
724 #[arg(long)]
727 output: Option<PathBuf>,
728}
729
730#[derive(Debug, Args)]
731struct HtmlArgs {
732 #[arg(long)]
734 report: Option<PathBuf>,
735 #[arg(long)]
737 require_current: bool,
738 #[arg(long)]
740 output: Option<PathBuf>,
741 #[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}