1use std::fs;
2use std::path::{Component, Path, PathBuf};
3
4use anyhow::{Context, Result};
5use clap::{ArgGroup, Args, CommandFactory, Parser, Subcommand, ValueEnum};
6use clap_complete::{Shell, generate};
7use serde_json::{Value, json};
8use sha2::Digest;
9
10use crate::build_info;
11use crate::config;
12use crate::health;
13use crate::report;
14use crate::report_ops::{
15 ExplainSelector, PlanSelector, compare_payload_with_force, explain_payload, failing_records,
16 health_json_payload, plan_payload, render_compare_text, render_explain_text,
17 render_github_annotations, render_json, render_plan_text, render_show_text, sarif_payload,
18 show_payload, write_prompt_pack,
19};
20use crate::{PROJECT_NAME, VERSION, analyze, git};
21
22#[derive(Debug, Parser)]
23#[command(
24 name = "git-slop",
25 about = "Find the files that cost too much context.",
26 version = VERSION
27)]
28struct Cli {
29 #[arg(long, global = true, value_name = "PATH")]
31 repo: Option<PathBuf>,
32 #[command(subcommand)]
33 command: Command,
34}
35
36#[derive(Debug, Subcommand)]
37enum Command {
38 Init(InitArgs),
40 Find(FindArgs),
42 Show(ShowArgs),
44 Explain(ExplainArgs),
46 Plan(PlanArgs),
48 Check(CheckArgs),
50 Compare(CompareArgs),
52 Sarif(SarifArgs),
54 Health(HealthArgs),
56 Config(ConfigArgs),
58 Doctor(DoctorArgs),
60 List(ListArgs),
62 Prune(PruneArgs),
64 Completions(CompletionsArgs),
66 Html(HtmlArgs),
68 Version,
70 BuildInfo(BuildInfoArgs),
72}
73
74#[derive(Debug, Args)]
75struct FindArgs {
76 #[arg(long)]
78 allow_shallow: bool,
79 #[arg(long)]
81 scope: Option<String>,
82 #[arg(long, visible_alias = "no-progress")]
84 quiet: bool,
85}
86
87#[derive(Debug, Args)]
88struct BuildInfoArgs {
89 #[arg(long, value_enum, default_value_t = BuildInfoFormat::Json)]
90 format: BuildInfoFormat,
91}
92
93#[derive(Debug, Args)]
94struct InitArgs {
95 #[arg(long)]
97 force: bool,
98}
99
100#[derive(Debug, Args)]
101struct ShowArgs {
102 target_path: String,
104 #[arg(long)]
106 report: Option<PathBuf>,
107 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
108 format: DisplayFormat,
109}
110
111#[derive(Debug, Args)]
112#[command(group(
113 ArgGroup::new("selector")
114 .args(["path", "cluster", "relationship", "top"])
115 .multiple(false)
116))]
117struct ExplainArgs {
118 #[arg(long)]
120 report: Option<PathBuf>,
121 #[arg(long)]
123 path: Option<String>,
124 #[arg(long)]
126 cluster: Option<String>,
127 #[arg(long)]
129 relationship: Option<String>,
130 #[arg(long)]
132 top: Option<i64>,
133 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
134 format: DisplayFormat,
135 #[arg(long)]
137 prompt_pack: Option<PathBuf>,
138}
139
140#[derive(Debug, Args)]
141#[command(group(
142 ArgGroup::new("selector")
143 .args(["path", "cluster", "relationship"])
144 .required(true)
145 .multiple(false)
146))]
147struct PlanArgs {
148 #[arg(long)]
150 report: Option<PathBuf>,
151 #[arg(long)]
153 path: Option<String>,
154 #[arg(long)]
156 cluster: Option<String>,
157 #[arg(long)]
159 relationship: Option<String>,
160 #[arg(long, default_value_t = 3)]
162 max_slices: i64,
163 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
164 format: DisplayFormat,
165 #[arg(long)]
167 prompt_pack: Option<PathBuf>,
168}
169
170#[derive(Debug, Args)]
171struct CheckArgs {
172 #[arg(long)]
174 report: Option<PathBuf>,
175 #[arg(long, value_enum)]
177 fail_on_context_band: Option<ContextBand>,
178 #[arg(long, value_enum)]
180 fail_on_slop_band: Option<SlopBand>,
181 #[arg(long, value_enum, default_value_t = CheckFormat::Text)]
182 format: CheckFormat,
183}
184
185#[derive(Debug, Args)]
186struct CompareArgs {
187 #[arg(long)]
189 base: PathBuf,
190 #[arg(long)]
192 head: PathBuf,
193 #[arg(long, default_value_t = 10)]
195 top: i64,
196 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
197 format: DisplayFormat,
198 #[arg(long)]
200 force: bool,
201 #[arg(long)]
203 fail_on_regression: bool,
204}
205
206#[derive(Debug, Args)]
207struct SarifArgs {
208 #[arg(long)]
210 report: Option<PathBuf>,
211 #[arg(long)]
213 top: Option<i64>,
214 #[arg(long)]
216 output: Option<PathBuf>,
217}
218
219#[derive(Debug, Args)]
220struct HealthArgs {
221 #[arg(long)]
223 report: Option<PathBuf>,
224 #[arg(long, value_enum, default_value_t = HealthFormat::Text)]
226 format: HealthFormat,
227 #[arg(long, default_value_t = 10)]
229 max_annotations: usize,
230}
231
232#[derive(Debug, Args)]
233struct ConfigArgs {
234 #[command(subcommand)]
235 command: ConfigCommand,
236}
237
238#[derive(Debug, Subcommand)]
239enum ConfigCommand {
240 Show {
242 #[arg(long)]
243 effective: bool,
244 },
245 Validate,
247 DiffDefaults,
249 Migrate,
251 Schema,
253}
254
255#[derive(Debug, Args)]
256struct DoctorArgs {
257 #[arg(long, num_args = 0..=1, default_missing_value = ".slop/diagnostic-bundle.json")]
259 bundle: Option<PathBuf>,
260}
261
262#[derive(Debug, Args)]
263struct ListArgs {
264 #[command(subcommand)]
265 command: ListCommand,
266}
267
268#[derive(Debug, Subcommand)]
269enum ListCommand {
270 Findings(ListFilterArgs),
271 Relationships(ListFilterArgs),
272 Clusters(ListFilterArgs),
273 Profiles(ListFilterArgs),
274}
275
276#[derive(Debug, Args)]
277struct ListFilterArgs {
278 #[arg(long)]
279 report: Option<PathBuf>,
280 #[arg(long)]
281 path: Option<String>,
282 #[arg(long)]
283 profile: Option<String>,
284 #[arg(long)]
285 language: Option<String>,
286 #[arg(long, visible_alias = "class")]
287 classification: Option<String>,
288 #[arg(long)]
289 severity: Option<String>,
290 #[arg(long, default_value_t = 50)]
291 top: usize,
292 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
293 format: DisplayFormat,
294}
295
296#[derive(Debug, Args)]
297struct PruneArgs {
298 #[arg(long)]
300 keep: Option<usize>,
301 #[arg(long)]
303 dry_run: bool,
304}
305
306#[derive(Debug, Args)]
307struct CompletionsArgs {
308 shell: CompletionShell,
309}
310
311#[derive(Debug, Args)]
312struct HtmlArgs {
313 #[arg(long)]
315 report: Option<PathBuf>,
316 #[arg(long)]
318 output: Option<PathBuf>,
319}
320
321#[derive(Debug, Clone, Copy, ValueEnum)]
322enum CompletionShell {
323 Bash,
324 Zsh,
325 Fish,
326 Powershell,
327 Nushell,
328}
329
330#[derive(Debug, Clone, Copy, ValueEnum)]
331enum DisplayFormat {
332 Text,
333 Json,
334 Yaml,
335}
336
337#[derive(Debug, Clone, Copy, ValueEnum)]
338enum HealthFormat {
339 Text,
340 Markdown,
341 Github,
342 Json,
343}
344
345#[derive(Debug, Clone, Copy, ValueEnum)]
346enum BuildInfoFormat {
347 Json,
348}
349
350#[derive(Debug, Clone, Copy, ValueEnum)]
351enum CheckFormat {
352 Text,
353 Json,
354 Github,
355}
356
357#[derive(Debug, Clone, Copy, ValueEnum)]
358enum ContextBand {
359 Compact,
360 Healthy,
361 Warning,
362 Critical,
363}
364
365impl ContextBand {
366 fn as_str(self) -> &'static str {
367 match self {
368 Self::Compact => "compact",
369 Self::Healthy => "healthy",
370 Self::Warning => "warning",
371 Self::Critical => "critical",
372 }
373 }
374}
375
376#[derive(Debug, Clone, Copy, ValueEnum)]
377enum SlopBand {
378 Low,
379 Moderate,
380 High,
381 Critical,
382}
383
384impl SlopBand {
385 fn as_str(self) -> &'static str {
386 match self {
387 Self::Low => "low",
388 Self::Moderate => "moderate",
389 Self::High => "high",
390 Self::Critical => "critical",
391 }
392 }
393}
394
395fn print_text(value: &str) {
396 if value.is_empty() {
397 return;
398 }
399 print!("{value}");
400 if !value.ends_with('\n') {
401 println!();
402 }
403}
404
405fn safe_terminal(value: &str) -> String {
406 value
407 .chars()
408 .filter(|character| *character == '\n' || *character == '\t' || !character.is_control())
409 .collect()
410}
411
412fn relative_display(path: &Path, root: &Path) -> String {
413 path.strip_prefix(root)
414 .unwrap_or(path)
415 .to_string_lossy()
416 .replace('\\', "/")
417}
418
419fn default_report_path(repo_root: &Path) -> PathBuf {
420 config::latest_dir(repo_root).join("report.json")
421}
422
423fn load_report_at(path: &Path) -> Result<Option<Value>> {
424 if !path.exists() {
425 return Ok(None);
426 }
427 report::load_report(path).map(Some)
428}
429
430fn load_default_report(
431 repo_root: &Path,
432 explicit_report: Option<&Path>,
433) -> Result<Option<(Value, PathBuf)>> {
434 let path = explicit_report
435 .map(Path::to_path_buf)
436 .unwrap_or_else(|| default_report_path(repo_root));
437 Ok(load_report_at(&path)?.map(|report| (report, path)))
438}
439
440fn report_or_missing(
441 repo_root: &Path,
442 explicit_report: Option<&Path>,
443) -> Result<Result<(Value, PathBuf), i32>> {
444 let fallback = explicit_report
445 .map(Path::to_path_buf)
446 .unwrap_or_else(|| default_report_path(repo_root));
447 let loaded = match load_default_report(repo_root, explicit_report) {
448 Ok(loaded) => loaded,
449 Err(error) => {
450 eprintln!("{error:#}");
451 return Ok(Err(2));
452 }
453 };
454 Ok(match loaded {
455 Some(loaded) => Ok(loaded),
456 None => {
457 eprintln!(
458 "Report not found: {}\nRun `git slop find` to generate it.",
459 fallback.display()
460 );
461 Err(2)
462 }
463 })
464}
465
466fn lexical_normalize(path: &Path) -> PathBuf {
467 let mut result = PathBuf::new();
468 for component in path.components() {
469 match component {
470 Component::CurDir => {}
471 Component::ParentDir => {
472 if !result.pop() {
473 result.push(component.as_os_str());
474 }
475 }
476 _ => result.push(component.as_os_str()),
477 }
478 }
479 result
480}
481
482fn selector_path(repo_root: &Path, input: &str) -> String {
483 let candidate = if Path::new(input).is_absolute() {
484 lexical_normalize(Path::new(input))
485 } else {
486 lexical_normalize(&repo_root.join(input))
487 };
488 candidate
489 .strip_prefix(repo_root)
490 .ok()
491 .map(|path| path.to_string_lossy().replace('\\', "/"))
492 .unwrap_or_else(|| {
493 let trimmed = input.trim();
494 if trimmed.is_empty() {
495 ".".to_string()
496 } else {
497 trimmed.to_string()
498 }
499 })
500}
501
502fn usage_error(error: impl std::fmt::Display) -> Result<i32> {
503 eprintln!("{error}");
504 Ok(2)
505}
506
507fn ensure_prompt_pack_target(path: &Path) -> Result<Result<(), i32>> {
508 if path.exists() && !path.is_dir() {
509 println!("Prompt pack path is not a directory: {}", path.display());
510 Ok(Err(2))
511 } else {
512 Ok(Ok(()))
513 }
514}
515
516fn run_init(repo_root: &Path, args: InitArgs) -> Result<i32> {
517 let result = config::initialize(repo_root, args.force)?;
518 println!(
519 "Initialized {} ({}).",
520 relative_display(&config::config_path(repo_root), repo_root),
521 result.config
522 );
523 println!(
524 "Initialized {} ({}).",
525 relative_display(&config::slop_dir(repo_root).join(".gitignore"), repo_root),
526 result.gitignore
527 );
528 println!("Ensured .slop/latest/, .slop/runs/, and .slop/cache/ exist.");
529 Ok(0)
530}
531
532fn run_find(repo_root: &Path, args: FindArgs) -> Result<i32> {
533 let result = analyze::run_find_scoped(
534 repo_root,
535 args.allow_shallow,
536 args.scope.as_deref(),
537 !args.quiet,
538 )?;
539 if args.quiet {
540 return Ok(0);
541 }
542 print_text(&result.terminal);
543 println!("Wrote report to {}.", result.report_json.display());
544 println!("Wrote YAML report to {}.", result.report_yaml.display());
545 println!("Wrote summary to {}.", result.summary_md.display());
546 println!(
547 "Wrote repository health summary to {}.",
548 result.health_md.display()
549 );
550 Ok(0)
551}
552
553fn run_show(repo_root: &Path, args: ShowArgs) -> Result<i32> {
554 let (loaded, report_path) = match report_or_missing(repo_root, args.report.as_deref())? {
555 Ok(value) => value,
556 Err(code) => return Ok(code),
557 };
558 let target = selector_path(repo_root, &args.target_path);
559 let Some(payload) = show_payload(&loaded, &target) else {
560 eprintln!(
561 "No record found for '{}' in {}.",
562 args.target_path,
563 report_path.display()
564 );
565 return Ok(2);
566 };
567 match args.format {
568 DisplayFormat::Json => print_text(&render_json(&payload)?),
569 DisplayFormat::Text => print_text(&render_show_text(&payload)),
570 DisplayFormat::Yaml => print_text(&serde_yaml::to_string(&payload)?),
571 }
572 Ok(0)
573}
574
575fn explain_selector(args: &ExplainArgs, repo_root: &Path) -> Result<ExplainSelector, i32> {
576 if let Some(path) = &args.path {
577 Ok(ExplainSelector::Path(selector_path(repo_root, path)))
578 } else if let Some(id) = &args.cluster {
579 Ok(ExplainSelector::Cluster(id.clone()))
580 } else if let Some(id) = &args.relationship {
581 Ok(ExplainSelector::Relationship(id.clone()))
582 } else {
583 let count = args.top.unwrap_or(5);
584 match usize::try_from(count).ok().filter(|count| *count > 0) {
585 Some(count) => Ok(ExplainSelector::Top(count)),
586 None => Err(2),
587 }
588 }
589}
590
591fn run_explain(repo_root: &Path, args: ExplainArgs) -> Result<i32> {
592 let (loaded, _) = match report_or_missing(repo_root, args.report.as_deref())? {
593 Ok(value) => value,
594 Err(code) => return Ok(code),
595 };
596 let selector = match explain_selector(&args, repo_root) {
597 Ok(selector) => selector,
598 Err(code) => {
599 eprintln!("--top must be greater than zero.");
600 return Ok(code);
601 }
602 };
603 let payload = match explain_payload(&loaded, Some(selector)) {
604 Ok(payload) => payload,
605 Err(error) => return usage_error(error),
606 };
607 if let Some(output_dir) = args.prompt_pack.as_deref() {
608 if let Err(code) = ensure_prompt_pack_target(output_dir)? {
609 return Ok(code);
610 }
611 write_prompt_pack("explain", &payload, &loaded, output_dir)?;
612 }
613 match args.format {
614 DisplayFormat::Json => print_text(&render_json(&payload)?),
615 DisplayFormat::Text => print_text(&render_explain_text(&payload)),
616 DisplayFormat::Yaml => print_text(&serde_yaml::to_string(&payload)?),
617 }
618 Ok(0)
619}
620
621fn plan_selector(args: &PlanArgs, repo_root: &Path) -> PlanSelector {
622 if let Some(path) = &args.path {
623 PlanSelector::Path(selector_path(repo_root, path))
624 } else if let Some(id) = &args.cluster {
625 PlanSelector::Cluster(id.clone())
626 } else {
627 PlanSelector::Relationship(args.relationship.clone().unwrap_or_default())
628 }
629}
630
631fn run_plan(repo_root: &Path, args: PlanArgs) -> Result<i32> {
632 let (loaded, _) = match report_or_missing(repo_root, args.report.as_deref())? {
633 Ok(value) => value,
634 Err(code) => return Ok(code),
635 };
636 let Some(max_slices) = usize::try_from(args.max_slices)
637 .ok()
638 .filter(|count| *count > 0)
639 else {
640 eprintln!("--max-slices must be greater than zero.");
641 return Ok(2);
642 };
643 let payload = match plan_payload(&loaded, plan_selector(&args, repo_root), max_slices) {
644 Ok(payload) => payload,
645 Err(error) => return usage_error(error),
646 };
647 if let Some(output_dir) = args.prompt_pack.as_deref() {
648 if let Err(code) = ensure_prompt_pack_target(output_dir)? {
649 return Ok(code);
650 }
651 write_prompt_pack("plan", &payload, &loaded, output_dir)?;
652 }
653 match args.format {
654 DisplayFormat::Json => print_text(&render_json(&payload)?),
655 DisplayFormat::Text => print_text(&render_plan_text(&payload)),
656 DisplayFormat::Yaml => print_text(&serde_yaml::to_string(&payload)?),
657 }
658 Ok(0)
659}
660
661fn run_check(repo_root: &Path, args: CheckArgs) -> Result<i32> {
662 let (loaded, _) = match report_or_missing(repo_root, args.report.as_deref())? {
663 Ok(value) => value,
664 Err(code) => return Ok(code),
665 };
666 let loaded_config = loaded
667 .get("config")
668 .cloned()
669 .unwrap_or_else(config::default_config);
670 let context_band = args
671 .fail_on_context_band
672 .map(ContextBand::as_str)
673 .or_else(|| {
674 loaded_config
675 .pointer("/check/fail_on_context_band")
676 .and_then(Value::as_str)
677 })
678 .unwrap_or("critical");
679 let slop_band = args
680 .fail_on_slop_band
681 .map(SlopBand::as_str)
682 .or_else(|| {
683 loaded_config
684 .pointer("/check/fail_on_slop_band")
685 .and_then(Value::as_str)
686 })
687 .unwrap_or("critical");
688 let failures = failing_records(&loaded, Some(context_band), Some(slop_band));
689 if !matches!(args.format, CheckFormat::Text) {
690 match args.format {
691 CheckFormat::Json => print_text(&render_json(&json!({
692 "schema_version": 1,
693 "command": "check",
694 "report": {"schema_version": loaded.get("schema_version"), "analyzer": loaded.get("analyzer"), "repo": loaded.get("repo")},
695 "boundary": {"context_band": context_band, "slop_band": slop_band},
696 "passed": failures.is_empty(),
697 "finding_count": failures.len(),
698 "findings": failures
699 }))?),
700 CheckFormat::Github => {
701 for failure in &failures {
702 let path = safe_terminal(
703 failure
704 .get("path")
705 .and_then(Value::as_str)
706 .unwrap_or_default(),
707 );
708 println!(
709 "::error file={}::Git Slop context={} slop={} score={}",
710 path.replace('%', "%25").replace(',', "%2C"),
711 failure
712 .get("context_band")
713 .and_then(Value::as_str)
714 .unwrap_or_default(),
715 failure
716 .get("slop_band")
717 .and_then(Value::as_str)
718 .unwrap_or_default(),
719 failure.get("slop_score").unwrap_or(&Value::Null)
720 );
721 }
722 }
723 CheckFormat::Text => {}
724 }
725 return Ok(if failures.is_empty() { 0 } else { 1 });
726 }
727 if failures.is_empty() {
728 println!(
729 "Check passed: no file records met or exceeded context={context_band} or slop={slop_band}."
730 );
731 return Ok(0);
732 }
733 println!(
734 "Check failed: {} file records met or exceeded context={context_band} or slop={slop_band}.",
735 failures.len()
736 );
737 for failure in failures.iter().take(10) {
738 println!(
739 "- {} (slop={}, context={}, slop_score={})",
740 failure
741 .get("path")
742 .and_then(Value::as_str)
743 .unwrap_or_default(),
744 failure
745 .get("slop_band")
746 .and_then(Value::as_str)
747 .unwrap_or_default(),
748 failure
749 .get("context_band")
750 .and_then(Value::as_str)
751 .unwrap_or_default(),
752 failure
753 .get("slop_score")
754 .map(ToString::to_string)
755 .unwrap_or_else(|| "null".to_string()),
756 );
757 }
758 Ok(1)
759}
760
761fn run_compare(args: CompareArgs) -> Result<i32> {
762 let Some(base_report) = (match load_report_at(&args.base) {
763 Ok(report) => report,
764 Err(error) => {
765 eprintln!("{error:#}");
766 return Ok(2);
767 }
768 }) else {
769 eprintln!("Report not found: {}", args.base.display());
770 return Ok(2);
771 };
772 let Some(head_report) = (match load_report_at(&args.head) {
773 Ok(report) => report,
774 Err(error) => {
775 eprintln!("{error:#}");
776 return Ok(2);
777 }
778 }) else {
779 eprintln!("Report not found: {}", args.head.display());
780 return Ok(2);
781 };
782 let Some(top) = usize::try_from(args.top).ok().filter(|count| *count > 0) else {
783 eprintln!("--top must be greater than zero.");
784 return Ok(2);
785 };
786 let payload = match compare_payload_with_force(
787 &base_report,
788 &head_report,
789 Some(&args.base.to_string_lossy()),
790 Some(&args.head.to_string_lossy()),
791 top,
792 args.force,
793 ) {
794 Ok(payload) => payload,
795 Err(error) => return usage_error(error),
796 };
797 match args.format {
798 DisplayFormat::Json => print_text(&render_json(&payload)?),
799 DisplayFormat::Text => print_text(&render_compare_text(&payload, top)),
800 DisplayFormat::Yaml => print_text(&serde_yaml::to_string(&payload)?),
801 }
802 let regressions = payload
803 .pointer("/summary/worsened_file_count")
804 .and_then(Value::as_u64)
805 .unwrap_or_default()
806 + payload
807 .pointer("/summary/files/added")
808 .and_then(Value::as_u64)
809 .unwrap_or_default();
810 Ok(if args.fail_on_regression && regressions > 0 {
811 1
812 } else {
813 0
814 })
815}
816
817fn run_sarif(repo_root: &Path, args: SarifArgs) -> Result<i32> {
818 let (loaded, report_path) = match report_or_missing(repo_root, args.report.as_deref())? {
819 Ok(value) => value,
820 Err(code) => return Ok(code),
821 };
822 let top = match args.top {
823 None => None,
824 Some(value) => match usize::try_from(value).ok().filter(|count| *count > 0) {
825 Some(value) => Some(value),
826 None => {
827 eprintln!("--top must be greater than zero.");
828 return Ok(2);
829 }
830 },
831 };
832 let payload = match sarif_payload(&loaded, Some(&report_path.to_string_lossy()), top) {
833 Ok(payload) => payload,
834 Err(error) => return usage_error(error),
835 };
836 let rendered = render_json(&payload)?;
837 if let Some(output) = args.output {
838 if let Some(parent) = output
839 .parent()
840 .filter(|parent| !parent.as_os_str().is_empty())
841 {
842 fs::create_dir_all(parent)
843 .with_context(|| format!("failed to create {}", parent.display()))?;
844 }
845 fs::write(&output, rendered)
846 .with_context(|| format!("failed to write {}", output.display()))?;
847 println!("Wrote SARIF report to {}.", output.display());
848 } else {
849 print_text(&rendered);
850 }
851 Ok(0)
852}
853
854fn run_health(repo_root: &Path, args: HealthArgs) -> Result<i32> {
855 let (mut loaded, _) = match report_or_missing(repo_root, args.report.as_deref())? {
856 Ok(value) => value,
857 Err(code) => return Ok(code),
858 };
859 let rollup = match health::health_rollup_from_report(&loaded) {
860 Ok(rollup) => rollup,
861 Err(error) => return usage_error(error),
862 };
863 let mut health_value = serde_json::to_value(rollup)?;
864 if let (Some(existing), Some(derived)) = (
865 loaded.get("health").and_then(Value::as_object),
866 health_value.as_object_mut(),
867 ) {
868 for (key, value) in existing {
869 derived.entry(key.clone()).or_insert_with(|| value.clone());
870 }
871 }
872 if let Some(object) = loaded.as_object_mut() {
873 object.insert("health".to_string(), health_value);
874 }
875 match args.format {
876 HealthFormat::Text => print_text(&report::render_terminal(&loaded)),
877 HealthFormat::Markdown => {
878 let rendered = match health::render_health_from_report(&loaded) {
879 Ok(rendered) => rendered,
880 Err(error) => return usage_error(error),
881 };
882 print_text(&rendered);
883 }
884 HealthFormat::Github => {
885 print_text(&render_github_annotations(&loaded, args.max_annotations));
886 }
887 HealthFormat::Json => {
888 print_text(&render_json(&health_json_payload(&loaded))?);
889 }
890 }
891 Ok(0)
892}
893
894fn diff_values(current: &Value, defaults: &Value) -> Value {
895 match (current, defaults) {
896 (Value::Object(current), Value::Object(defaults)) => {
897 let mut result = serde_json::Map::new();
898 for (key, value) in current {
899 if matches!(key.as_str(), "tokenizer" | "context_bands") {
900 continue;
901 }
902 let difference = defaults
903 .get(key)
904 .map_or_else(|| value.clone(), |default| diff_values(value, default));
905 if !difference.is_null()
906 && !difference
907 .as_object()
908 .is_some_and(serde_json::Map::is_empty)
909 {
910 result.insert(key.clone(), difference);
911 }
912 }
913 Value::Object(result)
914 }
915 _ if current == defaults => Value::Null,
916 _ => current.clone(),
917 }
918}
919
920fn run_config(repo_root: &Path, args: ConfigArgs) -> Result<i32> {
921 match args.command {
922 ConfigCommand::Show { effective } => {
923 if effective {
924 print_text(&serde_yaml::to_string(&config::load(repo_root)?)?);
925 } else {
926 let path = config::config_path(repo_root);
927 if path.exists() {
928 print_text(&fs::read_to_string(path)?);
929 } else {
930 print_text(config::MINIMAL_CONFIG);
931 }
932 }
933 }
934 ConfigCommand::Validate => {
935 config::load(repo_root)?;
936 println!(
937 "Configuration is valid: {}",
938 config::config_path(repo_root).display()
939 );
940 }
941 ConfigCommand::DiffDefaults => {
942 let diff = diff_values(&config::load(repo_root)?, &config::default_config());
943 print_text(&serde_yaml::to_string(&diff)?);
944 }
945 ConfigCommand::Migrate => {
946 let effective = config::load(repo_root)?;
947 let mut diff = diff_values(&effective, &config::default_config());
948 if let Some(object) = diff.as_object_mut() {
949 object.insert("schema_version".into(), json!(2));
950 }
951 config::ensure_state_dirs(repo_root)?;
952 fs::write(
953 config::config_path(repo_root),
954 serde_yaml::to_string(&diff)?,
955 )?;
956 println!(
957 "Migrated {} to schema 2.",
958 config::config_path(repo_root).display()
959 );
960 }
961 ConfigCommand::Schema => print_text(&render_json(&json!({
962 "$schema": "https://json-schema.org/draft/2020-12/schema",
963 "title": "Git Slop configuration schema 2",
964 "type": "object",
965 "additionalProperties": false,
966 "default": config::default_config()
967 }))?),
968 }
969 Ok(0)
970}
971
972fn run_doctor(repo_root: &Path, args: DoctorArgs) -> Result<i32> {
973 let repo = git::repo_metadata(repo_root)?;
974 let config_result = config::load(repo_root);
975 let report_path = default_report_path(repo_root);
976 let report_status = if report_path.exists() {
977 match report::load_report(&report_path) {
978 Ok(_) => "compatible",
979 Err(_) => "invalid",
980 }
981 } else {
982 "missing"
983 };
984 let tracked = git::list_tracked_files(repo_root)?.len();
985 let estimated_mb = tracked.saturating_mul(24).div_ceil(1024).max(1);
986 println!("Git Slop doctor");
987 println!("- git: available");
988 println!("- repository: {}", repo.repo_name);
989 println!(
990 "- branch: {}",
991 repo.branch.as_deref().unwrap_or(if repo.detached_head {
992 "detached HEAD"
993 } else {
994 "unborn"
995 })
996 );
997 println!(
998 "- history: {}",
999 if repo.is_shallow {
1000 "shallow (incomplete)"
1001 } else {
1002 "complete"
1003 }
1004 );
1005 println!(
1006 "- worktree: {} (staged={}, modified={}, untracked={})",
1007 if repo.worktree_clean {
1008 "clean"
1009 } else {
1010 "dirty"
1011 },
1012 repo.staged_change_count,
1013 repo.modified_tracked_file_count,
1014 repo.untracked_file_count
1015 );
1016 println!(
1017 "- config: {}",
1018 if config_result.is_ok() {
1019 "valid"
1020 } else {
1021 "invalid"
1022 }
1023 );
1024 println!("- report: {report_status}");
1025 println!("- preflight: {tracked} tracked files; estimated analysis floor {estimated_mb} MiB");
1026 if let Some(output) = args.bundle {
1027 let output = if output.is_absolute() {
1028 output
1029 } else {
1030 repo_root.join(output)
1031 };
1032 if let Some(parent) = output.parent() {
1033 fs::create_dir_all(parent)?;
1034 }
1035 let config_digest = config_result
1036 .as_ref()
1037 .ok()
1038 .and_then(|value| serde_json::to_vec(value).ok())
1039 .map(|bytes| hex::encode(sha2::Sha256::digest(bytes)));
1040 let payload = json!({
1041 "schema_version": 1,
1042 "git_slop_version": VERSION,
1043 "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},
1044 "config_digest": config_digest,
1045 "report_status": report_status,
1046 "metrics": {"tracked_file_count": tracked, "estimated_memory_floor_mb": estimated_mb},
1047 "privacy": {"source_included": false, "raw_tokens_included": false, "absolute_paths_included": false, "author_identities_included": false, "credentials_included": false}
1048 });
1049 fs::write(&output, render_json(&payload)?)?;
1050 println!("Wrote redacted diagnostic bundle to {}.", output.display());
1051 }
1052 Ok(if config_result.is_err() || report_status == "invalid" {
1053 2
1054 } else {
1055 0
1056 })
1057}
1058
1059fn matches_list_filter(item: &Value, args: &ListFilterArgs) -> bool {
1060 args.path.as_ref().is_none_or(|path| {
1061 item.get("path")
1062 .and_then(Value::as_str)
1063 .is_some_and(|value| value.starts_with(path))
1064 }) && args
1065 .profile
1066 .as_ref()
1067 .is_none_or(|value| item.get("profile").and_then(Value::as_str) == Some(value))
1068 && args
1069 .language
1070 .as_ref()
1071 .is_none_or(|value| item.get("language").and_then(Value::as_str) == Some(value))
1072 && args.classification.as_ref().is_none_or(|value| {
1073 item.get("classification")
1074 .or_else(|| item.get("class"))
1075 .and_then(Value::as_str)
1076 == Some(value)
1077 })
1078 && args
1079 .severity
1080 .as_ref()
1081 .is_none_or(|value| item.get("severity").and_then(Value::as_str) == Some(value))
1082}
1083
1084fn run_list(repo_root: &Path, args: ListArgs) -> Result<i32> {
1085 let filter = match &args.command {
1086 ListCommand::Findings(v)
1087 | ListCommand::Relationships(v)
1088 | ListCommand::Clusters(v)
1089 | ListCommand::Profiles(v) => v,
1090 };
1091 let (loaded, _) = match report_or_missing(repo_root, filter.report.as_deref())? {
1092 Ok(value) => value,
1093 Err(code) => return Ok(code),
1094 };
1095 let mut values = match &args.command {
1096 ListCommand::Findings(_) => loaded
1097 .pointer("/health/findings")
1098 .and_then(Value::as_array)
1099 .cloned()
1100 .unwrap_or_default(),
1101 ListCommand::Relationships(_) => loaded
1102 .get("relationships")
1103 .and_then(Value::as_object)
1104 .into_iter()
1105 .flat_map(|map| map.values())
1106 .filter_map(Value::as_array)
1107 .flatten()
1108 .cloned()
1109 .collect(),
1110 ListCommand::Clusters(_) => loaded
1111 .get("clusters")
1112 .and_then(Value::as_object)
1113 .into_iter()
1114 .flat_map(|map| map.values())
1115 .filter_map(Value::as_array)
1116 .flatten()
1117 .cloned()
1118 .collect(),
1119 ListCommand::Profiles(_) => loaded
1120 .pointer("/health/profile_rollups")
1121 .and_then(Value::as_array)
1122 .cloned()
1123 .unwrap_or_default(),
1124 };
1125 values.retain(|item| matches_list_filter(item, filter));
1126 values.truncate(filter.top);
1127 match filter.format {
1128 DisplayFormat::Json => print_text(&render_json(&Value::Array(values))?),
1129 DisplayFormat::Yaml => print_text(&serde_yaml::to_string(&values)?),
1130 DisplayFormat::Text => {
1131 for item in values {
1132 println!("{}", serde_json::to_string(&item)?);
1133 }
1134 }
1135 }
1136 Ok(0)
1137}
1138
1139fn run_prune(repo_root: &Path, args: PruneArgs) -> Result<i32> {
1140 let keep = args.keep.unwrap_or_else(|| {
1141 config::pointer_u64(
1142 &config::load(repo_root).unwrap_or_else(|_| config::default_config()),
1143 "/output/retention_runs",
1144 20,
1145 ) as usize
1146 });
1147 let root = config::runs_dir(repo_root);
1148 if !root.exists() {
1149 println!("No run snapshots to prune.");
1150 return Ok(0);
1151 }
1152 let mut runs = fs::read_dir(&root)?
1153 .filter_map(Result::ok)
1154 .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
1155 .collect::<Vec<_>>();
1156 runs.sort_by_key(|entry| std::cmp::Reverse(entry.file_name()));
1157 let remove = runs.into_iter().skip(keep).collect::<Vec<_>>();
1158 for entry in &remove {
1159 println!(
1160 "{} {}",
1161 if args.dry_run {
1162 "Would remove"
1163 } else {
1164 "Removing"
1165 },
1166 entry.path().display()
1167 );
1168 if !args.dry_run {
1169 fs::remove_dir_all(entry.path())?;
1170 }
1171 }
1172 println!(
1173 "{} {} old run snapshot(s); kept {keep}.",
1174 if args.dry_run { "Selected" } else { "Pruned" },
1175 remove.len()
1176 );
1177 Ok(0)
1178}
1179
1180fn run_completions(args: CompletionsArgs) -> Result<i32> {
1181 let mut command = Cli::command();
1182 let mut stdout = std::io::stdout().lock();
1183 match args.shell {
1184 CompletionShell::Bash => generate(Shell::Bash, &mut command, PROJECT_NAME, &mut stdout),
1185 CompletionShell::Zsh => generate(Shell::Zsh, &mut command, PROJECT_NAME, &mut stdout),
1186 CompletionShell::Fish => generate(Shell::Fish, &mut command, PROJECT_NAME, &mut stdout),
1187 CompletionShell::Powershell => {
1188 generate(Shell::PowerShell, &mut command, PROJECT_NAME, &mut stdout)
1189 }
1190 CompletionShell::Nushell => generate(
1191 clap_complete_nushell::Nushell,
1192 &mut command,
1193 PROJECT_NAME,
1194 &mut stdout,
1195 ),
1196 }
1197 Ok(0)
1198}
1199
1200fn run_html(repo_root: &Path, args: HtmlArgs) -> Result<i32> {
1201 let (loaded, _) = match report_or_missing(repo_root, args.report.as_deref())? {
1202 Ok(value) => value,
1203 Err(code) => return Ok(code),
1204 };
1205 let output = args
1206 .output
1207 .unwrap_or_else(|| config::latest_dir(repo_root).join("report.html"));
1208 if let Some(parent) = output.parent() {
1209 fs::create_dir_all(parent)?;
1210 }
1211 let payload = serde_json::to_string(&loaded)?.replace("</", "<\\/");
1212 let html = format!(
1213 r#"<!doctype html>
1214<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
1215<title>Git Slop local report</title><style>
1216:root {{ color-scheme: light dark; font: 15px system-ui,sans-serif }} body {{ margin: 2rem; max-width: 1100px }}
1217input,select {{ padding:.55rem; margin:0 .5rem .75rem 0 }} table {{ width:100%; border-collapse:collapse }}
1218th,td {{ text-align:left; padding:.5rem; border-bottom:1px solid #8885 }} th {{ cursor:pointer }}
1219code {{ overflow-wrap:anywhere }} details {{ margin:1rem 0 }} .muted {{ opacity:.7 }}
1220</style></head><body><h1>Git Slop local report</h1><p id="descriptor" class="muted"></p>
1221<input id="query" type="search" placeholder="Search paths"><select id="profile"><option value="">All profiles</option></select>
1222<select id="severity"><option value="">All maintenance bands</option><option>critical</option><option>high</option><option>moderate</option><option>low</option></select>
1223<p id="count"></p><table><thead><tr><th data-key="path">Path</th><th data-key="profile">Profile</th><th data-key="language">Language</th><th data-key="slop_band">Maintenance</th><th data-key="context_band">Context</th><th data-key="slop_score">Score</th><th data-key="tokens">Tokens</th></tr></thead><tbody id="rows"></tbody></table>
1224<details><summary>Relationships</summary><pre id="relationships"></pre></details>
1225<script id="report" type="application/json">{payload}</script><script>
1226const report=JSON.parse(document.getElementById('report').textContent); let sortKey='slop_score', ascending=false;
1227const files=report.files||[]; const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({{'&':'&','<':'<','>':'>','"':'"',"'":'''}}[c]));
1228document.getElementById('descriptor').textContent=`${{report.repo?.repo_name||'repository'}} · ${{report.generated_at||'unknown time'}} · schema ${{report.schema_version}}`;
1229const profile=document.getElementById('profile'); [...new Set(files.map(f=>f.profile).filter(Boolean))].sort().forEach(v=>profile.insertAdjacentHTML('beforeend',`<option>${{esc(v)}}</option>`));
1230function render() {{ const q=document.getElementById('query').value.toLowerCase(), p=profile.value, s=document.getElementById('severity').value;
1231 const selected=files.filter(f=>(!q||String(f.path).toLowerCase().includes(q))&&(!p||f.profile===p)&&(!s||f.slop_band===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)}});
1232 document.getElementById('count').textContent=`${{selected.length}} of ${{files.length}} files`;
1233 document.getElementById('rows').innerHTML=selected.map(f=>`<tr><td><code>${{esc(f.path)}}</code></td><td>${{esc(f.profile)}}</td><td>${{esc(f.language)}}</td><td>${{esc(f.slop_band)}}</td><td>${{esc(f.context_band)}}</td><td>${{esc(f.slop_score)}}</td><td>${{esc(f.tokens)}}</td></tr>`).join(''); }}
1234document.querySelectorAll('input,select').forEach(el=>el.addEventListener('input',render)); document.querySelectorAll('th').forEach(el=>el.addEventListener('click',()=>{{ascending=sortKey===el.dataset.key?!ascending:false;sortKey=el.dataset.key;render()}}));
1235const rel=report.relationships||report.overlays?.organization_health?.relationships||{{}}; document.getElementById('relationships').textContent=JSON.stringify(rel,null,2); render();
1236</script></body></html>"#
1237 );
1238 fs::write(&output, html)?;
1239 println!("Wrote local HTML report to {}.", output.display());
1240 Ok(0)
1241}
1242
1243fn execute(repo_root: &Path, command: Command) -> Result<i32> {
1244 match command {
1245 Command::Init(args) => run_init(repo_root, args),
1246 Command::Find(args) => run_find(repo_root, args),
1247 Command::Show(args) => run_show(repo_root, args),
1248 Command::Explain(args) => run_explain(repo_root, args),
1249 Command::Plan(args) => run_plan(repo_root, args),
1250 Command::Check(args) => run_check(repo_root, args),
1251 Command::Compare(args) => run_compare(args),
1252 Command::Sarif(args) => run_sarif(repo_root, args),
1253 Command::Health(args) => run_health(repo_root, args),
1254 Command::Config(args) => run_config(repo_root, args),
1255 Command::Doctor(args) => run_doctor(repo_root, args),
1256 Command::List(args) => run_list(repo_root, args),
1257 Command::Prune(args) => run_prune(repo_root, args),
1258 Command::Completions(args) => run_completions(args),
1259 Command::Html(args) => run_html(repo_root, args),
1260 Command::Version => {
1261 println!("{PROJECT_NAME} {VERSION}");
1262 Ok(0)
1263 }
1264 Command::BuildInfo(args) => {
1265 match args.format {
1266 BuildInfoFormat::Json => {
1267 println!("{}", serde_json::to_string_pretty(&build_info::current())?)
1268 }
1269 }
1270 Ok(0)
1271 }
1272 }
1273}
1274
1275fn command_requires_repository(command: &Command) -> bool {
1276 !matches!(
1277 command,
1278 Command::Completions(_) | Command::Version | Command::BuildInfo(_)
1279 )
1280}
1281
1282pub fn run() -> i32 {
1283 let cli = match Cli::try_parse() {
1284 Ok(cli) => cli,
1285 Err(error) => {
1286 let code = error.exit_code();
1287 let _ = error.print();
1288 return code;
1289 }
1290 };
1291 let repo_root = if command_requires_repository(&cli.command) {
1292 match git::resolve_repo_root_from(cli.repo.as_deref()) {
1293 Ok(root) => root,
1294 Err(error) => {
1295 eprintln!("{error:#}");
1296 return 3;
1297 }
1298 }
1299 } else {
1300 PathBuf::new()
1301 };
1302 match execute(&repo_root, cli.command) {
1303 Ok(code) => code,
1304 Err(error) => {
1305 let rendered = format!("{error:#}");
1306 eprintln!("{rendered}");
1307 if rendered.contains("memory_budget_mb") || rendered.contains("analysis bounded") {
1308 4
1309 } else if rendered.contains("config")
1310 || rendered.contains("schema")
1311 || rendered.contains("unsupported tokenization")
1312 {
1313 2
1314 } else {
1315 3
1316 }
1317 }
1318 }
1319}