diff --git a/Cargo.lock b/Cargo.lock index 90fa624..8fd24a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -229,6 +229,19 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.0", + "windows-sys 0.59.0", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -363,6 +376,12 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "env_home" version = "0.1.0" @@ -506,6 +525,19 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width 0.2.0", + "web-time", +] + [[package]] name = "indoc" version = "2.0.7" @@ -715,6 +747,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "objc2" version = "0.6.4" @@ -1252,6 +1290,7 @@ dependencies = [ "anyhow", "assert_cmd", "clap", + "indicatif", "is-terminal", "jiff", "mimalloc", @@ -1355,6 +1394,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "which" version = "7.0.3" diff --git a/Cargo.toml b/Cargo.toml index 6b9640a..3685dcc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ which = "7" # CLI clap = { version = "4", features = ["derive", "env", "wrap_help"] } is-terminal = "0.4" +indicatif = "0.17" # TUI ratatui = "0.29" diff --git a/crates/vacuum-cli/Cargo.toml b/crates/vacuum-cli/Cargo.toml index 10d55f8..3de5e89 100644 --- a/crates/vacuum-cli/Cargo.toml +++ b/crates/vacuum-cli/Cargo.toml @@ -24,6 +24,7 @@ vacuum-cleaners.workspace = true vacuum-tui = { path = "../vacuum-tui", version = "0.1.0" } clap.workspace = true +indicatif.workspace = true serde.workspace = true serde_json.workspace = true jiff.workspace = true diff --git a/crates/vacuum-cli/src/agent.rs b/crates/vacuum-cli/src/agent.rs index c0b7c67..f58093e 100644 --- a/crates/vacuum-cli/src/agent.rs +++ b/crates/vacuum-cli/src/agent.rs @@ -25,21 +25,35 @@ impl OutputMode { } } -/// Environment variables that signal an AI agent / CI is driving the CLI. -const AGENT_VARS: &[&str] = &[ - "AI_AGENT", - "AGENT", - "CI", - "CLAUDECODE", - "CURSOR_AGENT", - "GEMINI_CLI", -]; - -/// Whether an AI agent or CI environment is detected. +/// Variables that **force** machine output and suppress the TUI: a generic +/// agent, or CI (agentic-cli §4). These are the only behavioral switches. +const AGENT_FORCE_VARS: &[&str] = &["AI_AGENT", "AGENT", "CI"]; + +/// **Informational** variables that merely name the calling agent. Per +/// agentic-cli §4 they MUST NOT change the output format on their own — they +/// only surface in `metadata.invoking_agent`. TTY / `AGENT_FORCE_VARS` decide +/// the mode. +const AGENT_INFO_VARS: &[&str] = &["CLAUDECODE", "CURSOR_AGENT", "GEMINI_CLI"]; + +/// Return the first variable in `vars` that is set to a non-empty value, +/// looked up via `get`. Pure over `get` so it can be unit-tested without +/// mutating the process environment. +fn first_set<'a>(vars: &[&'a str], get: impl Fn(&str) -> Option) -> Option<&'a str> { + vars.iter() + .copied() + .find(|var| get(var).is_some_and(|value| !value.is_empty())) +} + +/// Whether an AI agent or CI environment is forcing non-interactive output. pub fn is_agent() -> bool { - AGENT_VARS - .iter() - .any(|var| std::env::var(var).is_ok_and(|value| !value.is_empty())) + first_set(AGENT_FORCE_VARS, |var| std::env::var(var).ok()).is_some() +} + +/// The name of the specific invoking agent, if one identified itself, for +/// telemetry in the JSON envelope (`metadata.invoking_agent`). Never a +/// behavioral switch. +pub fn invoking_agent() -> Option<&'static str> { + first_set(AGENT_INFO_VARS, |var| std::env::var(var).ok()) } /// Resolve the effective output mode from flags, agent detection, and TTY state. @@ -74,3 +88,38 @@ pub fn should_launch_tui(global: &GlobalArgs) -> bool { } std::io::stdout().is_terminal() } + +#[cfg(test)] +mod tests { + use super::{AGENT_FORCE_VARS, AGENT_INFO_VARS, first_set}; + + /// Build a fake env lookup from a fixed (name, value) table. + fn env_of<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { + move |var| { + pairs + .iter() + .find(|(name, _)| *name == var) + .map(|(_, value)| (*value).to_owned()) + } + } + + #[test] + fn force_vars_are_detected() { + let env = env_of(&[("AI_AGENT", "1")]); + assert_eq!(first_set(AGENT_FORCE_VARS, &env), Some("AI_AGENT")); + } + + #[test] + fn informational_vars_do_not_force_mode() { + // CLAUDECODE et al. must NOT count as a forcing agent (agentic-cli §4). + let env = env_of(&[("CLAUDECODE", "1")]); + assert_eq!(first_set(AGENT_FORCE_VARS, &env), None); + assert_eq!(first_set(AGENT_INFO_VARS, &env), Some("CLAUDECODE")); + } + + #[test] + fn empty_value_is_not_set() { + let env = env_of(&[("CI", "")]); + assert_eq!(first_set(AGENT_FORCE_VARS, &env), None); + } +} diff --git a/crates/vacuum-cli/src/app.rs b/crates/vacuum-cli/src/app.rs index 7c78a97..ff50199 100644 --- a/crates/vacuum-cli/src/app.rs +++ b/crates/vacuum-cli/src/app.rs @@ -5,7 +5,9 @@ use std::io::Write as _; use std::path::PathBuf; +use std::time::Duration; +use indicatif::{ProgressBar, ProgressStyle}; use serde::Serialize; use serde_json::{Value, json}; use vacuum_cleaners::cleaners_for; @@ -243,6 +245,78 @@ struct CleanReport { outcomes: Vec, } +/// Build a steady-tick spinner for the deletion loop, or `None` when progress +/// must stay silent (machine modes, `--quiet`, dry runs, or no work to do). +/// +/// indicatif draws to stderr and auto-hides when stderr is not a terminal, so +/// stdout stays data-only and pipes never see control characters (SFRS §7). The +/// template is uncolored, which trivially satisfies `NO_COLOR`. +fn clean_spinner(len: usize, enabled: bool) -> Option { + if !enabled || len == 0 { + return None; + } + let style = ProgressStyle::with_template("{spinner} [{pos}/{len}] {wide_msg}") + .expect("static progress template is valid"); + let bar = ProgressBar::new(len as u64).with_style(style); + bar.enable_steady_tick(Duration::from_millis(100)); + Some(bar) +} + +/// Act on every candidate, returning the outcomes and total reclaimed bytes. +/// +/// When `show_progress` is set, a live spinner (above) reports the item being +/// removed so a long deletion does not look frozen. Each candidate is a single +/// blocking removal, so the steady tick — not a percent bar — is what proves the +/// process is alive. Per-item skip warnings are printed above the bar via +/// [`ProgressBar::suspend`] so the spinner line is never corrupted. +fn execute_candidates( + deleter: &Deleter, + candidates: &[Candidate], + delete_mode: DeleteMode, + show_progress: bool, + quiet: bool, +) -> (Vec, u64) { + let verb = if delete_mode == DeleteMode::Purge { + "Removing" + } else { + "Trashing" + }; + let progress = clean_spinner(candidates.len(), show_progress); + let mut outcomes = Vec::new(); + let mut reclaimed = 0_u64; + + for candidate in candidates { + if let Some(bar) = &progress { + bar.set_message(format!("{verb} {}", candidate.label)); + } + match deleter.execute(candidate) { + Ok(outcome) => { + if counts_bytes(outcome.action) { + reclaimed += outcome.bytes; + } + outcomes.push(outcome); + } + Err(err) => { + if !quiet { + let warn = format!("vacuum: skipping {}: {err}", candidate.label); + match &progress { + Some(bar) => bar.suspend(|| eprintln!("{warn}")), + None => eprintln!("{warn}"), + } + } + } + } + if let Some(bar) = &progress { + bar.inc(1); + } + } + + if let Some(bar) = &progress { + bar.finish_and_clear(); + } + (outcomes, reclaimed) +} + /// Run `vacuum clean`. pub fn run_clean( args: &CleanArgs, @@ -283,24 +357,15 @@ pub fn run_clean( } let deleter = Deleter::new(delete_mode, dry_run, resolve_roots(&args.select.roots)); - let mut outcomes = Vec::new(); - let mut reclaimed = 0_u64; - - for candidate in &candidates { - match deleter.execute(candidate) { - Ok(outcome) => { - if counts_bytes(outcome.action) { - reclaimed += outcome.bytes; - } - outcomes.push(outcome); - } - Err(err) => { - if !global.quiet { - eprintln!("vacuum: skipping {}: {err}", candidate.label); - } - } - } - } + // Show a live spinner only for real, interactive work (SFRS §7). + let show_progress = mode == OutputMode::Human && !global.quiet && !dry_run; + let (outcomes, reclaimed) = execute_candidates( + &deleter, + &candidates, + delete_mode, + show_progress, + global.quiet, + ); let report = CleanReport { dry_run, diff --git a/crates/vacuum-cli/src/envelope.rs b/crates/vacuum-cli/src/envelope.rs index bf05e05..3cc0ea1 100644 --- a/crates/vacuum-cli/src/envelope.rs +++ b/crates/vacuum-cli/src/envelope.rs @@ -20,6 +20,10 @@ struct Metadata { version: &'static str, command: String, timestamp: String, + /// The specific agent that invoked us, when one identified itself + /// (agentic-cli §4). Telemetry only — omitted when none is present. + #[serde(skip_serializing_if = "Option::is_none")] + invoking_agent: Option<&'static str>, maintainer: &'static str, website: &'static str, } @@ -47,6 +51,7 @@ pub fn emit(command: &str, data: T) -> anyhow::Result<()> { version: env!("CARGO_PKG_VERSION"), command: command.to_owned(), timestamp: Timestamp::now().to_string(), + invoking_agent: crate::agent::invoking_agent(), maintainer: MAINTAINER, website: WEBSITE, },