use std::ffi::OsString;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::Parser;
use cargo_target_gc::agent_skill::{self, InstallOptions, Selection, SkillCollision};
use cargo_target_gc::clean::{self, CleanPlan};
use cargo_target_gc::cli::{Cli, Command};
use cargo_target_gc::report::human;
use cargo_target_gc::{config, discovery, scan};
fn main() -> Result<()> {
let cli = Cli::parse_from(cargo_args());
match cli.command {
Command::Scan { path, json } => run_scan(path, json),
Command::Clean {
path,
json,
dry_run,
confirm,
force_active,
max_reclaim,
stale,
profile_cache,
} => run_clean(CleanOptions {
path,
json,
dry_run,
confirm,
force_active,
max_reclaim,
stale,
profile_cache,
}),
Command::Config { path } => run_config(path),
Command::InstallAgentSkills {
claude_skills_dir,
codex_skills_dir,
all,
only,
yes,
dry_run,
force,
skip_existing,
} => {
let hosts = match only {
Some(raw) => agent_skill::parse_hosts(&raw)?,
None => Vec::new(),
};
let collision = if force {
SkillCollision::Overwrite
} else if skip_existing {
SkillCollision::Skip
} else {
SkillCollision::Prompt
};
let selection = if all {
Selection::All
} else {
Selection::Detected
};
let opts = InstallOptions {
claude_skills_dir,
codex_skills_dir,
only: hosts,
selection,
collision,
dry_run,
yes,
};
agent_skill::install(&opts)
}
}
}
fn cargo_args() -> Vec<OsString> {
let mut args: Vec<_> = std::env::args_os().collect();
if args.get(1).and_then(|arg| arg.to_str()) == Some("target-gc") {
args.remove(1);
}
args
}
fn resolve(path: Option<PathBuf>) -> PathBuf {
path.unwrap_or_else(|| PathBuf::from("."))
}
fn run_scan(path: Option<PathBuf>, json: bool) -> Result<()> {
let root = resolve(path);
let report =
scan::scan(&root).with_context(|| format!("scan failed for {}", root.display()))?;
if json {
println!("{}", report.render_json().context("render JSON report")?);
} else {
print!("{}", report.render_text());
}
Ok(())
}
struct CleanOptions {
path: Option<PathBuf>,
json: bool,
dry_run: bool,
confirm: bool,
force_active: bool,
max_reclaim: Option<u64>,
stale: bool,
profile_cache: bool,
}
fn run_clean(options: CleanOptions) -> Result<()> {
if !options.dry_run && !options.confirm {
bail!(
"clean requires an explicit mode: pass --dry-run to preview removals \
or --confirm to delete reclaimable artifacts"
);
}
let root = resolve(options.path);
let plan = clean::plan(&root, options.stale, options.profile_cache)
.with_context(|| format!("clean planning failed for {}", root.display()))?;
if options.confirm {
eprintln!(
"cargo-target-gc: removing {} artifact(s)",
plan.removals.len()
);
let freed = clean::execute(&plan, options.force_active, options.max_reclaim)
.context("executing clean plan")?;
emit_clean(
&plan,
true,
freed,
options.json,
options.stale,
options.profile_cache,
)?;
} else {
eprintln!("cargo-target-gc: dry-run — no files will be removed");
emit_clean(
&plan,
false,
plan.total_bytes(),
options.json,
options.stale,
options.profile_cache,
)?;
}
Ok(())
}
fn emit_clean(
plan: &CleanPlan,
executed: bool,
bytes: u64,
json: bool,
include_stale: bool,
include_profile_cache: bool,
) -> Result<()> {
if json {
let removals: Vec<_> = plan
.removals
.iter()
.map(|r| {
serde_json::json!({
"path": r.path.display().to_string(),
"category": r.category.name(),
"bytes": r.bytes,
})
})
.collect();
let value = serde_json::json!({
"project_root": plan.project_root.display().to_string(),
"executed": executed,
"include_stale": include_stale,
"include_profile_cache": include_profile_cache,
"reclaimable_bytes": bytes,
"removals": removals,
});
println!(
"{}",
serde_json::to_string_pretty(&value).context("render clean JSON")?
);
return Ok(());
}
let verb = if executed { "removed" } else { "would remove" };
println!("cargo target-gc clean: {}", plan.project_root.display());
if include_profile_cache {
println!(" profile-cache mode: enabled");
}
for removal in &plan.removals {
println!(
" {verb} {:<12} {} ({})",
removal.category.name(),
removal.path.display(),
human(removal.bytes)
);
}
let label = if executed { "reclaimed" } else { "reclaimable" };
println!(
" {} artifact(s), {}: {}",
plan.removals.len(),
label,
human(bytes)
);
Ok(())
}
fn run_config(path: Option<PathBuf>) -> Result<()> {
let start = resolve(path);
let project = discovery::discover(&start)
.with_context(|| format!("discover project for {}", start.display()))?;
let cfg = config::load(&project.root)
.with_context(|| format!("load config for {}", project.root.display()))?;
print_config(&project.root, &cfg);
Ok(())
}
fn print_config(root: &Path, cfg: &config::Config) {
println!("cargo target-gc config: {}", root.display());
println!(" retention_days: {}", cfg.retention_days);
println!(
" incremental_retention_hours: {}",
cfg.incremental_retention_hours
);
match cfg.max_reclaim_bytes {
Some(bytes) => println!(" max_reclaim_bytes: {}", bytes),
None => println!(" max_reclaim_bytes: (none)"),
}
match &cfg.crate_path {
Some(p) => println!(" crate_path: {}", p.display()),
None => println!(" crate_path: (none)"),
}
}