use clap::{CommandFactory, Parser, Subcommand};
use safesweep::render::{self, human};
use safesweep::{Ctx, clean, collect, rules, tui, why, wsl};
use std::fmt::Write as _;
use std::io::IsTerminal;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::Duration;
use std::{env, fs};
#[derive(Parser)]
#[command(version, about)]
struct Cli {
#[command(subcommand)]
cmd: Option<Cmd>,
#[arg(long = "category", value_name = "ID", global = true)]
categories: Vec<String>,
}
#[derive(Subcommand)]
enum Cmd {
Scan {
#[arg(long)]
json: bool,
},
Clean {
#[arg(long)]
apply: bool,
},
Tui,
Completions { shell: clap_complete::Shell },
#[command(hide = true)]
Man,
Why {
#[arg(long, default_value_t = 1000)]
interval_ms: u64,
#[arg(long, default_value_t = 10)]
top: usize,
},
}
fn main() -> ExitCode {
run(Cli::parse()).unwrap_or_else(|msg| {
eprintln!("error: {msg}");
ExitCode::from(2)
})
}
fn runtime_error(msg: &str) -> ExitCode {
eprintln!("error: {msg}");
ExitCode::from(1)
}
fn run(cli: Cli) -> Result<ExitCode, String> {
let cmd = cli.cmd.unwrap_or(Cmd::Scan { json: false });
let ignores_category = match &cmd {
Cmd::Why { .. } => Some("why"),
Cmd::Completions { .. } => Some("completions"),
Cmd::Man => Some("man"),
_ => None,
};
if let Some(name) = ignores_category
&& !cli.categories.is_empty()
{
return Err(format!("--category does not apply to {name}"));
}
match cmd {
Cmd::Completions { shell } => {
clap_complete::generate(
shell,
&mut Cli::command(),
"safesweep",
&mut std::io::stdout(),
);
Ok(ExitCode::SUCCESS)
}
Cmd::Man => Ok(
match clap_mangen::Man::new(Cli::command()).render(&mut std::io::stdout()) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => runtime_error(&format!("cannot write man page: {e}")),
},
),
Cmd::Why { interval_ms, top } => run_why(interval_ms, top),
Cmd::Scan { json } => {
let (_, rows) = scan_rows(&cli.categories)?;
if json {
println!("{}", render::json(&rows));
} else {
print!("{}", render::table(&rows));
}
Ok(ExitCode::SUCCESS)
}
Cmd::Clean { apply } => {
let (ctx, rows) = scan_rows(&cli.categories)?;
Ok(run_clean(&ctx, &rows, apply))
}
Cmd::Tui => run_tui(&cli.categories),
}
}
fn scan_rows(categories: &[String]) -> Result<(Ctx, Vec<safesweep::Row>), String> {
let ctx = Ctx::from_env()?;
let selected = rules::select(categories)?;
let rows = collect(&selected, &ctx);
Ok((ctx, rows))
}
fn manifest_dir(ctx: &Ctx) -> PathBuf {
ctx.home.join(".local/state/safesweep")
}
fn run_clean(ctx: &Ctx, rows: &[safesweep::Row], apply: bool) -> ExitCode {
let dir = manifest_dir(ctx);
let out = match clean::clean(rows, ctx, apply, &dir) {
Ok(out) => out,
Err(e) => {
eprintln!("error: cannot write manifest in {}: {e}", dir.display());
return ExitCode::from(1);
}
};
for line in &out.lines {
println!("{line}");
}
println!(
"{} {}",
if apply { "freed" } else { "would free" },
human(out.freed)
);
let on_wsl = fs::read_to_string(ctx.proc_root.join("version")).is_ok_and(|v| wsl::is_wsl(&v));
if apply && out.freed > 0 && on_wsl {
let distro = env::var("WSL_DISTRO_NAME").unwrap_or_else(|_| "<distro>".into());
print!("\n{}", wsl::hint(&distro));
}
if out.failed > 0 {
ExitCode::from(1)
} else {
ExitCode::SUCCESS
}
}
fn run_tui(categories: &[String]) -> Result<ExitCode, String> {
let ctx = Ctx::from_env()?;
let selected = rules::select(categories)?;
if !std::io::stdout().is_terminal() {
return Err("tui needs an interactive terminal".into());
}
Ok(match tui::run(&selected, &ctx, &manifest_dir(&ctx)) {
Ok(false) => ExitCode::SUCCESS,
Ok(true) => ExitCode::from(1),
Err(e) => runtime_error(&format!("terminal error: {e}")),
})
}
fn run_why(interval_ms: u64, top: usize) -> Result<ExitCode, String> {
if interval_ms < 100 {
return Err("--interval-ms must be at least 100".into());
}
let proc_root = Path::new("/proc");
let err = |e: std::io::Error| runtime_error(&format!("cannot read /proc: {e}"));
let a = match why::sample(proc_root) {
Ok(s) => s,
Err(e) => return Ok(err(e)),
};
std::thread::sleep(Duration::from_millis(interval_ms));
let b = match why::sample(proc_root) {
Ok(s) => s,
Err(e) => return Ok(err(e)),
};
let r = why::diff(&a, &b);
let mut out = String::new();
let _ = writeln!(
out,
"CPU busy: {:.0}% of {} cores (sampled {:.1}s)",
r.busy_pct,
r.ncpu,
interval_ms as f64 / 1000.0
);
let _ = writeln!(out, "\nPROGRAM CPU% PROCS");
for (comm, pct, n) in why::by_program(&r.procs).iter().take(top) {
let _ = writeln!(out, "{:<20} {pct:>6.1} {n:>5}", render::escape_text(comm));
}
let _ = writeln!(out, "\n PID CPU% COMMAND");
for u in r.procs.iter().take(top) {
let raw = fs::read(proc_root.join(u.pid.to_string()).join("cmdline")).unwrap_or_default();
let cmdline = why::fit_cmdline(&why::command_line(&raw, &u.comm), 100);
let _ = writeln!(out, "{:>7} {:>6.1} {cmdline}", u.pid, u.pct);
}
if r.procs.is_empty() {
let _ = writeln!(out, "(no process used measurable CPU)");
}
if fs::read_to_string(proc_root.join("version")).is_ok_and(|v| wsl::is_wsl(&v)) {
let _ = writeln!(
out,
"\nWSL: only Linux processes are visible here. If Task Manager shows vmmem busy\n\
but this list is quiet, the load is on the Windows side."
);
}
let _ = std::io::stdout().write_all(out.as_bytes());
Ok(ExitCode::SUCCESS)
}