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