1use std::fs;
2use std::path::{Component, Path, PathBuf};
3
4use anyhow::{Context, Result};
5use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum};
6use serde_json::Value;
7
8use crate::build_info;
9use crate::config;
10use crate::health;
11use crate::report;
12use crate::report_ops::{
13 ExplainSelector, PlanSelector, compare_payload, explain_payload, failing_records,
14 health_json_payload, plan_payload, render_compare_text, render_explain_text,
15 render_github_annotations, render_json, render_plan_text, sarif_payload, show_payload,
16 write_prompt_pack,
17};
18use crate::{PROJECT_NAME, VERSION, analyze, git};
19
20#[derive(Debug, Parser)]
21#[command(
22 name = "git-slop",
23 about = "Find the files that cost too much context.",
24 disable_version_flag = true
25)]
26struct Cli {
27 #[command(subcommand)]
28 command: Command,
29}
30
31#[derive(Debug, Subcommand)]
32enum Command {
33 Init(InitArgs),
35 Find,
37 Show(ShowArgs),
39 Explain(ExplainArgs),
41 Plan(PlanArgs),
43 Check(CheckArgs),
45 Compare(CompareArgs),
47 Sarif(SarifArgs),
49 Health(HealthArgs),
51 Version,
53 BuildInfo(BuildInfoArgs),
55}
56
57#[derive(Debug, Args)]
58struct BuildInfoArgs {
59 #[arg(long, value_enum, default_value_t = BuildInfoFormat::Json)]
60 format: BuildInfoFormat,
61}
62
63#[derive(Debug, Args)]
64struct InitArgs {
65 #[arg(long)]
67 force: bool,
68}
69
70#[derive(Debug, Args)]
71struct ShowArgs {
72 target_path: String,
74 #[arg(long)]
76 report: Option<PathBuf>,
77 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
78 format: DisplayFormat,
79}
80
81#[derive(Debug, Args)]
82#[command(group(
83 ArgGroup::new("selector")
84 .args(["path", "cluster", "relationship", "top"])
85 .multiple(false)
86))]
87struct ExplainArgs {
88 #[arg(long)]
90 report: Option<PathBuf>,
91 #[arg(long)]
93 path: Option<String>,
94 #[arg(long)]
96 cluster: Option<String>,
97 #[arg(long)]
99 relationship: Option<String>,
100 #[arg(long)]
102 top: Option<i64>,
103 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
104 format: DisplayFormat,
105 #[arg(long)]
107 prompt_pack: Option<PathBuf>,
108}
109
110#[derive(Debug, Args)]
111#[command(group(
112 ArgGroup::new("selector")
113 .args(["path", "cluster", "relationship"])
114 .required(true)
115 .multiple(false)
116))]
117struct PlanArgs {
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, default_value_t = 3)]
132 max_slices: 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)]
141struct CheckArgs {
142 #[arg(long)]
144 report: Option<PathBuf>,
145 #[arg(long, value_enum)]
147 fail_on_context_band: Option<ContextBand>,
148 #[arg(long, value_enum)]
150 fail_on_slop_band: Option<SlopBand>,
151}
152
153#[derive(Debug, Args)]
154struct CompareArgs {
155 #[arg(long)]
157 base: PathBuf,
158 #[arg(long)]
160 head: PathBuf,
161 #[arg(long, default_value_t = 10)]
163 top: i64,
164 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
165 format: DisplayFormat,
166}
167
168#[derive(Debug, Args)]
169struct SarifArgs {
170 #[arg(long)]
172 report: Option<PathBuf>,
173 #[arg(long)]
175 top: Option<i64>,
176 #[arg(long)]
178 output: Option<PathBuf>,
179}
180
181#[derive(Debug, Args)]
182struct HealthArgs {
183 #[arg(long)]
185 report: Option<PathBuf>,
186 #[arg(long, value_enum, default_value_t = HealthFormat::Markdown)]
188 format: HealthFormat,
189 #[arg(long, default_value_t = 10)]
191 max_annotations: usize,
192}
193
194#[derive(Debug, Clone, Copy, ValueEnum)]
195enum DisplayFormat {
196 Text,
197 Json,
198}
199
200#[derive(Debug, Clone, Copy, ValueEnum)]
201enum HealthFormat {
202 Markdown,
203 Github,
204 Json,
205}
206
207#[derive(Debug, Clone, Copy, ValueEnum)]
208enum BuildInfoFormat {
209 Json,
210}
211
212#[derive(Debug, Clone, Copy, ValueEnum)]
213enum ContextBand {
214 Compact,
215 Healthy,
216 Warning,
217 Critical,
218}
219
220impl ContextBand {
221 fn as_str(self) -> &'static str {
222 match self {
223 Self::Compact => "compact",
224 Self::Healthy => "healthy",
225 Self::Warning => "warning",
226 Self::Critical => "critical",
227 }
228 }
229}
230
231#[derive(Debug, Clone, Copy, ValueEnum)]
232enum SlopBand {
233 Low,
234 Moderate,
235 High,
236 Critical,
237}
238
239impl SlopBand {
240 fn as_str(self) -> &'static str {
241 match self {
242 Self::Low => "low",
243 Self::Moderate => "moderate",
244 Self::High => "high",
245 Self::Critical => "critical",
246 }
247 }
248}
249
250fn print_text(value: &str) {
251 if value.is_empty() {
252 return;
253 }
254 print!("{value}");
255 if !value.ends_with('\n') {
256 println!();
257 }
258}
259
260fn relative_display(path: &Path, root: &Path) -> String {
261 path.strip_prefix(root)
262 .unwrap_or(path)
263 .to_string_lossy()
264 .replace('\\', "/")
265}
266
267fn default_report_path(repo_root: &Path) -> PathBuf {
268 config::latest_dir(repo_root).join("report.json")
269}
270
271fn load_report_at(path: &Path) -> Result<Option<Value>> {
272 if !path.exists() {
273 return Ok(None);
274 }
275 report::load_report(path).map(Some)
276}
277
278fn load_default_report(
279 repo_root: &Path,
280 explicit_report: Option<&Path>,
281) -> Result<Option<(Value, PathBuf)>> {
282 let path = explicit_report
283 .map(Path::to_path_buf)
284 .unwrap_or_else(|| default_report_path(repo_root));
285 Ok(load_report_at(&path)?.map(|report| (report, path)))
286}
287
288fn report_or_missing(
289 repo_root: &Path,
290 explicit_report: Option<&Path>,
291) -> Result<Result<(Value, PathBuf), i32>> {
292 let fallback = explicit_report
293 .map(Path::to_path_buf)
294 .unwrap_or_else(|| default_report_path(repo_root));
295 Ok(match load_default_report(repo_root, explicit_report)? {
296 Some(loaded) => Ok(loaded),
297 None => {
298 println!("Report not found: {}", fallback.display());
299 Err(2)
300 }
301 })
302}
303
304fn lexical_normalize(path: &Path) -> PathBuf {
305 let mut result = PathBuf::new();
306 for component in path.components() {
307 match component {
308 Component::CurDir => {}
309 Component::ParentDir => {
310 if !result.pop() {
311 result.push(component.as_os_str());
312 }
313 }
314 _ => result.push(component.as_os_str()),
315 }
316 }
317 result
318}
319
320fn selector_path(repo_root: &Path, input: &str) -> String {
321 let candidate = if Path::new(input).is_absolute() {
322 lexical_normalize(Path::new(input))
323 } else {
324 lexical_normalize(&repo_root.join(input))
325 };
326 candidate
327 .strip_prefix(repo_root)
328 .ok()
329 .map(|path| path.to_string_lossy().replace('\\', "/"))
330 .unwrap_or_else(|| {
331 let trimmed = input.trim();
332 if trimmed.is_empty() {
333 ".".to_string()
334 } else {
335 trimmed.to_string()
336 }
337 })
338}
339
340fn usage_error(error: impl std::fmt::Display) -> Result<i32> {
341 println!("{error}");
342 Ok(2)
343}
344
345fn ensure_prompt_pack_target(path: &Path) -> Result<Result<(), i32>> {
346 if path.exists() && !path.is_dir() {
347 println!("Prompt pack path is not a directory: {}", path.display());
348 Ok(Err(2))
349 } else {
350 Ok(Ok(()))
351 }
352}
353
354fn run_init(args: InitArgs) -> Result<i32> {
355 let repo_root = git::resolve_repo_root()?;
356 let result = config::initialize(&repo_root, args.force)?;
357 println!(
358 "Initialized {} ({}).",
359 relative_display(&config::config_path(&repo_root), &repo_root),
360 result.config
361 );
362 println!(
363 "Initialized {} ({}).",
364 relative_display(&config::slop_dir(&repo_root).join(".gitignore"), &repo_root),
365 result.gitignore
366 );
367 println!("Ensured .slop/latest/, .slop/runs/, and .slop/cache/ exist.");
368 Ok(0)
369}
370
371fn run_find() -> Result<i32> {
372 let result = analyze::run_find()?;
373 print_text(&result.terminal);
374 println!("Wrote report to {}.", result.report_json.display());
375 println!("Wrote YAML report to {}.", result.report_yaml.display());
376 println!("Wrote summary to {}.", result.summary_md.display());
377 println!(
378 "Wrote repository health summary to {}.",
379 result.health_md.display()
380 );
381 Ok(0)
382}
383
384fn run_show(args: ShowArgs) -> Result<i32> {
385 let repo_root = git::resolve_repo_root()?;
386 let (loaded, report_path) = match report_or_missing(&repo_root, args.report.as_deref())? {
387 Ok(value) => value,
388 Err(code) => return Ok(code),
389 };
390 let target = selector_path(&repo_root, &args.target_path);
391 let Some(payload) = show_payload(&loaded, &target) else {
392 println!(
393 "No record found for '{}' in {}.",
394 args.target_path,
395 report_path.display()
396 );
397 return Ok(2);
398 };
399 match args.format {
400 DisplayFormat::Json => print_text(&render_json(&payload)?),
401 DisplayFormat::Text => {
402 let yaml = serde_yaml::to_string(&payload)?;
403 print_text(&yaml);
404 }
405 }
406 Ok(0)
407}
408
409fn explain_selector(args: &ExplainArgs, repo_root: &Path) -> Result<ExplainSelector, i32> {
410 if let Some(path) = &args.path {
411 Ok(ExplainSelector::Path(selector_path(repo_root, path)))
412 } else if let Some(id) = &args.cluster {
413 Ok(ExplainSelector::Cluster(id.clone()))
414 } else if let Some(id) = &args.relationship {
415 Ok(ExplainSelector::Relationship(id.clone()))
416 } else {
417 let count = args.top.unwrap_or(5);
418 match usize::try_from(count).ok().filter(|count| *count > 0) {
419 Some(count) => Ok(ExplainSelector::Top(count)),
420 None => Err(2),
421 }
422 }
423}
424
425fn run_explain(args: ExplainArgs) -> Result<i32> {
426 let repo_root = git::resolve_repo_root()?;
427 let (loaded, _) = match report_or_missing(&repo_root, args.report.as_deref())? {
428 Ok(value) => value,
429 Err(code) => return Ok(code),
430 };
431 let selector = match explain_selector(&args, &repo_root) {
432 Ok(selector) => selector,
433 Err(code) => {
434 println!("--top must be greater than zero.");
435 return Ok(code);
436 }
437 };
438 let payload = match explain_payload(&loaded, Some(selector)) {
439 Ok(payload) => payload,
440 Err(error) => return usage_error(error),
441 };
442 if let Some(output_dir) = args.prompt_pack.as_deref() {
443 if let Err(code) = ensure_prompt_pack_target(output_dir)? {
444 return Ok(code);
445 }
446 write_prompt_pack("explain", &payload, &loaded, output_dir)?;
447 }
448 match args.format {
449 DisplayFormat::Json => print_text(&render_json(&payload)?),
450 DisplayFormat::Text => print_text(&render_explain_text(&payload)),
451 }
452 Ok(0)
453}
454
455fn plan_selector(args: &PlanArgs, repo_root: &Path) -> PlanSelector {
456 if let Some(path) = &args.path {
457 PlanSelector::Path(selector_path(repo_root, path))
458 } else if let Some(id) = &args.cluster {
459 PlanSelector::Cluster(id.clone())
460 } else {
461 PlanSelector::Relationship(args.relationship.clone().unwrap_or_default())
462 }
463}
464
465fn run_plan(args: PlanArgs) -> Result<i32> {
466 let repo_root = git::resolve_repo_root()?;
467 let (loaded, _) = match report_or_missing(&repo_root, args.report.as_deref())? {
468 Ok(value) => value,
469 Err(code) => return Ok(code),
470 };
471 let Some(max_slices) = usize::try_from(args.max_slices)
472 .ok()
473 .filter(|count| *count > 0)
474 else {
475 println!("--max-slices must be greater than zero.");
476 return Ok(2);
477 };
478 let payload = match plan_payload(&loaded, plan_selector(&args, &repo_root), max_slices) {
479 Ok(payload) => payload,
480 Err(error) => return usage_error(error),
481 };
482 if let Some(output_dir) = args.prompt_pack.as_deref() {
483 if let Err(code) = ensure_prompt_pack_target(output_dir)? {
484 return Ok(code);
485 }
486 write_prompt_pack("plan", &payload, &loaded, output_dir)?;
487 }
488 match args.format {
489 DisplayFormat::Json => print_text(&render_json(&payload)?),
490 DisplayFormat::Text => print_text(&render_plan_text(&payload)),
491 }
492 Ok(0)
493}
494
495fn run_check(args: CheckArgs) -> Result<i32> {
496 let repo_root = git::resolve_repo_root()?;
497 let (loaded, _) = match report_or_missing(&repo_root, args.report.as_deref())? {
498 Ok(value) => value,
499 Err(code) => return Ok(code),
500 };
501 let loaded_config = config::load(&repo_root)?;
502 let context_band = args
503 .fail_on_context_band
504 .map(ContextBand::as_str)
505 .or_else(|| {
506 loaded_config
507 .pointer("/check/fail_on_context_band")
508 .and_then(Value::as_str)
509 })
510 .unwrap_or("critical");
511 let slop_band = args
512 .fail_on_slop_band
513 .map(SlopBand::as_str)
514 .or_else(|| {
515 loaded_config
516 .pointer("/check/fail_on_slop_band")
517 .and_then(Value::as_str)
518 })
519 .unwrap_or("critical");
520 let failures = failing_records(&loaded, Some(context_band), Some(slop_band));
521 if failures.is_empty() {
522 println!(
523 "Check passed: no file records met or exceeded context={context_band} or slop={slop_band}."
524 );
525 return Ok(0);
526 }
527 println!(
528 "Check failed: {} file records met or exceeded context={context_band} or slop={slop_band}.",
529 failures.len()
530 );
531 for failure in failures.iter().take(10) {
532 println!(
533 "- {} (slop={}, context={}, slop_score={})",
534 failure
535 .get("path")
536 .and_then(Value::as_str)
537 .unwrap_or_default(),
538 failure
539 .get("slop_band")
540 .and_then(Value::as_str)
541 .unwrap_or_default(),
542 failure
543 .get("context_band")
544 .and_then(Value::as_str)
545 .unwrap_or_default(),
546 failure
547 .get("slop_score")
548 .map(ToString::to_string)
549 .unwrap_or_else(|| "null".to_string()),
550 );
551 }
552 Ok(1)
553}
554
555fn run_compare(args: CompareArgs) -> Result<i32> {
556 let Some(base_report) = load_report_at(&args.base)? else {
557 println!("Report not found: {}", args.base.display());
558 return Ok(2);
559 };
560 let Some(head_report) = load_report_at(&args.head)? else {
561 println!("Report not found: {}", args.head.display());
562 return Ok(2);
563 };
564 let Some(top) = usize::try_from(args.top).ok().filter(|count| *count > 0) else {
565 println!("--top must be greater than zero.");
566 return Ok(2);
567 };
568 let payload = match compare_payload(
569 &base_report,
570 &head_report,
571 Some(&args.base.to_string_lossy()),
572 Some(&args.head.to_string_lossy()),
573 top,
574 ) {
575 Ok(payload) => payload,
576 Err(error) => return usage_error(error),
577 };
578 match args.format {
579 DisplayFormat::Json => print_text(&render_json(&payload)?),
580 DisplayFormat::Text => print_text(&render_compare_text(&payload, top)),
581 }
582 Ok(0)
583}
584
585fn run_sarif(args: SarifArgs) -> Result<i32> {
586 let repo_root = git::resolve_repo_root()?;
587 let (loaded, report_path) = match report_or_missing(&repo_root, args.report.as_deref())? {
588 Ok(value) => value,
589 Err(code) => return Ok(code),
590 };
591 let top = match args.top {
592 None => None,
593 Some(value) => match usize::try_from(value).ok().filter(|count| *count > 0) {
594 Some(value) => Some(value),
595 None => {
596 println!("--top must be greater than zero.");
597 return Ok(2);
598 }
599 },
600 };
601 let payload = match sarif_payload(&loaded, Some(&report_path.to_string_lossy()), top) {
602 Ok(payload) => payload,
603 Err(error) => return usage_error(error),
604 };
605 let rendered = render_json(&payload)?;
606 if let Some(output) = args.output {
607 if let Some(parent) = output
608 .parent()
609 .filter(|parent| !parent.as_os_str().is_empty())
610 {
611 fs::create_dir_all(parent)
612 .with_context(|| format!("failed to create {}", parent.display()))?;
613 }
614 fs::write(&output, rendered)
615 .with_context(|| format!("failed to write {}", output.display()))?;
616 println!("Wrote SARIF report to {}.", output.display());
617 } else {
618 print_text(&rendered);
619 }
620 Ok(0)
621}
622
623fn run_health(args: HealthArgs) -> Result<i32> {
624 let repo_root = git::resolve_repo_root()?;
625 let (mut loaded, _) = match report_or_missing(&repo_root, args.report.as_deref())? {
626 Ok(value) => value,
627 Err(code) => return Ok(code),
628 };
629 let rollup = match health::health_rollup_from_report(&loaded) {
630 Ok(rollup) => rollup,
631 Err(error) => return usage_error(error),
632 };
633 let mut health_value = serde_json::to_value(rollup)?;
634 if let (Some(existing), Some(derived)) = (
635 loaded.get("health").and_then(Value::as_object),
636 health_value.as_object_mut(),
637 ) {
638 for (key, value) in existing {
639 derived.entry(key.clone()).or_insert_with(|| value.clone());
640 }
641 }
642 if let Some(object) = loaded.as_object_mut() {
643 object.insert("health".to_string(), health_value);
644 }
645 match args.format {
646 HealthFormat::Markdown => {
647 let rendered = match health::render_health_from_report(&loaded) {
648 Ok(rendered) => rendered,
649 Err(error) => return usage_error(error),
650 };
651 print_text(&rendered);
652 }
653 HealthFormat::Github => {
654 print_text(&render_github_annotations(&loaded, args.max_annotations));
655 }
656 HealthFormat::Json => {
657 print_text(&render_json(&health_json_payload(&loaded))?);
658 }
659 }
660 Ok(0)
661}
662
663fn execute(command: Command) -> Result<i32> {
664 match command {
665 Command::Init(args) => run_init(args),
666 Command::Find => run_find(),
667 Command::Show(args) => run_show(args),
668 Command::Explain(args) => run_explain(args),
669 Command::Plan(args) => run_plan(args),
670 Command::Check(args) => run_check(args),
671 Command::Compare(args) => run_compare(args),
672 Command::Sarif(args) => run_sarif(args),
673 Command::Health(args) => run_health(args),
674 Command::Version => {
675 println!("{PROJECT_NAME} {VERSION}");
676 Ok(0)
677 }
678 Command::BuildInfo(args) => {
679 match args.format {
680 BuildInfoFormat::Json => {
681 println!("{}", serde_json::to_string_pretty(&build_info::current())?)
682 }
683 }
684 Ok(0)
685 }
686 }
687}
688
689pub fn run() -> i32 {
690 let cli = match Cli::try_parse() {
691 Ok(cli) => cli,
692 Err(error) => {
693 let code = error.exit_code();
694 let _ = error.print();
695 return code;
696 }
697 };
698 match execute(cli.command) {
699 Ok(code) => code,
700 Err(error) => {
701 eprintln!("{error:#}");
702 1
703 }
704 }
705}