1use std::fs;
2use std::io::IsTerminal;
3use std::path::{Component, Path, PathBuf};
4
5use anyhow::{Context, Result};
6use clap::{ArgGroup, Args, CommandFactory, Parser, Subcommand, ValueEnum};
7use clap_complete::{Shell, generate};
8use serde_json::{Value, json};
9use sha2::Digest;
10
11use crate::build_info;
12use crate::config;
13use crate::error::{ClassifiedError, ErrorKind};
14use crate::health;
15use crate::report;
16use crate::report_ops::{
17 ExplainSelector, PlanSelector, compare_payload_with_force, explain_payload, failing_records,
18 health_json_payload, plan_payload, render_compare_text, render_explain_text,
19 render_github_annotations, render_json, render_plan_text, render_show_text, sarif_payload,
20 show_payload, write_prompt_pack,
21};
22use crate::{PROJECT_NAME, VERSION, analyze, git};
23
24#[derive(Debug, Parser)]
25#[command(
26 name = "git-slop",
27 about = "Find the files that cost too much context.",
28 version = VERSION
29)]
30struct Cli {
31 #[arg(long, global = true, value_name = "PATH")]
33 repo: Option<PathBuf>,
34 #[arg(long, global = true, value_enum, default_value_t = ErrorFormat::Human)]
36 error_format: ErrorFormat,
37 #[command(subcommand)]
38 command: Command,
39}
40
41#[derive(Debug, Subcommand)]
42enum Command {
43 Init(InitArgs),
45 Find(FindArgs),
47 Show(ShowArgs),
49 Explain(ExplainArgs),
51 Plan(PlanArgs),
53 Check(CheckArgs),
55 Compare(CompareArgs),
57 Report(ReportArgs),
59 Sarif(SarifArgs),
61 Health(HealthArgs),
63 Config(ConfigArgs),
65 Doctor(DoctorArgs),
67 List(ListArgs),
69 Prune(PruneArgs),
71 Cache(CacheArgs),
73 Completions(CompletionsArgs),
75 Man(ManArgs),
77 Reference(ReferenceArgs),
79 Html(HtmlArgs),
81 Version,
83 BuildInfo(BuildInfoArgs),
85 Schema(SchemaArgs),
87}
88
89#[derive(Debug, Args)]
90struct SchemaArgs {
91 #[arg(value_enum)]
92 contract: SchemaContract,
93}
94
95#[derive(Debug, Clone, Copy, ValueEnum)]
96enum SchemaContract {
97 Report,
98 Config,
99 Compare,
100 Explain,
101 Plan,
102 Sarif,
103 Health,
104 Check,
105 Doctor,
106 BuildInfo,
107 List,
108 Show,
109 PromptManifest,
110}
111
112#[derive(Debug, Args)]
113struct FindArgs {
114 #[arg(long)]
116 allow_shallow: bool,
117 #[arg(long)]
119 scope: Option<String>,
120 #[arg(long)]
122 allow_empty_scope: bool,
123 #[arg(long)]
125 quiet: bool,
126 #[arg(long)]
128 no_progress: bool,
129 #[arg(long, value_name = "PATH")]
131 state_dir: Option<PathBuf>,
132 #[arg(long, value_name = "PATH")]
134 output_dir: Option<PathBuf>,
135 #[arg(long)]
137 no_cache: bool,
138 #[arg(long)]
140 allow_degraded: bool,
141 #[arg(long, value_enum, default_value_t = ReportProfile::Standard)]
143 report_profile: ReportProfile,
144 #[arg(long, value_enum, default_value_t = ReportCompression::None)]
146 compression: ReportCompression,
147 #[arg(long)]
149 estimate_only: bool,
150}
151
152#[derive(Debug, Clone, Copy, ValueEnum)]
153enum ReportProfile {
154 Compact,
155 Standard,
156 FullEvidence,
157}
158
159impl ReportProfile {
160 fn as_str(self) -> &'static str {
161 match self {
162 Self::Compact => "compact",
163 Self::Standard => "standard",
164 Self::FullEvidence => "full_evidence",
165 }
166 }
167}
168
169#[derive(Debug, Clone, Copy, ValueEnum)]
170enum ReportCompression {
171 None,
172 Gzip,
173 Zstd,
174}
175
176impl ReportCompression {
177 fn as_str(self) -> &'static str {
178 match self {
179 Self::None => "none",
180 Self::Gzip => "gzip",
181 Self::Zstd => "zstd",
182 }
183 }
184}
185
186#[derive(Debug, Args)]
187struct BuildInfoArgs {
188 #[arg(long, value_enum, default_value_t = BuildInfoFormat::Json)]
189 format: BuildInfoFormat,
190}
191
192#[derive(Debug, Args)]
193struct InitArgs {
194 #[arg(long)]
196 force: bool,
197}
198
199#[derive(Debug, Args)]
200struct ShowArgs {
201 target_path: String,
203 #[arg(long)]
205 report: Option<PathBuf>,
206 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
207 format: DisplayFormat,
208}
209
210#[derive(Debug, Args)]
211#[command(group(
212 ArgGroup::new("selector")
213 .args(["path", "cluster", "relationship", "top"])
214 .multiple(false)
215))]
216struct ExplainArgs {
217 #[arg(long)]
219 report: Option<PathBuf>,
220 #[arg(long)]
222 path: Option<String>,
223 #[arg(long)]
225 cluster: Option<String>,
226 #[arg(long)]
228 relationship: Option<String>,
229 #[arg(long)]
231 top: Option<i64>,
232 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
233 format: DisplayFormat,
234 #[arg(long)]
236 prompt_pack: Option<PathBuf>,
237 #[arg(long, requires = "prompt_pack")]
239 include_repository_context: bool,
240 #[arg(long, default_value_t = 2048, requires = "include_repository_context")]
242 excerpt_bytes: usize,
243}
244
245#[derive(Debug, Args)]
246#[command(group(
247 ArgGroup::new("selector")
248 .args(["path", "cluster", "relationship"])
249 .required(true)
250 .multiple(false)
251))]
252struct PlanArgs {
253 #[arg(long)]
255 report: Option<PathBuf>,
256 #[arg(long)]
258 path: Option<String>,
259 #[arg(long)]
261 cluster: Option<String>,
262 #[arg(long)]
264 relationship: Option<String>,
265 #[arg(long, default_value_t = 3)]
267 max_slices: i64,
268 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
269 format: DisplayFormat,
270 #[arg(long)]
272 prompt_pack: Option<PathBuf>,
273 #[arg(long, requires = "prompt_pack")]
275 include_repository_context: bool,
276 #[arg(long, default_value_t = 2048, requires = "include_repository_context")]
278 excerpt_bytes: usize,
279}
280
281#[derive(Debug, Args)]
282struct CheckArgs {
283 #[arg(long)]
285 report: Option<PathBuf>,
286 #[arg(long, value_enum)]
288 fail_on_context_band: Option<ContextBand>,
289 #[arg(long, value_enum)]
291 fail_on_slop_band: Option<SlopBand>,
292 #[arg(long, value_enum, default_value_t = CheckFormat::Text)]
293 format: CheckFormat,
294 #[arg(long)]
296 details: bool,
297}
298
299#[derive(Debug, Args)]
300struct CompareArgs {
301 #[arg(
303 long,
304 required_unless_present = "base_ref",
305 conflicts_with = "base_ref"
306 )]
307 base: Option<PathBuf>,
308 #[arg(long, conflicts_with = "base")]
310 base_ref: Option<String>,
311 #[arg(long, default_value = ".slop/latest/report.json")]
313 head: PathBuf,
314 #[arg(long)]
316 scope: Option<String>,
317 #[arg(long)]
319 allow_shallow: bool,
320 #[arg(long, default_value_t = 10)]
322 top: i64,
323 #[arg(long, value_enum, default_value_t = CompareFormat::Text)]
324 format: CompareFormat,
325 #[arg(long, value_enum, default_value_t = CompareDetail::Top)]
327 detail: CompareDetail,
328 #[arg(long, default_value_t = 0)]
330 offset: usize,
331 #[arg(long, default_value_t = 1000)]
333 limit: usize,
334 #[arg(long)]
336 force: bool,
337 #[arg(long)]
339 fail_on_regression: bool,
340}
341
342#[derive(Debug, Args)]
343struct ReportArgs {
344 #[command(subcommand)]
345 command: ReportCommand,
346}
347
348#[derive(Debug, Subcommand)]
349enum ReportCommand {
350 Validate {
352 #[arg(value_name = "REPORT_JSON")]
353 path: PathBuf,
354 #[arg(long)]
356 allow_legacy: bool,
357 },
358 Migrate {
360 #[arg(value_name = "REPORT_JSON")]
361 path: PathBuf,
362 #[arg(long, value_name = "PATH")]
363 output: PathBuf,
364 },
365 Schema,
367}
368
369#[derive(Debug, Args)]
370struct SarifArgs {
371 #[arg(long)]
373 report: Option<PathBuf>,
374 #[arg(long)]
376 top: Option<i64>,
377 #[arg(long)]
379 output: Option<PathBuf>,
380}
381
382#[derive(Debug, Args)]
383struct HealthArgs {
384 #[arg(long)]
386 report: Option<PathBuf>,
387 #[arg(long, value_enum, default_value_t = HealthFormat::Text)]
389 format: HealthFormat,
390 #[arg(long, default_value_t = 10)]
392 max_annotations: usize,
393}
394
395#[derive(Debug, Args)]
396struct ConfigArgs {
397 #[command(subcommand)]
398 command: ConfigCommand,
399}
400
401#[derive(Debug, Subcommand)]
402enum ConfigCommand {
403 Show {
405 #[arg(long)]
406 effective: bool,
407 },
408 Validate,
410 DiffDefaults,
412 Migrate,
414 Schema,
416}
417
418#[derive(Debug, Args)]
419struct DoctorArgs {
420 #[arg(long, num_args = 0..=1, default_missing_value = ".slop/diagnostic-bundle.json")]
422 bundle: Option<PathBuf>,
423 #[arg(long, value_enum, default_value_t = DoctorFormat::Text)]
424 format: DoctorFormat,
425 #[arg(long)]
427 scope: Option<String>,
428}
429
430#[derive(Debug, Clone, Copy, ValueEnum)]
431enum DoctorFormat {
432 Text,
433 Json,
434}
435
436#[derive(Debug, Args)]
437struct ListArgs {
438 #[command(subcommand)]
439 command: ListCommand,
440}
441
442#[derive(Debug, Subcommand)]
443enum ListCommand {
444 Findings(ListFilterArgs),
445 Relationships(ListFilterArgs),
446 Clusters(ListFilterArgs),
447 Profiles(ListFilterArgs),
448}
449
450#[derive(Debug, Args)]
451struct ListFilterArgs {
452 #[arg(long)]
453 report: Option<PathBuf>,
454 #[arg(long)]
455 path: Option<String>,
456 #[arg(long)]
457 profile: Option<String>,
458 #[arg(long)]
459 language: Option<String>,
460 #[arg(long, visible_alias = "class")]
461 classification: Option<String>,
462 #[arg(long)]
463 severity: Option<String>,
464 #[arg(long, default_value_t = 50)]
465 top: usize,
466 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
467 format: DisplayFormat,
468 #[arg(long)]
470 wide: bool,
471 #[arg(long)]
473 no_truncate: bool,
474}
475
476#[derive(Debug, Args)]
477struct PruneArgs {
478 #[arg(long)]
480 keep: Option<usize>,
481 #[arg(long)]
483 max_bytes: Option<u64>,
484 #[arg(long)]
486 dry_run: bool,
487 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
489 format: DisplayFormat,
490}
491
492#[derive(Debug, Args)]
493struct CacheArgs {
494 #[arg(long, value_name = "PATH")]
495 state_dir: Option<PathBuf>,
496 #[command(subcommand)]
497 command: CacheCommand,
498}
499
500#[derive(Debug, Subcommand)]
501enum CacheCommand {
502 Status {
503 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
504 format: DisplayFormat,
505 },
506 Prune {
507 #[arg(long, default_value_t = 10_000)]
508 max_entries: usize,
509 #[arg(long, default_value_t = 536_870_912)]
510 max_bytes: u64,
511 #[arg(long)]
512 dry_run: bool,
513 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
514 format: DisplayFormat,
515 },
516}
517
518#[derive(Debug, Args)]
519struct CompletionsArgs {
520 shell: CompletionShell,
521}
522
523#[derive(Debug, Args)]
524struct ManArgs {
525 #[arg(long)]
526 output: Option<PathBuf>,
527}
528
529#[derive(Debug, Args)]
530struct ReferenceArgs {
531 #[arg(long)]
532 output: Option<PathBuf>,
533}
534
535#[derive(Debug, Args)]
536struct HtmlArgs {
537 #[arg(long)]
539 report: Option<PathBuf>,
540 #[arg(long)]
542 output: Option<PathBuf>,
543}
544
545#[derive(Debug, Clone, Copy, ValueEnum)]
546enum CompletionShell {
547 Bash,
548 Zsh,
549 Fish,
550 Powershell,
551 Nushell,
552}
553
554#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
555enum DisplayFormat {
556 Text,
557 Json,
558 Yaml,
559}
560
561#[derive(Debug, Clone, Copy, ValueEnum)]
562enum CompareFormat {
563 Text,
564 Json,
565 Yaml,
566 Ndjson,
567}
568
569#[derive(Debug, Clone, Copy, ValueEnum)]
570enum CompareDetail {
571 Summary,
572 Top,
573 Full,
574}
575
576#[derive(Debug, Clone, Copy, ValueEnum)]
577enum HealthFormat {
578 Text,
579 Markdown,
580 Github,
581 Json,
582}
583
584#[derive(Debug, Clone, Copy, ValueEnum)]
585enum BuildInfoFormat {
586 Json,
587}
588
589#[derive(Debug, Clone, Copy, ValueEnum)]
590enum CheckFormat {
591 Text,
592 Json,
593 Github,
594}
595
596#[derive(Debug, Clone, Copy, ValueEnum)]
597enum ErrorFormat {
598 Human,
599 Json,
600}
601
602#[derive(Debug, Clone, Copy, ValueEnum)]
603enum ContextBand {
604 Compact,
605 Healthy,
606 Warning,
607 Critical,
608}
609
610impl ContextBand {
611 fn as_str(self) -> &'static str {
612 match self {
613 Self::Compact => "compact",
614 Self::Healthy => "healthy",
615 Self::Warning => "warning",
616 Self::Critical => "critical",
617 }
618 }
619}
620
621#[derive(Debug, Clone, Copy, ValueEnum)]
622enum SlopBand {
623 Low,
624 Moderate,
625 High,
626 Critical,
627}
628
629impl SlopBand {
630 fn as_str(self) -> &'static str {
631 match self {
632 Self::Low => "low",
633 Self::Moderate => "moderate",
634 Self::High => "high",
635 Self::Critical => "critical",
636 }
637 }
638}
639
640fn print_text(value: &str) {
641 if value.is_empty() {
642 return;
643 }
644 print!("{value}");
645 if !value.ends_with('\n') {
646 println!();
647 }
648}
649
650fn safe_terminal(value: &str) -> String {
651 value
652 .chars()
653 .filter(|character| *character == '\n' || *character == '\t' || !character.is_control())
654 .collect()
655}
656
657fn relative_display(path: &Path, root: &Path) -> String {
658 path.strip_prefix(root)
659 .unwrap_or(path)
660 .to_string_lossy()
661 .replace('\\', "/")
662}
663
664fn default_report_path(repo_root: &Path) -> PathBuf {
665 config::latest_dir(repo_root).join("report.json")
666}
667
668fn load_report_at(path: &Path) -> Result<Option<Value>> {
669 if !path.exists() {
670 return Ok(None);
671 }
672 report::load_report(path).map(Some)
673}
674
675fn load_default_report(
676 repo_root: &Path,
677 explicit_report: Option<&Path>,
678) -> Result<Option<(Value, PathBuf)>> {
679 let path = explicit_report
680 .map(Path::to_path_buf)
681 .unwrap_or_else(|| default_report_path(repo_root));
682 Ok(load_report_at(&path)?.map(|report| (report, path)))
683}
684
685fn report_or_missing(
686 repo_root: &Path,
687 explicit_report: Option<&Path>,
688) -> Result<Result<(Value, PathBuf), i32>> {
689 let fallback = explicit_report
690 .map(Path::to_path_buf)
691 .unwrap_or_else(|| default_report_path(repo_root));
692 let loaded = match load_default_report(repo_root, explicit_report) {
693 Ok(loaded) => loaded,
694 Err(error) => {
695 eprintln!("{error:#}");
696 return Ok(Err(2));
697 }
698 };
699 Ok(match loaded {
700 Some(loaded) => Ok(loaded),
701 None => {
702 eprintln!(
703 "Report not found: {}\nRun `git slop find` to generate it.",
704 fallback.display()
705 );
706 Err(2)
707 }
708 })
709}
710
711fn lexical_normalize(path: &Path) -> PathBuf {
712 let mut result = PathBuf::new();
713 for component in path.components() {
714 match component {
715 Component::CurDir => {}
716 Component::ParentDir => {
717 if !result.pop() {
718 result.push(component.as_os_str());
719 }
720 }
721 _ => result.push(component.as_os_str()),
722 }
723 }
724 result
725}
726
727fn selector_path(repo_root: &Path, input: &str) -> String {
728 let candidate = if Path::new(input).is_absolute() {
729 lexical_normalize(Path::new(input))
730 } else {
731 lexical_normalize(&repo_root.join(input))
732 };
733 candidate
734 .strip_prefix(repo_root)
735 .ok()
736 .map(|path| path.to_string_lossy().replace('\\', "/"))
737 .unwrap_or_else(|| {
738 let trimmed = input.trim();
739 if trimmed.is_empty() {
740 ".".to_string()
741 } else {
742 trimmed.to_string()
743 }
744 })
745}
746
747fn usage_error(error: impl std::fmt::Display) -> Result<i32> {
748 eprintln!("{error}");
749 Ok(2)
750}
751
752fn ensure_prompt_pack_target(path: &Path) -> Result<Result<(), i32>> {
753 if path.exists() && !path.is_dir() {
754 eprintln!("Prompt pack path is not a directory: {}", path.display());
755 Ok(Err(2))
756 } else {
757 Ok(Ok(()))
758 }
759}
760
761fn run_init(repo_root: &Path, args: InitArgs) -> Result<i32> {
762 let result = config::initialize(repo_root, args.force)?;
763 println!(
764 "Initialized {} ({}).",
765 relative_display(&config::config_path(repo_root), repo_root),
766 result.config
767 );
768 println!(
769 "Initialized {} ({}).",
770 relative_display(&config::slop_dir(repo_root).join(".gitignore"), repo_root),
771 result.gitignore
772 );
773 println!("Ensured .slop/latest/, .slop/runs/, and .slop/cache/ exist.");
774 Ok(0)
775}
776
777fn run_find(repo_root: &Path, args: FindArgs) -> Result<i32> {
778 if args.estimate_only {
779 let config = config::load(repo_root)?;
780 let scope = args
781 .scope
782 .as_deref()
783 .map(str::trim)
784 .filter(|value| !value.is_empty() && *value != ".");
785 let paths = git::list_tracked_files(repo_root)?
786 .into_iter()
787 .filter(|path| {
788 scope.is_none_or(|scope| path == scope || path.starts_with(&format!("{scope}/")))
789 })
790 .collect::<Vec<_>>();
791 let payload = json!({
792 "schema_version": 1,
793 "command": "find estimate",
794 "scope": scope,
795 "estimate": crate::estimate::build(repo_root, &paths, &config)
796 });
797 print_text(&render_json(&payload)?);
798 return Ok(0);
799 }
800 let result = analyze::run_find_with_options(
801 repo_root,
802 &analyze::FindOptions {
803 allow_shallow: args.allow_shallow,
804 scope: args.scope,
805 progress: !args.quiet && !args.no_progress && std::io::stderr().is_terminal(),
806 allow_empty_scope: args.allow_empty_scope,
807 state_dir: args.state_dir,
808 output_dir: args.output_dir,
809 no_cache: args.no_cache,
810 allow_degraded: args.allow_degraded,
811 report_profile: args.report_profile.as_str().to_string(),
812 compression: args.compression.as_str().to_string(),
813 },
814 )?;
815 if args.quiet {
816 return Ok(0);
817 }
818 print_text(&result.terminal);
819 println!("Wrote report to {}.", result.report_json.display());
820 if result.report_yaml.exists() {
821 println!("Wrote YAML report to {}.", result.report_yaml.display());
822 }
823 println!("Wrote summary to {}.", result.summary_md.display());
824 println!(
825 "Wrote repository health summary to {}.",
826 result.health_md.display()
827 );
828 if let Some(path) = result.compressed_report {
829 println!("Wrote compressed report to {}.", path.display());
830 }
831 Ok(0)
832}
833
834fn run_show(repo_root: &Path, args: ShowArgs) -> Result<i32> {
835 let (loaded, report_path) = match report_or_missing(repo_root, args.report.as_deref())? {
836 Ok(value) => value,
837 Err(code) => return Ok(code),
838 };
839 let target = selector_path(repo_root, &args.target_path);
840 let Some(payload) = show_payload(&loaded, &target) else {
841 eprintln!(
842 "No record found for '{}' in {}.",
843 args.target_path,
844 report_path.display()
845 );
846 return Ok(2);
847 };
848 match args.format {
849 DisplayFormat::Json => print_text(&render_json(&payload)?),
850 DisplayFormat::Text => print_text(&render_show_text(&payload)),
851 DisplayFormat::Yaml => print_text(&serde_yaml::to_string(&payload)?),
852 }
853 Ok(0)
854}
855
856fn explain_selector(args: &ExplainArgs, repo_root: &Path) -> Result<ExplainSelector, i32> {
857 if let Some(path) = &args.path {
858 Ok(ExplainSelector::Path(selector_path(repo_root, path)))
859 } else if let Some(id) = &args.cluster {
860 Ok(ExplainSelector::Cluster(id.clone()))
861 } else if let Some(id) = &args.relationship {
862 Ok(ExplainSelector::Relationship(id.clone()))
863 } else {
864 let count = args.top.unwrap_or(5);
865 match usize::try_from(count).ok().filter(|count| *count > 0) {
866 Some(count) => Ok(ExplainSelector::Top(count)),
867 None => Err(2),
868 }
869 }
870}
871
872fn run_explain(repo_root: &Path, args: ExplainArgs) -> Result<i32> {
873 if args.include_repository_context && !(256..=4096).contains(&args.excerpt_bytes) {
874 eprintln!("--excerpt-bytes must be between 256 and 4096.");
875 return Ok(2);
876 }
877 let (loaded, _) = match report_or_missing(repo_root, args.report.as_deref())? {
878 Ok(value) => value,
879 Err(code) => return Ok(code),
880 };
881 let selector = match explain_selector(&args, repo_root) {
882 Ok(selector) => selector,
883 Err(code) => {
884 eprintln!("--top must be greater than zero.");
885 return Ok(code);
886 }
887 };
888 let payload = match explain_payload(&loaded, Some(selector)) {
889 Ok(payload) => payload,
890 Err(error) => return usage_error(error),
891 };
892 if let Some(output_dir) = args.prompt_pack.as_deref() {
893 if let Err(code) = ensure_prompt_pack_target(output_dir)? {
894 return Ok(code);
895 }
896 write_prompt_pack(
897 "explain",
898 &payload,
899 &loaded,
900 output_dir,
901 args.include_repository_context.then_some(repo_root),
902 args.excerpt_bytes,
903 )?;
904 }
905 match args.format {
906 DisplayFormat::Json => print_text(&render_json(&payload)?),
907 DisplayFormat::Text => print_text(&render_explain_text(&payload)),
908 DisplayFormat::Yaml => print_text(&serde_yaml::to_string(&payload)?),
909 }
910 Ok(0)
911}
912
913fn plan_selector(args: &PlanArgs, repo_root: &Path) -> PlanSelector {
914 if let Some(path) = &args.path {
915 PlanSelector::Path(selector_path(repo_root, path))
916 } else if let Some(id) = &args.cluster {
917 PlanSelector::Cluster(id.clone())
918 } else {
919 PlanSelector::Relationship(args.relationship.clone().unwrap_or_default())
920 }
921}
922
923fn run_plan(repo_root: &Path, args: PlanArgs) -> Result<i32> {
924 if args.include_repository_context && !(256..=4096).contains(&args.excerpt_bytes) {
925 eprintln!("--excerpt-bytes must be between 256 and 4096.");
926 return Ok(2);
927 }
928 let (loaded, _) = match report_or_missing(repo_root, args.report.as_deref())? {
929 Ok(value) => value,
930 Err(code) => return Ok(code),
931 };
932 let Some(max_slices) = usize::try_from(args.max_slices)
933 .ok()
934 .filter(|count| *count > 0)
935 else {
936 eprintln!("--max-slices must be greater than zero.");
937 return Ok(2);
938 };
939 let payload = match plan_payload(&loaded, plan_selector(&args, repo_root), max_slices) {
940 Ok(payload) => payload,
941 Err(error) => return usage_error(error),
942 };
943 if let Some(output_dir) = args.prompt_pack.as_deref() {
944 if let Err(code) = ensure_prompt_pack_target(output_dir)? {
945 return Ok(code);
946 }
947 write_prompt_pack(
948 "plan",
949 &payload,
950 &loaded,
951 output_dir,
952 args.include_repository_context.then_some(repo_root),
953 args.excerpt_bytes,
954 )?;
955 }
956 match args.format {
957 DisplayFormat::Json => print_text(&render_json(&payload)?),
958 DisplayFormat::Text => print_text(&render_plan_text(&payload)),
959 DisplayFormat::Yaml => print_text(&serde_yaml::to_string(&payload)?),
960 }
961 Ok(0)
962}
963
964fn run_check(repo_root: &Path, args: CheckArgs) -> Result<i32> {
965 let (loaded, _) = match report_or_missing(repo_root, args.report.as_deref())? {
966 Ok(value) => value,
967 Err(code) => return Ok(code),
968 };
969 let loaded_config = loaded
970 .get("config")
971 .cloned()
972 .unwrap_or_else(config::default_config);
973 let context_band = args
974 .fail_on_context_band
975 .map(ContextBand::as_str)
976 .or_else(|| {
977 loaded_config
978 .pointer("/check/fail_on_context_band")
979 .and_then(Value::as_str)
980 })
981 .unwrap_or("critical");
982 let slop_band = args
983 .fail_on_slop_band
984 .map(SlopBand::as_str)
985 .or_else(|| {
986 loaded_config
987 .pointer("/check/fail_on_slop_band")
988 .and_then(Value::as_str)
989 })
990 .unwrap_or("critical");
991 let failures = failing_records(&loaded, Some(context_band), Some(slop_band));
992 if !matches!(args.format, CheckFormat::Text) {
993 match args.format {
994 CheckFormat::Json => {
995 let mut payload = json!({
996 "schema_version": 1,
997 "command": "check",
998 "report": {"schema_version": loaded.get("schema_version"), "analyzer": loaded.get("analyzer"), "repo": loaded.get("repo"), "scope": loaded.get("scope")},
999 "boundary": {"context_band": context_band, "slop_band": slop_band},
1000 "passed": failures.is_empty(),
1001 "finding_count": failures.len(),
1002 "details_included": args.details,
1003 });
1004 if args.details {
1005 payload["findings"] = json!(failures);
1006 }
1007 print_text(&render_json(&payload)?);
1008 }
1009 CheckFormat::Github => {
1010 for failure in &failures {
1011 let path = safe_terminal(
1012 failure
1013 .get("path")
1014 .and_then(Value::as_str)
1015 .unwrap_or_default(),
1016 );
1017 println!(
1018 "::error file={}::Git Slop context={} slop={} score={}",
1019 path.replace('%', "%25").replace(',', "%2C"),
1020 failure
1021 .get("context_band")
1022 .and_then(Value::as_str)
1023 .unwrap_or_default(),
1024 failure
1025 .get("slop_band")
1026 .and_then(Value::as_str)
1027 .unwrap_or_default(),
1028 failure.get("slop_score").unwrap_or(&Value::Null)
1029 );
1030 }
1031 }
1032 CheckFormat::Text => {}
1033 }
1034 return Ok(if failures.is_empty() { 0 } else { 1 });
1035 }
1036 if failures.is_empty() {
1037 println!(
1038 "Check passed: no file records met or exceeded context={context_band} or slop={slop_band}."
1039 );
1040 return Ok(0);
1041 }
1042 println!(
1043 "Check failed: {} file records met or exceeded context={context_band} or slop={slop_band}.",
1044 failures.len()
1045 );
1046 for failure in failures.iter().take(10) {
1047 println!(
1048 "- {} (slop={}, context={}, slop_score={})",
1049 failure
1050 .get("path")
1051 .and_then(Value::as_str)
1052 .unwrap_or_default(),
1053 failure
1054 .get("slop_band")
1055 .and_then(Value::as_str)
1056 .unwrap_or_default(),
1057 failure
1058 .get("context_band")
1059 .and_then(Value::as_str)
1060 .unwrap_or_default(),
1061 failure
1062 .get("slop_score")
1063 .map(ToString::to_string)
1064 .unwrap_or_else(|| "null".to_string()),
1065 );
1066 }
1067 Ok(1)
1068}
1069
1070fn bounded_compare_output(
1071 payload: &Value,
1072 detail: CompareDetail,
1073 top: usize,
1074 offset: usize,
1075 limit: usize,
1076) -> Result<Value> {
1077 if limit == 0 {
1078 anyhow::bail!("--limit must be greater than zero");
1079 }
1080 let mut bounded = payload.clone();
1081 let cap = match detail {
1082 CompareDetail::Summary => 0,
1083 CompareDetail::Top => top,
1084 CompareDetail::Full => limit,
1085 };
1086 let start = if matches!(detail, CompareDetail::Full) {
1087 offset
1088 } else {
1089 0
1090 };
1091 let mut pagination = serde_json::Map::new();
1092 for key in [
1093 "file_deltas",
1094 "folder_deltas",
1095 "queue_movement",
1096 "overlay_deltas",
1097 "regressions",
1098 ] {
1099 let values = payload
1100 .get(key)
1101 .and_then(Value::as_array)
1102 .cloned()
1103 .unwrap_or_default();
1104 let returned = values
1105 .iter()
1106 .skip(start)
1107 .take(cap)
1108 .cloned()
1109 .collect::<Vec<_>>();
1110 pagination.insert(
1111 key.to_string(),
1112 json!({
1113 "total": values.len(),
1114 "offset": start,
1115 "limit": cap,
1116 "returned": returned.len(),
1117 "has_more": start.saturating_add(returned.len()) < values.len()
1118 }),
1119 );
1120 bounded[key] = json!(returned);
1121 }
1122 bounded["detail"] = json!(match detail {
1123 CompareDetail::Summary => "summary",
1124 CompareDetail::Top => "top",
1125 CompareDetail::Full => "full",
1126 });
1127 bounded["pagination"] = Value::Object(pagination);
1128 Ok(bounded)
1129}
1130
1131fn render_compare_ndjson(payload: &Value) -> Result<String> {
1132 let mut lines = vec![render_json(&json!({
1133 "record_type": "summary",
1134 "schema_version": payload.get("schema_version"),
1135 "summary": payload.get("summary"),
1136 "pagination": payload.get("pagination"),
1137 "baseline_status": payload.get("baseline_status")
1138 }))?];
1139 for (key, record_type) in [
1140 ("file_deltas", "file_delta"),
1141 ("folder_deltas", "folder_delta"),
1142 ("regressions", "regression"),
1143 ] {
1144 for record in payload
1145 .get(key)
1146 .and_then(Value::as_array)
1147 .into_iter()
1148 .flatten()
1149 {
1150 lines.push(render_json(
1151 &json!({"record_type": record_type, "record": record}),
1152 )?);
1153 }
1154 }
1155 Ok(lines.join("\n"))
1156}
1157
1158fn run_compare(repo_root: &Path, args: CompareArgs) -> Result<i32> {
1159 let materialized = if let Some(reference) = args.base_ref.as_deref() {
1160 Some(crate::baseline::MaterializedBaseline::create(
1161 repo_root,
1162 reference,
1163 args.scope.clone(),
1164 args.allow_shallow,
1165 )?)
1166 } else {
1167 None
1168 };
1169 let base_path = args
1170 .base
1171 .as_deref()
1172 .or_else(|| {
1173 materialized
1174 .as_ref()
1175 .map(|value| value.report_path.as_path())
1176 })
1177 .expect("Clap requires --base or --base-ref");
1178 let Some(base_report) = (match load_report_at(base_path) {
1179 Ok(report) => report,
1180 Err(error) => {
1181 eprintln!("{error:#}");
1182 return Ok(2);
1183 }
1184 }) else {
1185 eprintln!("Report not found: {}", base_path.display());
1186 return Ok(2);
1187 };
1188 let Some(head_report) = (match load_report_at(&args.head) {
1189 Ok(report) => report,
1190 Err(error) => {
1191 eprintln!("{error:#}");
1192 return Ok(2);
1193 }
1194 }) else {
1195 eprintln!("Report not found: {}", args.head.display());
1196 return Ok(2);
1197 };
1198 let Some(top) = usize::try_from(args.top).ok().filter(|count| *count > 0) else {
1199 eprintln!("--top must be greater than zero.");
1200 return Ok(2);
1201 };
1202 let payload = match compare_payload_with_force(
1203 &base_report,
1204 &head_report,
1205 Some(&base_path.to_string_lossy()),
1206 Some(&args.head.to_string_lossy()),
1207 top,
1208 args.force,
1209 ) {
1210 Ok(payload) => payload,
1211 Err(error) => return usage_error(error),
1212 };
1213 let output = match bounded_compare_output(&payload, args.detail, top, args.offset, args.limit) {
1214 Ok(output) => output,
1215 Err(error) => return usage_error(error),
1216 };
1217 let mut output = output;
1218 if let Some(materialized) = materialized.as_ref() {
1219 output["baseline_materialization"] = json!({
1220 "reference": args.base_ref,
1221 "resolved_revision": materialized.revision,
1222 "isolated_worktree": true,
1223 "copied_head_config": materialized.copied_head_config,
1224 "cache_disabled": true
1225 });
1226 }
1227 match args.format {
1228 CompareFormat::Json => print_text(&render_json(&output)?),
1229 CompareFormat::Text => print_text(&render_compare_text(&output, top)),
1230 CompareFormat::Yaml => print_text(&serde_yaml::to_string(&output)?),
1231 CompareFormat::Ndjson => print_text(&render_compare_ndjson(&output)?),
1232 }
1233 let regressions = payload
1234 .pointer("/summary/regression_count")
1235 .and_then(Value::as_u64)
1236 .unwrap_or_default();
1237 Ok(if args.fail_on_regression && regressions > 0 {
1238 1
1239 } else {
1240 0
1241 })
1242}
1243
1244fn run_report(args: ReportArgs) -> Result<i32> {
1245 match args.command {
1246 ReportCommand::Validate { path, allow_legacy } => {
1247 match report::load_report_with_legacy(&path, allow_legacy) {
1248 Ok(value) => {
1249 println!(
1250 "Report is valid: {} (schema {}).",
1251 path.display(),
1252 value["schema_version"]
1253 );
1254 Ok(0)
1255 }
1256 Err(error) => {
1257 eprintln!("{error:#}");
1258 Ok(2)
1259 }
1260 }
1261 }
1262 ReportCommand::Migrate { path, output } => {
1263 let source = fs::read_to_string(&path)
1264 .with_context(|| format!("failed to read {}", path.display()))?;
1265 let value: Value = serde_json::from_str(&source)
1266 .with_context(|| format!("invalid git-slop report JSON: {}", path.display()))?;
1267 let migrated = report::migrate_legacy_report(value)?;
1268 report::write_json_atomically(&output, &migrated)?;
1269 println!(
1270 "Migrated {} to schema 5 at {}.",
1271 path.display(),
1272 output.display()
1273 );
1274 Ok(0)
1275 }
1276 ReportCommand::Schema => {
1277 print_text(&render_json(&report::schema())?);
1278 Ok(0)
1279 }
1280 }
1281}
1282
1283fn run_sarif(repo_root: &Path, args: SarifArgs) -> Result<i32> {
1284 let (loaded, report_path) = match report_or_missing(repo_root, args.report.as_deref())? {
1285 Ok(value) => value,
1286 Err(code) => return Ok(code),
1287 };
1288 let top = match args.top {
1289 None => None,
1290 Some(value) => match usize::try_from(value).ok().filter(|count| *count > 0) {
1291 Some(value) => Some(value),
1292 None => {
1293 eprintln!("--top must be greater than zero.");
1294 return Ok(2);
1295 }
1296 },
1297 };
1298 let payload = match sarif_payload(&loaded, Some(&report_path.to_string_lossy()), top) {
1299 Ok(payload) => payload,
1300 Err(error) => return usage_error(error),
1301 };
1302 let rendered = render_json(&payload)?;
1303 if let Some(output) = args.output {
1304 if let Some(parent) = output
1305 .parent()
1306 .filter(|parent| !parent.as_os_str().is_empty())
1307 {
1308 fs::create_dir_all(parent)
1309 .with_context(|| format!("failed to create {}", parent.display()))?;
1310 }
1311 fs::write(&output, rendered)
1312 .with_context(|| format!("failed to write {}", output.display()))?;
1313 println!("Wrote SARIF report to {}.", output.display());
1314 } else {
1315 print_text(&rendered);
1316 }
1317 Ok(0)
1318}
1319
1320fn run_health(repo_root: &Path, args: HealthArgs) -> Result<i32> {
1321 let (mut loaded, _) = match report_or_missing(repo_root, args.report.as_deref())? {
1322 Ok(value) => value,
1323 Err(code) => return Ok(code),
1324 };
1325 let rollup = match health::health_rollup_from_report(&loaded) {
1326 Ok(rollup) => rollup,
1327 Err(error) => return usage_error(error),
1328 };
1329 let mut health_value = serde_json::to_value(rollup)?;
1330 if let (Some(existing), Some(derived)) = (
1331 loaded.get("health").and_then(Value::as_object),
1332 health_value.as_object_mut(),
1333 ) {
1334 for (key, value) in existing {
1335 derived.entry(key.clone()).or_insert_with(|| value.clone());
1336 }
1337 }
1338 if let Some(object) = loaded.as_object_mut() {
1339 object.insert("health".to_string(), health_value);
1340 }
1341 match args.format {
1342 HealthFormat::Text => print_text(&report::render_terminal(&loaded)),
1343 HealthFormat::Markdown => {
1344 let rendered = match health::render_health_from_report(&loaded) {
1345 Ok(rendered) => rendered,
1346 Err(error) => return usage_error(error),
1347 };
1348 print_text(&rendered);
1349 }
1350 HealthFormat::Github => {
1351 print_text(&render_github_annotations(&loaded, args.max_annotations));
1352 }
1353 HealthFormat::Json => {
1354 print_text(&render_json(&health_json_payload(&loaded))?);
1355 }
1356 }
1357 Ok(0)
1358}
1359
1360fn diff_values(current: &Value, defaults: &Value) -> Value {
1361 match (current, defaults) {
1362 (Value::Object(current), Value::Object(defaults)) => {
1363 let mut result = serde_json::Map::new();
1364 for (key, value) in current {
1365 if matches!(key.as_str(), "tokenizer" | "context_bands") {
1366 continue;
1367 }
1368 let difference = defaults
1369 .get(key)
1370 .map_or_else(|| value.clone(), |default| diff_values(value, default));
1371 if !difference.is_null()
1372 && !difference
1373 .as_object()
1374 .is_some_and(serde_json::Map::is_empty)
1375 {
1376 result.insert(key.clone(), difference);
1377 }
1378 }
1379 Value::Object(result)
1380 }
1381 _ if current == defaults => Value::Null,
1382 _ => current.clone(),
1383 }
1384}
1385
1386fn run_config(repo_root: &Path, args: ConfigArgs) -> Result<i32> {
1387 match args.command {
1388 ConfigCommand::Show { effective } => {
1389 if effective {
1390 print_text(&serde_yaml::to_string(&config::load(repo_root)?)?);
1391 } else {
1392 let path = config::config_path(repo_root);
1393 if path.exists() {
1394 print_text(&fs::read_to_string(path)?);
1395 } else {
1396 print_text(config::MINIMAL_CONFIG);
1397 }
1398 }
1399 }
1400 ConfigCommand::Validate => {
1401 config::load(repo_root)?;
1402 let path = config::config_path(repo_root);
1403 if path.exists() {
1404 println!("Configuration is valid: {}", path.display());
1405 } else {
1406 println!(
1407 "Configuration is valid: built-in defaults ({} is absent).",
1408 path.display()
1409 );
1410 }
1411 }
1412 ConfigCommand::DiffDefaults => {
1413 let diff = diff_values(&config::load(repo_root)?, &config::default_config());
1414 print_text(&serde_yaml::to_string(&diff)?);
1415 }
1416 ConfigCommand::Migrate => {
1417 let effective = config::load(repo_root)?;
1418 let mut diff = diff_values(&effective, &config::default_config());
1419 if let Some(object) = diff.as_object_mut() {
1420 object.insert("schema_version".into(), json!(2));
1421 }
1422 config::ensure_state_dirs(repo_root)?;
1423 fs::write(
1424 config::config_path(repo_root),
1425 serde_yaml::to_string(&diff)?,
1426 )?;
1427 println!(
1428 "Migrated {} to schema 2.",
1429 config::config_path(repo_root).display()
1430 );
1431 }
1432 ConfigCommand::Schema => print_text(&render_json(&config::schema())?),
1433 }
1434 Ok(0)
1435}
1436
1437fn run_doctor(repo_root: &Path, args: DoctorArgs) -> Result<i32> {
1438 let repo = git::repo_metadata(repo_root)?;
1439 let config_result = config::load(repo_root);
1440 let config_exists = config::config_path(repo_root).is_file();
1441 let report_path = default_report_path(repo_root);
1442 let report_status = if report_path.exists() {
1443 match report::load_report(&report_path) {
1444 Ok(_) => "compatible",
1445 Err(_) => "invalid",
1446 }
1447 } else {
1448 "missing"
1449 };
1450 let tracked_paths = git::list_tracked_files(repo_root)?
1451 .into_iter()
1452 .filter(|path| {
1453 args.scope
1454 .as_deref()
1455 .is_none_or(|scope| path == scope || path.starts_with(&format!("{scope}/")))
1456 })
1457 .collect::<Vec<_>>();
1458 let tracked = tracked_paths.len();
1459 let effective_config = config_result
1460 .as_ref()
1461 .cloned()
1462 .unwrap_or_else(|_| config::default_config());
1463 let estimate = crate::estimate::build(repo_root, &tracked_paths, &effective_config);
1464 let resource_status = if estimate.estimated_peak_memory_bytes > estimate.memory_budget_bytes {
1465 "over_memory_budget"
1466 } else {
1467 "within_budget"
1468 };
1469 let bundle_path = args.bundle.as_ref().map(|output| {
1470 if output.is_absolute() {
1471 output.clone()
1472 } else {
1473 repo_root.join(output)
1474 }
1475 });
1476 let diagnostic = json!({
1477 "schema_version": 1,
1478 "command": "doctor",
1479 "status": if config_result.is_err() || report_status == "invalid" || resource_status == "over_memory_budget" { "error" } else { "ready" },
1480 "repository": {"name": repo.repo_name, "branch": repo.branch, "shallow": repo.is_shallow, "detached": repo.detached_head, "clean": repo.worktree_clean},
1481 "config": {"status": if config_result.is_err() { "invalid" } else if config_exists { "valid" } else { "using_defaults" }, "path": config::config_path(repo_root)},
1482 "report": {"status": report_status, "path": report_path},
1483 "estimate": estimate,
1484 "resource_status": resource_status,
1485 "bundle_path": bundle_path,
1486 "recovery": {
1487 "config": "Run git slop config validate, then correct the reported key or run git slop config migrate.",
1488 "report": "Run git slop find to replace a missing or incompatible latest report.",
1489 "shallow": "Fetch full history or rerun find with --allow-shallow to acknowledge incomplete evidence."
1490 }
1491 });
1492 if matches!(args.format, DoctorFormat::Json) {
1493 print_text(&render_json(&diagnostic)?);
1494 } else {
1495 println!("Git Slop doctor");
1496 println!("- git: available");
1497 println!("- repository: {}", repo.repo_name);
1498 println!(
1499 "- branch: {}",
1500 repo.branch.as_deref().unwrap_or(if repo.detached_head {
1501 "detached HEAD"
1502 } else {
1503 "unborn"
1504 })
1505 );
1506 println!(
1507 "- history: {}",
1508 if repo.is_shallow {
1509 "shallow (incomplete)"
1510 } else {
1511 "complete"
1512 }
1513 );
1514 println!(
1515 "- worktree: {} (staged={}, modified={}, untracked={})",
1516 if repo.worktree_clean {
1517 "clean"
1518 } else {
1519 "dirty"
1520 },
1521 repo.staged_change_count,
1522 repo.modified_tracked_file_count,
1523 repo.untracked_file_count
1524 );
1525 println!(
1526 "- config: {}",
1527 if config_result.is_ok() {
1528 if config_exists {
1529 "valid"
1530 } else {
1531 "using built-in defaults"
1532 }
1533 } else {
1534 "invalid"
1535 }
1536 );
1537 println!("- report: {report_status}");
1538 println!(
1539 "- preflight: {tracked} tracked files; peak memory ~{} MiB; cache ~{} MiB; report ~{} MiB; time ~{}s; inodes ~{}",
1540 estimate.estimated_peak_memory_bytes.div_ceil(1024 * 1024),
1541 estimate.estimated_cache_bytes.div_ceil(1024 * 1024),
1542 estimate.estimated_report_bytes.div_ceil(1024 * 1024),
1543 estimate.estimated_seconds,
1544 estimate.estimated_inode_count,
1545 );
1546 if repo.is_shallow {
1547 println!("- recovery: fetch full history, or explicitly use --allow-shallow");
1548 }
1549 if config_result.is_err() {
1550 println!("- recovery: run `git slop config validate` and correct the reported key");
1551 }
1552 if report_status != "compatible" {
1553 println!("- recovery: run `git slop find` to produce a compatible latest report");
1554 }
1555 }
1556 if let Some(output) = bundle_path {
1557 if let Some(parent) = output.parent() {
1558 fs::create_dir_all(parent)?;
1559 }
1560 let config_digest = config_result
1561 .as_ref()
1562 .ok()
1563 .and_then(|value| serde_json::to_vec(value).ok())
1564 .map(|bytes| hex::encode(sha2::Sha256::digest(bytes)));
1565 let payload = json!({
1566 "schema_version": 1,
1567 "git_slop_version": VERSION,
1568 "repository": {"name": repo.repo_name, "shallow": repo.is_shallow, "detached": repo.detached_head, "clean": repo.worktree_clean, "staged": repo.staged_change_count, "modified": repo.modified_tracked_file_count, "untracked_count": repo.untracked_file_count},
1569 "config_digest": config_digest,
1570 "report_status": report_status,
1571 "estimate": estimate,
1572 "privacy": {"source_included": false, "raw_tokens_included": false, "absolute_paths_included": false, "author_identities_included": false, "credentials_included": false}
1573 });
1574 fs::write(&output, render_json(&payload)?)?;
1575 if matches!(args.format, DoctorFormat::Json) {
1576 eprintln!("Wrote redacted diagnostic bundle to {}.", output.display());
1577 } else {
1578 println!("Wrote redacted diagnostic bundle to {}.", output.display());
1579 }
1580 }
1581 Ok(
1582 if config_result.is_err()
1583 || report_status == "invalid"
1584 || resource_status == "over_memory_budget"
1585 {
1586 2
1587 } else {
1588 0
1589 },
1590 )
1591}
1592
1593fn matches_list_filter(item: &Value, args: &ListFilterArgs) -> bool {
1594 args.path.as_ref().is_none_or(|path| {
1595 item.get("path")
1596 .and_then(Value::as_str)
1597 .is_some_and(|value| value.starts_with(path))
1598 }) && args
1599 .profile
1600 .as_ref()
1601 .is_none_or(|value| item.get("profile").and_then(Value::as_str) == Some(value))
1602 && args
1603 .language
1604 .as_ref()
1605 .is_none_or(|value| item.get("language").and_then(Value::as_str) == Some(value))
1606 && args.classification.as_ref().is_none_or(|value| {
1607 item.get("classification")
1608 .or_else(|| item.get("class"))
1609 .and_then(Value::as_str)
1610 == Some(value)
1611 })
1612 && args
1613 .severity
1614 .as_ref()
1615 .is_none_or(|value| item.get("severity").and_then(Value::as_str) == Some(value))
1616}
1617
1618fn terminal_field(value: &str, width: usize, no_truncate: bool) -> String {
1619 let value = safe_terminal(value).replace(['\n', '\t'], " ");
1620 if no_truncate || value.chars().count() <= width {
1621 return value;
1622 }
1623 if width <= 1 {
1624 return "…".to_string();
1625 }
1626 value.chars().take(width - 1).collect::<String>() + "…"
1627}
1628
1629fn run_list(repo_root: &Path, args: ListArgs) -> Result<i32> {
1630 let filter = match &args.command {
1631 ListCommand::Findings(v)
1632 | ListCommand::Relationships(v)
1633 | ListCommand::Clusters(v)
1634 | ListCommand::Profiles(v) => v,
1635 };
1636 let (loaded, _) = match report_or_missing(repo_root, filter.report.as_deref())? {
1637 Ok(value) => value,
1638 Err(code) => return Ok(code),
1639 };
1640 let mut values = match &args.command {
1641 ListCommand::Findings(_) => loaded
1642 .pointer("/health/findings")
1643 .and_then(Value::as_array)
1644 .cloned()
1645 .unwrap_or_default(),
1646 ListCommand::Relationships(_) => loaded
1647 .pointer("/overlays/organization_health/relationships")
1648 .and_then(Value::as_object)
1649 .into_iter()
1650 .flat_map(|map| map.values())
1651 .filter_map(Value::as_array)
1652 .flatten()
1653 .cloned()
1654 .collect(),
1655 ListCommand::Clusters(_) => loaded
1656 .pointer("/overlays/organization_health/clusters")
1657 .and_then(Value::as_object)
1658 .into_iter()
1659 .flat_map(|map| map.values())
1660 .filter_map(Value::as_array)
1661 .flatten()
1662 .cloned()
1663 .collect(),
1664 ListCommand::Profiles(_) => loaded
1665 .pointer("/health/profile_rollups")
1666 .and_then(Value::as_array)
1667 .cloned()
1668 .unwrap_or_default(),
1669 };
1670 values.retain(|item| matches_list_filter(item, filter));
1671 let total = values.len();
1672 values.truncate(filter.top);
1673 let returned = values.len();
1674 match filter.format {
1675 DisplayFormat::Json => print_text(&render_json(&json!({
1676 "schema_version": 1,
1677 "command": "list",
1678 "kind": match &args.command {
1679 ListCommand::Findings(_) => "findings",
1680 ListCommand::Relationships(_) => "relationships",
1681 ListCommand::Clusters(_) => "clusters",
1682 ListCommand::Profiles(_) => "profiles",
1683 },
1684 "items": values,
1685 "collection": {"total": total, "returned": returned, "limit": filter.top, "truncated": returned < total}
1686 }))?),
1687 DisplayFormat::Yaml => print_text(&serde_yaml::to_string(&values)?),
1688 DisplayFormat::Text => {
1689 let terminal_width = std::env::var("COLUMNS")
1690 .ok()
1691 .and_then(|value| value.parse::<usize>().ok())
1692 .unwrap_or(100);
1693 let path_width = if filter.no_truncate {
1694 values
1695 .iter()
1696 .filter_map(|item| {
1697 item.get("path")
1698 .or_else(|| item.get("name"))
1699 .or_else(|| item.get("id"))
1700 .and_then(Value::as_str)
1701 })
1702 .map(str::len)
1703 .max()
1704 .unwrap_or(24)
1705 .max(24)
1706 } else if filter.wide {
1707 80
1708 } else {
1709 terminal_width.saturating_sub(59).clamp(24, 48)
1710 };
1711 println!(
1712 "{:<path_width$} {:<16} {:<10} {:<10} {:<10} {:>7}",
1713 "PATH OR NAME", "PROFILE", "SEVERITY", "CONTEXT", "SLOP", "SCORE"
1714 );
1715 println!(
1716 "{:-<path_width$} {:-<16} {:-<10} {:-<10} {:-<10} {:-<7}",
1717 "", "", "", "", "", ""
1718 );
1719 for item in &values {
1720 let label = item
1721 .get("path")
1722 .or_else(|| item.get("name"))
1723 .or_else(|| item.get("id"))
1724 .and_then(Value::as_str)
1725 .unwrap_or("-");
1726 let profile = item
1727 .get("profile")
1728 .or_else(|| item.get("kind"))
1729 .and_then(Value::as_str)
1730 .unwrap_or("-");
1731 let severity = item.get("severity").and_then(Value::as_str).unwrap_or("-");
1732 let context = item
1733 .get("context_band")
1734 .and_then(Value::as_str)
1735 .unwrap_or("-");
1736 let slop = item.get("slop_band").and_then(Value::as_str).unwrap_or("-");
1737 let score = item
1738 .get("slop_score")
1739 .or_else(|| item.get("evidence_score"))
1740 .and_then(Value::as_f64)
1741 .map_or_else(|| "-".to_string(), |value| format!("{value:.3}"));
1742 println!(
1743 "{:<path_width$} {:<16} {:<10} {:<10} {:<10} {:>7}",
1744 terminal_field(label, path_width, filter.no_truncate),
1745 terminal_field(profile, 16, filter.no_truncate),
1746 terminal_field(severity, 10, filter.no_truncate),
1747 terminal_field(context, 10, filter.no_truncate),
1748 terminal_field(slop, 10, filter.no_truncate),
1749 score
1750 );
1751 }
1752 println!(
1753 "\nReturned {returned} of {total} matching record(s).{}",
1754 if returned < total {
1755 " Increase --top to see more."
1756 } else {
1757 ""
1758 }
1759 );
1760 }
1761 }
1762 Ok(0)
1763}
1764
1765fn run_prune(repo_root: &Path, args: PruneArgs) -> Result<i32> {
1766 let loaded = config::load(repo_root).unwrap_or_else(|_| config::default_config());
1767 let keep = args
1768 .keep
1769 .unwrap_or_else(|| config::pointer_u64(&loaded, "/output/retention_runs", 20) as usize);
1770 let max_bytes = args
1771 .max_bytes
1772 .unwrap_or_else(|| config::pointer_u64(&loaded, "/output/retention_bytes", 2_147_483_648));
1773 let root = config::runs_dir(repo_root);
1774 if !root.exists() {
1775 let payload = json!({
1776 "schema_version": 1,
1777 "command": "prune",
1778 "dry_run": args.dry_run,
1779 "limits": {"max_runs": keep, "max_bytes": max_bytes},
1780 "before": {"runs": 0, "bytes": 0},
1781 "selected": [],
1782 "after": {"runs": 0, "bytes": 0}
1783 });
1784 match args.format {
1785 DisplayFormat::Text => println!("No run snapshots to prune."),
1786 DisplayFormat::Json => print_text(&render_json(&payload)?),
1787 DisplayFormat::Yaml => print_text(&serde_yaml::to_string(&payload)?),
1788 }
1789 return Ok(0);
1790 }
1791 let mut runs = fs::read_dir(&root)?
1792 .filter_map(Result::ok)
1793 .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
1794 .map(|entry| {
1795 let bytes = directory_size(&entry.path())?;
1796 Ok((entry, bytes))
1797 })
1798 .collect::<Result<Vec<_>>>()?;
1799 runs.sort_by_key(|(entry, _)| std::cmp::Reverse(entry.file_name()));
1800 let before_bytes = runs.iter().map(|(_, bytes)| *bytes).sum::<u64>();
1801 let before_runs = runs.len();
1802 let mut retained_bytes = 0u64;
1803 let mut retained_runs = 0usize;
1804 let mut remove = Vec::new();
1805 let mut retention_prefix_exhausted = false;
1806 for (entry, bytes) in runs {
1807 if !retention_prefix_exhausted
1808 && retained_runs < keep
1809 && retained_bytes.saturating_add(bytes) <= max_bytes
1810 {
1811 retained_runs += 1;
1812 retained_bytes = retained_bytes.saturating_add(bytes);
1813 } else {
1814 retention_prefix_exhausted = true;
1815 remove.push((entry, bytes));
1816 }
1817 }
1818 let selected = remove
1819 .iter()
1820 .map(|(entry, bytes)| json!({"path": entry.path(), "bytes": bytes}))
1821 .collect::<Vec<_>>();
1822 if args.format == DisplayFormat::Text {
1823 for (entry, _) in &remove {
1824 println!(
1825 "{} {}",
1826 if args.dry_run {
1827 "Would remove"
1828 } else {
1829 "Removing"
1830 },
1831 entry.path().display()
1832 );
1833 }
1834 }
1835 for (entry, _) in &remove {
1836 if !args.dry_run {
1837 fs::remove_dir_all(entry.path())?;
1838 }
1839 }
1840 let removed_bytes = remove.iter().map(|(_, bytes)| *bytes).sum::<u64>();
1841 let payload = json!({
1842 "schema_version": 1,
1843 "command": "prune",
1844 "dry_run": args.dry_run,
1845 "limits": {"max_runs": keep, "max_bytes": max_bytes},
1846 "before": {"runs": before_runs, "bytes": before_bytes},
1847 "selected": selected,
1848 "removed": {"runs": remove.len(), "bytes": removed_bytes},
1849 "after": {"runs": retained_runs, "bytes": retained_bytes, "projected": args.dry_run}
1850 });
1851 match args.format {
1852 DisplayFormat::Text => println!(
1853 "{} {} old run snapshot(s) ({} bytes); retained {} run(s) ({} bytes).",
1854 if args.dry_run { "Selected" } else { "Pruned" },
1855 remove.len(),
1856 removed_bytes,
1857 retained_runs,
1858 retained_bytes
1859 ),
1860 DisplayFormat::Json => print_text(&render_json(&payload)?),
1861 DisplayFormat::Yaml => print_text(&serde_yaml::to_string(&payload)?),
1862 }
1863 Ok(0)
1864}
1865
1866fn directory_size(path: &Path) -> Result<u64> {
1867 let mut total = 0u64;
1868 for entry in fs::read_dir(path)? {
1869 let entry = entry?;
1870 let metadata = fs::symlink_metadata(entry.path())?;
1871 if metadata.is_dir() {
1872 total = total.saturating_add(directory_size(&entry.path())?);
1873 } else {
1874 total = total.saturating_add(metadata.len());
1875 }
1876 }
1877 Ok(total)
1878}
1879
1880fn run_cache(repo_root: &Path, args: CacheArgs) -> Result<i32> {
1881 let state_root = args.state_dir.map_or_else(
1882 || config::slop_dir(repo_root),
1883 |path| {
1884 if path.is_absolute() {
1885 path
1886 } else {
1887 repo_root.join(path)
1888 }
1889 },
1890 );
1891 let (payload, format) = match args.command {
1892 CacheCommand::Status { format } => (crate::cache::status(&state_root)?, format),
1893 CacheCommand::Prune {
1894 max_entries,
1895 max_bytes,
1896 dry_run,
1897 format,
1898 } => (
1899 crate::cache::prune(&state_root, max_entries, max_bytes, dry_run)?,
1900 format,
1901 ),
1902 };
1903 match format {
1904 DisplayFormat::Json => print_text(&render_json(&payload)?),
1905 DisplayFormat::Yaml => print_text(&serde_yaml::to_string(&payload)?),
1906 DisplayFormat::Text => {
1907 if payload["command"] == "cache status" {
1908 println!(
1909 "Cache {}: {} entries, {} payload bytes, {} database bytes.",
1910 payload["status"].as_str().unwrap_or("unknown"),
1911 payload["entries"],
1912 payload["payload_bytes"],
1913 payload["database_bytes"]
1914 );
1915 } else {
1916 println!(
1917 "{} {} cache entries ({} payload bytes).",
1918 if payload["dry_run"].as_bool().unwrap_or(false) {
1919 "Would prune"
1920 } else {
1921 "Pruned"
1922 },
1923 payload["removed_entries"],
1924 payload["removed_payload_bytes"]
1925 );
1926 }
1927 }
1928 }
1929 Ok(0)
1930}
1931
1932fn run_completions(args: CompletionsArgs) -> Result<i32> {
1933 let mut command = Cli::command();
1934 let mut stdout = std::io::stdout().lock();
1935 match args.shell {
1936 CompletionShell::Bash => generate(Shell::Bash, &mut command, PROJECT_NAME, &mut stdout),
1937 CompletionShell::Zsh => generate(Shell::Zsh, &mut command, PROJECT_NAME, &mut stdout),
1938 CompletionShell::Fish => generate(Shell::Fish, &mut command, PROJECT_NAME, &mut stdout),
1939 CompletionShell::Powershell => {
1940 generate(Shell::PowerShell, &mut command, PROJECT_NAME, &mut stdout)
1941 }
1942 CompletionShell::Nushell => generate(
1943 clap_complete_nushell::Nushell,
1944 &mut command,
1945 PROJECT_NAME,
1946 &mut stdout,
1947 ),
1948 }
1949 Ok(0)
1950}
1951
1952fn write_generated_output(output: Option<&Path>, bytes: &[u8]) -> Result<()> {
1953 if let Some(path) = output {
1954 if let Some(parent) = path.parent() {
1955 fs::create_dir_all(parent)?;
1956 }
1957 fs::write(path, bytes)?;
1958 } else {
1959 use std::io::Write;
1960 std::io::stdout().lock().write_all(bytes)?;
1961 }
1962 Ok(())
1963}
1964
1965fn run_man(args: ManArgs) -> Result<i32> {
1966 let mut bytes = Vec::new();
1967 clap_mangen::Man::new(Cli::command()).render(&mut bytes)?;
1968 let rendered = String::from_utf8(bytes).context("generated manual was not UTF-8")?;
1969 let normalized = format!(
1970 "{}\n",
1971 rendered
1972 .lines()
1973 .map(str::trim_end)
1974 .collect::<Vec<_>>()
1975 .join("\n")
1976 );
1977 write_generated_output(args.output.as_deref(), normalized.as_bytes())?;
1978 Ok(0)
1979}
1980
1981fn markdown_command(command: &clap::Command, path: &str, output: &mut String) {
1982 output.push_str(&format!("## `{path}`\n\n"));
1983 if let Some(about) = command.get_about() {
1984 output.push_str(&format!("{about}\n\n"));
1985 }
1986 let arguments = command.get_arguments().collect::<Vec<_>>();
1987 if !arguments.is_empty() {
1988 output.push_str("| Argument | Description |\n| --- | --- |\n");
1989 for argument in arguments {
1990 let name = argument
1991 .get_long()
1992 .map(|value| format!("--{value}"))
1993 .unwrap_or_else(|| argument.get_id().to_string());
1994 let help = argument
1995 .get_help()
1996 .map(ToString::to_string)
1997 .unwrap_or_default();
1998 output.push_str(&format!("| `{name}` | {} |\n", help.replace('|', "\\|")));
1999 }
2000 output.push('\n');
2001 }
2002 for subcommand in command.get_subcommands() {
2003 markdown_command(
2004 subcommand,
2005 &format!("{path} {}", subcommand.get_name()),
2006 output,
2007 );
2008 }
2009}
2010
2011fn run_reference(args: ReferenceArgs) -> Result<i32> {
2012 let command = Cli::command();
2013 let mut markdown =
2014 "# Git Slop CLI Reference\n\nGenerated from the live Clap command tree.\n\n".to_string();
2015 markdown_command(&command, "git-slop", &mut markdown);
2016 let normalized = format!("{}\n", markdown.trim_end());
2017 write_generated_output(args.output.as_deref(), normalized.as_bytes())?;
2018 Ok(0)
2019}
2020
2021fn run_html(repo_root: &Path, args: HtmlArgs) -> Result<i32> {
2022 let explicit_report = args.report.clone();
2023 let (loaded, report_path) = match report_or_missing(repo_root, args.report.as_deref())? {
2024 Ok(value) => value,
2025 Err(code) => return Ok(code),
2026 };
2027 let output = args.output.unwrap_or_else(|| {
2028 explicit_report
2029 .as_ref()
2030 .and_then(|path| path.parent())
2031 .map_or_else(|| config::latest_dir(repo_root), Path::to_path_buf)
2032 .join("report.html")
2033 });
2034 if let Some(parent) = output.parent() {
2035 fs::create_dir_all(parent)?;
2036 }
2037 let payload = serde_json::to_string(&json!({
2038 "schema_version": loaded.get("schema_version"),
2039 "generated_at": loaded.get("generated_at"),
2040 "analyzed_revision_at": loaded.get("analyzed_revision_at"),
2041 "analyzer": loaded.get("analyzer"),
2042 "repo": loaded.get("repo"),
2043 "scope": loaded.get("scope"),
2044 "config_digests": loaded.pointer("/analyzer/config_digests"),
2045 "collection_metadata": loaded.get("collection_metadata"),
2046 "evidence_completeness": loaded.get("evidence_completeness"),
2047 "files": loaded.get("files"),
2048 "action_queue": loaded.get("action_queue"),
2049 "health": {
2050 "summary": loaded.pointer("/health/summary"),
2051 "findings": loaded.pointer("/health/findings")
2052 },
2053 "organization": {
2054 "relationships": loaded.pointer("/overlays/organization_health/relationships"),
2055 "clusters": loaded.pointer("/overlays/organization_health/clusters")
2056 },
2057 "source_report": report_path
2058 }))?
2059 .replace("</", "<\\/");
2060 let html = format!(
2061 r#"<!doctype html>
2062<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
2063<title>Git Slop local report</title><style>
2064:root {{ color-scheme: light dark; font: 15px system-ui,sans-serif }} body {{ margin: 2rem; max-width: 1100px }}
2065input,select {{ padding:.55rem; margin:0 .5rem .75rem 0 }} table {{ width:100%; border-collapse:collapse }}
2066th,td {{ text-align:left; padding:.5rem; border-bottom:1px solid #8885 }} th button {{ all:unset;cursor:pointer;font-weight:700 }}
2067code {{ overflow-wrap:anywhere }} details {{ margin:1rem 0 }} .muted {{ opacity:.7 }} .sr {{ position:absolute;left:-10000px }}
2068.views button[aria-pressed="true"] {{ font-weight:700;text-decoration:underline }} tr:target {{ outline:2px solid currentColor }}
2069</style></head><body><h1>Git Slop local report</h1><p id="descriptor" class="muted"></p>
2070<nav class="views" aria-label="Report view"><button data-view="files" aria-pressed="true">Files</button> <button data-view="queue" aria-pressed="false">Action queue</button> <button data-view="health" aria-pressed="false">Health findings</button></nav>
2071<label for="query" class="sr">Search paths</label><input id="query" type="search" placeholder="Search paths"><label for="profile" class="sr">Profile</label><select id="profile"><option value="">All profiles</option></select>
2072<label for="severity" class="sr">Maintenance band</label><select id="severity"><option value="">All maintenance bands</option><option>critical</option><option>high</option><option>moderate</option><option>low</option></select>
2073<p id="count" aria-live="polite"></p><button id="previous" type="button">Previous</button><button id="next" type="button">Next</button><table><caption class="sr">Git Slop records</caption><thead><tr><th scope="col"><button data-key="path">Path</button></th><th scope="col"><button data-key="profile">Profile</button></th><th scope="col"><button data-key="language">Language</button></th><th scope="col"><button data-key="slop_band">Maintenance</button></th><th scope="col"><button data-key="context_band">Context</button></th><th scope="col"><button data-key="slop_score">Score</button></th><th scope="col"><button data-key="tokens">Tokens</button></th></tr></thead><tbody id="rows"></tbody></table>
2074<details id="file-detail"><summary>Selected record details</summary><pre id="detail"></pre></details>
2075<details><summary>Relationships</summary><pre id="relationships"></pre></details>
2076<script id="report" type="application/json">{payload}</script><script>
2077const report=JSON.parse(document.getElementById('report').textContent), params=new URLSearchParams(location.search); let view=params.get('view')||'files', sortKey=params.get('sort')||'slop_score', ascending=params.get('dir')==='asc', page=Number(params.get('page')||0); const pageSize=100;
2078const files=report.files||[], queue=report.action_queue||[], findings=report.health?.findings||[]; const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({{'&':'&','<':'<','>':'>','"':'"',"'":'''}}[c]));
2079document.getElementById('descriptor').textContent=`${{report.repo?.repo_name||'repository'}} · ${{report.generated_at||'unknown time'}} · schema ${{report.schema_version}}`;
2080const profile=document.getElementById('profile'); [...new Set(files.map(f=>f.profile).filter(Boolean))].sort().forEach(v=>profile.insertAdjacentHTML('beforeend',`<option>${{esc(v)}}</option>`));
2081document.getElementById('query').value=params.get('q')||''; profile.value=params.get('profile')||''; document.getElementById('severity').value=params.get('band')||'';
2082function records() {{ return view==='queue'?queue:view==='health'?findings:files }}
2083function syncUrl() {{ const p=new URLSearchParams(); for (const [k,v] of Object.entries({{view,q:document.getElementById('query').value,profile:profile.value,band:document.getElementById('severity').value,sort:sortKey,dir:ascending?'asc':'desc',page}})) if(v!==''&&v!==0)p.set(k,v); history.replaceState(null,'',`${{location.pathname}}?${{p}}${{location.hash}}`) }}
2084function render() {{ const q=document.getElementById('query').value.toLowerCase(), p=profile.value, s=document.getElementById('severity').value, source=records();
2085 document.querySelectorAll('[data-view]').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.view===view)));
2086 const selected=source.filter(f=>(!q||String(f.path||f.id).toLowerCase().includes(q))&&(!p||f.profile===p)&&(!s||(f.slop_band||f.severity)===s)).sort((a,b)=>{{const x=a[sortKey],y=b[sortKey]; return (typeof x==='number'?x-y:String(x??'').localeCompare(String(y??'')))*(ascending?1:-1)}});
2087 const pages=Math.max(1,Math.ceil(selected.length/pageSize)); page=Math.min(page,pages-1); const visible=selected.slice(page*pageSize,(page+1)*pageSize);
2088 document.getElementById('count').textContent=`${{selected.length}} of ${{source.length}} ${{view.replace('_',' ')}} records · page ${{page+1}} of ${{pages}}`;
2089 document.getElementById('previous').disabled=page===0; document.getElementById('next').disabled=page+1>=pages;
2090 document.getElementById('rows').innerHTML=visible.map((f,i)=>`<tr id="record-${{page*pageSize+i}}" data-index="${{page*pageSize+i}}"><td><button class="record"><code>${{esc(f.path||f.id)}}</code></button></td><td>${{esc(f.profile||f.kind)}}</td><td>${{esc(f.language)}}</td><td>${{esc(f.slop_band||f.severity)}}</td><td>${{esc(f.context_band)}}</td><td>${{esc(f.slop_score||f.evidence_score)}}</td><td>${{esc(f.tokens)}}</td></tr>`).join('');
2091 document.querySelectorAll('.record').forEach((button,i)=>button.addEventListener('click',()=>{{document.getElementById('detail').textContent=JSON.stringify(visible[i],null,2);document.getElementById('file-detail').open=true;location.hash=`record-${{page*pageSize+i}}`}})); syncUrl(); }}
2092document.querySelectorAll('input,select').forEach(el=>el.addEventListener('input',()=>{{page=0;render()}})); document.querySelectorAll('th button').forEach(el=>el.addEventListener('click',()=>{{ascending=sortKey===el.dataset.key?!ascending:true;sortKey=el.dataset.key;page=0;render()}}));
2093document.querySelectorAll('[data-view]').forEach(el=>el.addEventListener('click',()=>{{view=el.dataset.view;page=0;render()}})); document.getElementById('previous').addEventListener('click',()=>{{page=Math.max(0,page-1);render()}}); document.getElementById('next').addEventListener('click',()=>{{page+=1;render()}});
2094document.getElementById('relationships').parentElement.addEventListener('toggle',e=>{{if(e.target.open&&!e.target.dataset.loaded){{e.target.dataset.loaded='true';document.getElementById('relationships').textContent=JSON.stringify(report.organization?.relationships||{{}},null,2)}}}}); render();
2095</script></body></html>"#
2096 );
2097 fs::write(&output, html)?;
2098 println!("Wrote local HTML report to {}.", output.display());
2099 Ok(0)
2100}
2101
2102fn execute(repo_root: &Path, command: Command) -> Result<i32> {
2103 match command {
2104 Command::Init(args) => run_init(repo_root, args),
2105 Command::Find(args) => run_find(repo_root, args),
2106 Command::Show(args) => run_show(repo_root, args),
2107 Command::Explain(args) => run_explain(repo_root, args),
2108 Command::Plan(args) => run_plan(repo_root, args),
2109 Command::Check(args) => run_check(repo_root, args),
2110 Command::Compare(args) => run_compare(repo_root, args),
2111 Command::Report(args) => run_report(args),
2112 Command::Sarif(args) => run_sarif(repo_root, args),
2113 Command::Health(args) => run_health(repo_root, args),
2114 Command::Config(args) => run_config(repo_root, args),
2115 Command::Doctor(args) => run_doctor(repo_root, args),
2116 Command::List(args) => run_list(repo_root, args),
2117 Command::Prune(args) => run_prune(repo_root, args),
2118 Command::Cache(args) => run_cache(repo_root, args),
2119 Command::Completions(args) => run_completions(args),
2120 Command::Man(args) => run_man(args),
2121 Command::Reference(args) => run_reference(args),
2122 Command::Html(args) => run_html(repo_root, args),
2123 Command::Version => {
2124 println!("{PROJECT_NAME} {VERSION}");
2125 Ok(0)
2126 }
2127 Command::BuildInfo(args) => {
2128 match args.format {
2129 BuildInfoFormat::Json => {
2130 println!("{}", serde_json::to_string_pretty(&build_info::current())?)
2131 }
2132 }
2133 Ok(0)
2134 }
2135 Command::Schema(args) => {
2136 let source = match args.contract {
2137 SchemaContract::Report => {
2138 return {
2139 print_text(&render_json(&report::schema())?);
2140 Ok(0)
2141 };
2142 }
2143 SchemaContract::Config => {
2144 return {
2145 print_text(&render_json(&config::schema())?);
2146 Ok(0)
2147 };
2148 }
2149 SchemaContract::Compare => include_str!("../schemas/compare-1.json"),
2150 SchemaContract::Explain => include_str!("../schemas/explain-2.json"),
2151 SchemaContract::Plan => include_str!("../schemas/plan-2.json"),
2152 SchemaContract::Sarif => include_str!("../schemas/sarif-1.json"),
2153 SchemaContract::Health => include_str!("../schemas/health-1.json"),
2154 SchemaContract::Check => include_str!("../schemas/check-1.json"),
2155 SchemaContract::Doctor => include_str!("../schemas/doctor-1.json"),
2156 SchemaContract::BuildInfo => include_str!("../schemas/build-info-1.json"),
2157 SchemaContract::List => include_str!("../schemas/list-1.json"),
2158 SchemaContract::Show => include_str!("../schemas/show-1.json"),
2159 SchemaContract::PromptManifest => {
2160 include_str!("../schemas/prompt-manifest-1.json")
2161 }
2162 };
2163 let value: Value = serde_json::from_str(source)?;
2164 print_text(&render_json(&value)?);
2165 Ok(0)
2166 }
2167 }
2168}
2169
2170fn command_requires_repository(command: &Command) -> bool {
2171 match command {
2172 Command::Completions(_)
2173 | Command::Man(_)
2174 | Command::Reference(_)
2175 | Command::Version
2176 | Command::BuildInfo(_)
2177 | Command::Schema(_)
2178 | Command::Report(_)
2179 | Command::Config(ConfigArgs {
2180 command: ConfigCommand::Schema,
2181 }) => false,
2182 Command::Show(args) => args.report.is_none(),
2183 Command::Compare(args) => args.base_ref.is_some(),
2184 Command::Explain(args) => args.report.is_none() || args.include_repository_context,
2185 Command::Plan(args) => args.report.is_none() || args.include_repository_context,
2186 Command::Check(args) => args.report.is_none(),
2187 Command::Sarif(args) => args.report.is_none(),
2188 Command::Health(args) => args.report.is_none(),
2189 Command::Html(args) => args.report.is_none(),
2190 Command::List(ListArgs {
2191 command:
2192 ListCommand::Findings(args)
2193 | ListCommand::Relationships(args)
2194 | ListCommand::Clusters(args)
2195 | ListCommand::Profiles(args),
2196 }) => args.report.is_none(),
2197 _ => true,
2198 }
2199}
2200
2201pub fn run() -> i32 {
2202 let cli = match Cli::try_parse() {
2203 Ok(cli) => cli,
2204 Err(error) => {
2205 let code = error.exit_code();
2206 let _ = error.print();
2207 return code;
2208 }
2209 };
2210 let error_format = cli.error_format;
2211 let repo_root = if command_requires_repository(&cli.command) {
2212 match git::resolve_repo_root_from(cli.repo.as_deref()) {
2213 Ok(root) => root,
2214 Err(error) => {
2215 render_runtime_error(
2216 error_format,
2217 &ClassifiedError::new(
2218 ErrorKind::Repository,
2219 "repository_not_found",
2220 format!("{error:#}"),
2221 ),
2222 );
2223 return 3;
2224 }
2225 }
2226 } else {
2227 PathBuf::new()
2228 };
2229 match execute(&repo_root, cli.command) {
2230 Ok(code) => code,
2231 Err(error) => {
2232 let classified = error.downcast_ref::<ClassifiedError>();
2233 let fallback;
2234 let classified = if let Some(classified) = classified {
2235 classified
2236 } else {
2237 fallback = if error.downcast_ref::<std::io::Error>().is_some() {
2238 ClassifiedError::new(ErrorKind::Io, "io_failure", format!("{error:#}"))
2239 } else {
2240 ClassifiedError::new(
2241 ErrorKind::Repository,
2242 "operation_failed",
2243 format!("{error:#}"),
2244 )
2245 };
2246 &fallback
2247 };
2248 render_runtime_error(error_format, classified);
2249 classified.kind.exit_code()
2250 }
2251 }
2252}
2253
2254fn render_runtime_error(format: ErrorFormat, error: &ClassifiedError) {
2255 match format {
2256 ErrorFormat::Human => eprintln!("{}", error.message),
2257 ErrorFormat::Json => eprintln!(
2258 "{}",
2259 serde_json::to_string(&json!({
2260 "schema_version": 1,
2261 "error": {
2262 "kind": error.kind,
2263 "code": error.code,
2264 "pointer": error.pointer,
2265 "message": error.message
2266 }
2267 }))
2268 .unwrap_or_else(|_| {
2269 "{\"schema_version\":1,\"error\":{\"code\":\"serialization_failed\"}}".to_string()
2270 })
2271 ),
2272 }
2273}