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 #[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 Policy(PolicyArgs),
56 Advise(AdviseArgs),
58 Check(CheckArgs),
60 Compare(CompareArgs),
62 Baseline(BaselineArgs),
64 Report(ReportArgs),
66 Sarif(SarifArgs),
68 Health(HealthArgs),
70 Config(ConfigArgs),
72 Doctor(DoctorArgs),
74 List(ListArgs),
76 Prune(PruneArgs),
78 Cache(CacheArgs),
80 Completions(CompletionsArgs),
82 Man(ManArgs),
84 Reference(ReferenceArgs),
86 Html(HtmlArgs),
88 Version,
90 BuildInfo(BuildInfoArgs),
92 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 #[arg(long)]
132 allow_shallow: bool,
133 #[arg(long)]
135 scope: Option<String>,
136 #[arg(long)]
138 allow_empty_scope: bool,
139 #[arg(long)]
141 quiet: bool,
142 #[arg(long)]
144 no_progress: bool,
145 #[arg(long, value_name = "PATH")]
147 state_dir: Option<PathBuf>,
148 #[arg(long, value_name = "PATH")]
150 output_dir: Option<PathBuf>,
151 #[arg(long)]
153 no_cache: bool,
154 #[arg(long, conflicts_with_all = ["state_dir", "output_dir", "persist_unadopted"])]
156 ephemeral: bool,
157 #[arg(long, conflicts_with = "ephemeral")]
159 persist_unadopted: bool,
160 #[arg(long)]
162 allow_degraded: bool,
163 #[arg(long, value_name = "RFC3339")]
165 as_of: Option<String>,
166 #[arg(long, value_enum, default_value_t = ReportProfile::Standard)]
168 report_profile: ReportProfile,
169 #[arg(long, value_enum, default_value_t = ReportCompression::None)]
171 compression: ReportCompression,
172 #[arg(long)]
174 estimate_only: bool,
175 #[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 #[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 #[arg(long)]
230 force: bool,
231 #[arg(long)]
233 repair: bool,
234 #[arg(long)]
236 check: bool,
237 #[arg(long)]
239 gitignore_only: bool,
240}
241
242#[derive(Debug, Args)]
243struct ShowArgs {
244 target_path: String,
246 #[arg(long)]
248 report: Option<PathBuf>,
249 #[arg(long)]
251 require_current: bool,
252 #[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 #[arg(long)]
266 report: Option<PathBuf>,
267 #[arg(long)]
269 require_current: bool,
270 #[arg(long)]
272 path: Option<String>,
273 #[arg(long)]
275 cluster: Option<String>,
276 #[arg(long)]
278 relationship: Option<String>,
279 #[arg(long)]
281 top: Option<i64>,
282 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
284 format: DisplayFormat,
285 #[arg(long)]
287 verbose: bool,
288 #[arg(long)]
290 prompt_pack: Option<PathBuf>,
291 #[arg(long, requires = "prompt_pack")]
293 force: bool,
294 #[arg(long, requires = "prompt_pack")]
296 include_repository_context: bool,
297 #[arg(long, default_value_t = 2048, requires = "include_repository_context")]
299 excerpt_bytes: usize,
300 #[arg(long, requires = "prompt_pack")]
302 include_local_paths: bool,
303}
304
305#[derive(Debug, Args)]
306#[command(group(
307 ArgGroup::new("selector")
308 .args(["path", "cluster", "relationship"])
309 .required(true)
310 .multiple(false)
311))]
312struct PlanArgs {
313 #[arg(long)]
315 report: Option<PathBuf>,
316 #[arg(long)]
318 require_current: bool,
319 #[arg(long)]
321 path: Option<String>,
322 #[arg(long)]
324 cluster: Option<String>,
325 #[arg(long)]
327 relationship: Option<String>,
328 #[arg(long, default_value_t = 3)]
330 max_slices: i64,
331 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
333 format: DisplayFormat,
334 #[arg(long)]
336 prompt_pack: Option<PathBuf>,
337 #[arg(long, requires = "prompt_pack")]
339 force: bool,
340 #[arg(long, requires = "prompt_pack")]
342 include_repository_context: bool,
343 #[arg(long, default_value_t = 2048, requires = "include_repository_context")]
345 excerpt_bytes: usize,
346 #[arg(long, requires = "prompt_pack")]
348 include_local_paths: bool,
349}
350
351#[derive(Debug, Args)]
352struct CheckArgs {
353 #[arg(long)]
355 report: Option<PathBuf>,
356 #[arg(long, value_enum)]
358 fail_on_context_band: Option<ContextBand>,
359 #[arg(long, value_enum)]
361 fail_on_slop_band: Option<SlopBand>,
362 #[arg(long, value_enum, default_value_t = CheckFormat::Text)]
364 format: CheckFormat,
365 #[arg(long)]
367 details: bool,
368 #[arg(long)]
370 include_folders: bool,
371 #[arg(long, default_value_t = 0, requires = "details")]
373 offset: usize,
374 #[arg(long, default_value_t = 1000, requires = "details")]
376 limit: usize,
377 #[arg(long)]
379 allow_incomplete_evidence: bool,
380 #[arg(long)]
382 evaluate_only: bool,
383 #[arg(long)]
385 require_current: bool,
386}
387
388#[derive(Debug, Args)]
389struct CompareArgs {
390 #[arg(long, value_name = "PATH")]
392 state_dir: Option<PathBuf>,
393 #[arg(
395 long,
396 required_unless_present_any = ["base_ref", "baseline"],
397 conflicts_with_all = ["base_ref", "baseline"]
398 )]
399 base: Option<PathBuf>,
400 #[arg(long, conflicts_with_all = ["base", "baseline"])]
402 base_ref: Option<String>,
403 #[arg(long, conflicts_with_all = ["base", "base_ref"])]
405 baseline: Option<String>,
406 #[arg(long, default_value = ".slop/latest/report.json")]
408 head: PathBuf,
409 #[arg(long)]
411 scope: Option<String>,
412 #[arg(long)]
414 allow_shallow: bool,
415 #[arg(long)]
417 allow_incomplete_evidence: bool,
418 #[arg(long, default_value_t = 10)]
420 top: i64,
421 #[arg(long, value_enum, default_value_t = CompareFormat::Text)]
423 format: CompareFormat,
424 #[arg(long, value_enum, default_value_t = CompareDetail::Top)]
426 detail: CompareDetail,
427 #[arg(long, default_value_t = 0)]
429 offset: usize,
430 #[arg(long, default_value_t = 1000)]
432 limit: usize,
433 #[arg(long)]
435 force: bool,
436 #[arg(long)]
438 include_local_paths: bool,
439 #[arg(long)]
441 include_unchanged: bool,
442 #[arg(long, value_enum, default_value_t = PolicySource::Base)]
444 policy_from: PolicySource,
445 #[arg(long)]
447 fail_on_regression: bool,
448}
449
450#[derive(Debug, Args)]
451struct BaselineArgs {
452 #[arg(long, value_name = "PATH", global = true)]
454 state_dir: Option<PathBuf>,
455 #[command(subcommand)]
456 command: BaselineCommand,
457}
458
459#[derive(Debug, Subcommand)]
460enum BaselineCommand {
461 Ensure {
463 #[arg(long, default_value = "default")]
465 name: String,
466 #[arg(long)]
468 report: Option<PathBuf>,
469 #[arg(long)]
471 require_current: bool,
472 #[arg(long)]
474 replace: bool,
475 #[arg(long)]
477 allow_dirty: bool,
478 #[arg(long)]
480 allow_incomplete_evidence: bool,
481 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
483 format: DisplayFormat,
484 },
485 Create {
487 #[arg(long, default_value = "default")]
489 name: String,
490 #[arg(long)]
492 report: Option<PathBuf>,
493 #[arg(long)]
495 require_current: bool,
496 #[arg(long)]
498 force: bool,
499 #[arg(long)]
501 allow_dirty: bool,
502 #[arg(long)]
504 allow_incomplete_evidence: bool,
505 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
507 format: DisplayFormat,
508 },
509 Update {
511 #[arg(long, default_value = "default")]
513 name: String,
514 #[arg(long)]
516 report: Option<PathBuf>,
517 #[arg(long)]
519 require_current: bool,
520 #[arg(long)]
522 allow_dirty: bool,
523 #[arg(long)]
525 allow_incomplete_evidence: bool,
526 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
528 format: DisplayFormat,
529 },
530 List {
532 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
534 format: DisplayFormat,
535 },
536 Inspect {
538 #[arg(long, default_value = "default")]
540 name: String,
541 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
543 format: DisplayFormat,
544 },
545 Validate {
547 #[arg(long, default_value = "default")]
549 name: String,
550 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
552 format: DisplayFormat,
553 },
554 Remove {
556 #[arg(long, default_value = "default")]
558 name: String,
559 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
561 format: DisplayFormat,
562 #[arg(long)]
564 yes: bool,
565 },
566}
567
568#[derive(Debug, Args)]
569struct ReportArgs {
570 #[command(subcommand)]
571 command: ReportCommand,
572}
573
574#[derive(Debug, Subcommand)]
575enum ReportCommand {
576 Validate {
578 #[arg(value_name = "REPORT_JSON", required_unless_present = "report")]
580 path: Option<PathBuf>,
581 #[arg(long, value_name = "REPORT_JSON", required_unless_present = "path")]
583 report: Option<PathBuf>,
584 #[arg(long)]
586 allow_legacy: bool,
587 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
589 format: DisplayFormat,
590 },
591 Migrate {
593 #[arg(value_name = "REPORT_JSON")]
595 path: PathBuf,
596 #[arg(long, value_name = "PATH")]
598 output: PathBuf,
599 },
600 Schema,
602}
603
604#[derive(Debug, Args)]
605struct SarifArgs {
606 #[arg(long)]
608 report: Option<PathBuf>,
609 #[arg(long)]
611 require_current: bool,
612 #[arg(long)]
614 top: Option<i64>,
615 #[arg(long, value_enum, default_value_t = SarifScope::ActionQueue)]
617 scope: SarifScope,
618 #[arg(long)]
620 output: Option<PathBuf>,
621 #[arg(long)]
623 include_local_paths: bool,
624}
625
626#[derive(Debug, Args)]
627struct HealthArgs {
628 #[arg(long)]
630 report: Option<PathBuf>,
631 #[arg(long, value_enum, default_value_t = HealthFormat::Text)]
633 format: HealthFormat,
634 #[arg(long, default_value_t = 10)]
636 max_annotations: usize,
637 #[arg(long)]
639 require_current: bool,
640}
641
642#[derive(Debug, Args)]
643struct ConfigArgs {
644 #[command(subcommand)]
645 command: ConfigCommand,
646}
647
648#[derive(Debug, Subcommand)]
649enum ConfigCommand {
650 Show {
652 #[arg(long)]
654 effective: bool,
655 },
656 Validate,
658 DiffDefaults,
660 Migrate {
662 #[arg(long)]
664 dry_run: bool,
665 #[arg(long)]
667 no_backup: bool,
668 },
669 Schema,
671}
672
673#[derive(Debug, Args)]
674struct DoctorArgs {
675 #[arg(long, num_args = 0..=1, default_missing_value = "__git_slop_active_state_bundle__")]
677 bundle: Option<PathBuf>,
678 #[arg(long, value_enum, default_value_t = DoctorFormat::Text)]
680 format: DoctorFormat,
681 #[arg(long)]
683 scope: Option<String>,
684 #[arg(long)]
686 require_current: bool,
687}
688
689#[derive(Debug, Clone, Copy, ValueEnum)]
690enum DoctorFormat {
691 Text,
692 Json,
693}
694
695include!("cli/list_args.rs");
696
697#[derive(Debug, Args)]
698struct PruneArgs {
699 #[arg(long, value_name = "PATH")]
701 state_dir: Option<PathBuf>,
702 #[arg(long)]
704 keep: Option<usize>,
705 #[arg(long)]
707 max_bytes: Option<u64>,
708 #[arg(long, conflicts_with = "yes")]
710 dry_run: bool,
711 #[arg(long, conflicts_with = "dry_run")]
713 yes: bool,
714 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
716 format: DisplayFormat,
717}
718
719include!("cli/cache_args.rs");
720include!("cli/policy_args.rs");
721include!("cli/advice_args.rs");
722
723#[derive(Debug, Args)]
724struct CompletionsArgs {
725 shell: CompletionShell,
727}
728
729#[derive(Debug, Args)]
730struct ManArgs {
731 #[arg(long)]
733 output: Option<PathBuf>,
734}
735
736#[derive(Debug, Args)]
737struct ReferenceArgs {
738 #[arg(long)]
741 output: Option<PathBuf>,
742}
743
744#[derive(Debug, Args)]
745struct HtmlArgs {
746 #[arg(long)]
748 report: Option<PathBuf>,
749 #[arg(long)]
751 require_current: bool,
752 #[arg(long)]
754 output: Option<PathBuf>,
755 #[arg(long)]
757 include_local_paths: bool,
758 #[arg(long)]
760 serve: bool,
761 #[arg(long, requires = "serve")]
763 open: bool,
764 #[arg(long, default_value_t = 120, value_parser = clap::value_parser!(u64).range(1..=3600), requires = "serve")]
766 serve_seconds: u64,
767}
768
769include!("cli/formats.rs");
770include!("cli/support.rs");
771include!("cli/support/validation.rs");
772include!("cli/init.rs");
773include!("cli/analysis.rs");
774include!("cli/analysis/reporting.rs");
775include!("cli/analysis_receipt.rs");
776include!("cli/check.rs");
777include!("cli/baseline_compare.rs");
778include!("cli/reporting.rs");
779include!("cli/doctor/support.rs");
780include!("cli/doctor.rs");
781include!("cli/listing.rs");
782include!("cli/policy_cmd.rs");
783include!("cli/advice_cmd.rs");
784include!("cli/generation/server.rs");
785include!("cli/generation.rs");
786include!("cli/generation/artifacts.rs");
787include!("cli/generation/reference.rs");
788include!("cli/generation/reference/pages.rs");
789include!("cli/generation/reference/bundle.rs");
790include!("cli/entry.rs");
791
792#[cfg(test)]
793mod tests {
794 use clap::CommandFactory;
795
796 use super::Cli;
797
798 fn assert_descriptions(command: &clap::Command, path: &str) {
799 for argument in command.get_arguments() {
800 assert!(
801 argument
802 .get_help()
803 .is_some_and(|help| !help.to_string().trim().is_empty()),
804 "{path} argument {} has no generated reference description",
805 argument.get_id()
806 );
807 }
808 for subcommand in command.get_subcommands() {
809 assert_descriptions(subcommand, &format!("{path} {}", subcommand.get_name()));
810 }
811 }
812
813 #[test]
814 fn generated_reference_has_no_blank_argument_descriptions() {
815 assert_descriptions(&Cli::command(), "git-slop");
816 }
817}