Skip to main content

git_slop/
cli.rs

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    /// Scaffold .slop/ config, ignore rules, and state directories.
34    Init(InitArgs),
35    /// Scan the repository and generate hotspot reports.
36    Find,
37    /// Show metrics and reasons for one file or folder.
38    Show(ShowArgs),
39    /// Explain why selected hotspots or structural findings are expensive.
40    Explain(ExplainArgs),
41    /// Propose bounded maintenance slices from the current detector report.
42    Plan(PlanArgs),
43    /// Evaluate an existing report against CI thresholds.
44    Check(CheckArgs),
45    /// Compare two existing schema-4 reports without rerunning the detector.
46    Compare(CompareArgs),
47    /// Export action-queue findings from an existing schema-4 report as SARIF.
48    Sarif(SarifArgs),
49    /// Render repository health for CI summaries, annotations, or automation.
50    Health(HealthArgs),
51    /// Print version information.
52    Version,
53    /// Print package and source-build provenance.
54    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    /// Overwrite generated config files.
66    #[arg(long)]
67    force: bool,
68}
69
70#[derive(Debug, Args)]
71struct ShowArgs {
72    /// Repo-relative file or folder path.
73    target_path: String,
74    /// Report path. Defaults to .slop/latest/report.json.
75    #[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    /// Report path. Defaults to .slop/latest/report.json.
89    #[arg(long)]
90    report: Option<PathBuf>,
91    /// Repo-relative file or folder path.
92    #[arg(long)]
93    path: Option<String>,
94    /// Cluster identifier.
95    #[arg(long)]
96    cluster: Option<String>,
97    /// Relationship identifier.
98    #[arg(long)]
99    relationship: Option<String>,
100    /// Explain the top N hotspots from the action queue.
101    #[arg(long)]
102    top: Option<i64>,
103    #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
104    format: DisplayFormat,
105    /// Write a deterministic local-model prompt pack to this directory.
106    #[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    /// Report path. Defaults to .slop/latest/report.json.
119    #[arg(long)]
120    report: Option<PathBuf>,
121    /// Repo-relative file or folder path.
122    #[arg(long)]
123    path: Option<String>,
124    /// Cluster identifier.
125    #[arg(long)]
126    cluster: Option<String>,
127    /// Relationship identifier.
128    #[arg(long)]
129    relationship: Option<String>,
130    /// Maximum number of bounded maintenance slices to propose.
131    #[arg(long, default_value_t = 3)]
132    max_slices: i64,
133    #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
134    format: DisplayFormat,
135    /// Write a deterministic local-model prompt pack to this directory.
136    #[arg(long)]
137    prompt_pack: Option<PathBuf>,
138}
139
140#[derive(Debug, Args)]
141struct CheckArgs {
142    /// Report path. Defaults to .slop/latest/report.json.
143    #[arg(long)]
144    report: Option<PathBuf>,
145    /// Override the config default fail threshold for context_band.
146    #[arg(long, value_enum)]
147    fail_on_context_band: Option<ContextBand>,
148    /// Override the config default fail threshold for slop_band.
149    #[arg(long, value_enum)]
150    fail_on_slop_band: Option<SlopBand>,
151}
152
153#[derive(Debug, Args)]
154struct CompareArgs {
155    /// Base report.json path.
156    #[arg(long)]
157    base: PathBuf,
158    /// Head report.json path.
159    #[arg(long)]
160    head: PathBuf,
161    /// Maximum number of changed files and queue movements to show.
162    #[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    /// Report path. Defaults to .slop/latest/report.json.
171    #[arg(long)]
172    report: Option<PathBuf>,
173    /// Maximum number of action-queue findings to export.
174    #[arg(long)]
175    top: Option<i64>,
176    /// Optional SARIF output path. Defaults to stdout.
177    #[arg(long)]
178    output: Option<PathBuf>,
179}
180
181#[derive(Debug, Args)]
182struct HealthArgs {
183    /// Report path. Defaults to .slop/latest/report.json.
184    #[arg(long)]
185    report: Option<PathBuf>,
186    /// Output suited for a job summary, workflow annotations, or automation.
187    #[arg(long, value_enum, default_value_t = HealthFormat::Markdown)]
188    format: HealthFormat,
189    /// Maximum number of GitHub workflow annotations to emit.
190    #[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}