diff --git a/CHANGELOG.md b/CHANGELOG.md index 77dae46..80bd44b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,25 @@ dedicated `feat(...): ... (P)` commit; see those for design details. ### Added +- `disky free [PATH]` — live `statvfs` probe of the volume holding `PATH` + (default `$HOME`). Emits `{path, total_bytes, free_bytes, used_bytes, + used_pct}` under `kind="volume"`. No snapshot and no scan required, so a + cleanup loop can gate on "keep 30 GB free" without a 3-minute traversal. + `stats` answers what a past scan measured; this answers what the filesystem + says now. `free_bytes` uses `f_bavail` (unprivileged-writable), not the + root-only figure. +- New `volume` module: `volume::probe(path)` and `volume::free_bytes(path)`. + The latter returns `None` on probe failure — never `0`, which would read as + a full disk. + +### Changed +- `disky predict` defaults `--free-bytes` to the live `$HOME`-volume free + space. Previously omitting the flag produced `fill_at: null` with reason + "provide --free-bytes", which made the command useless by default and + forced callers into `df -k / | awk '{print $4*1024}'`. +- `disky growth --over-n` defaults `--fill-target` the same way. Its `--help` + already claimed this default; the code never implemented it. + - `disky cleanup` (CLI + MCP) now adds `summary: [CategorySummary]` and `total_bytes` to the JSON envelope, aggregating hits across paths per category. Text mode prints a second table beneath the per-path list diff --git a/Cargo.lock b/Cargo.lock index eb3d28b..cda7942 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -801,6 +801,7 @@ dependencies = [ "humansize", "indicatif", "jwalk", + "libc", "memchr", "num_cpus", "ratatui", diff --git a/Cargo.toml b/Cargo.toml index d382369..f2e29dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ ratatui = "0.29" crossterm = "0.28" arboard = "3" dirs = "5" +libc = "0.2" serde = { version = "1", features = ["derive"] } serde_json = "1" ctrlc = "3" diff --git a/README.md b/README.md index 8748ed8..50164d3 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ brew install biliboss/tap/disky disky scan / # scan from root (~3 min for 2M files) disky # open ncdu-style TUI +disky free # live free space on the $HOME volume (no scan) +disky free /Volumes/EXT # …or any other volume disky top --physical # largest files (physical bytes — APFS-aware) disky dirs --physical # largest directories disky ext # usage by extension @@ -80,6 +82,7 @@ disky scan / # before disky scan / # after disky diff @latest~1 @latest # what grew, shrank, was added or removed disky growth --over-n 5 # OLS fit across 5 most-recent snapshots +disky predict # fill-by date; free-byte budget probed live ``` ### Agent-native output diff --git a/src/cli.rs b/src/cli.rs index 8d3ff04..8ceeb3b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -217,8 +217,8 @@ pub enum Command { #[arg(long, value_name = "N")] over_n: Option, /// Free-byte budget the projection extrapolates against. Only used - /// with `--over-n`. Default: free bytes on the volume that holds - /// `$HOME` (best-effort; may be 0 if `statvfs` fails). + /// with `--over-n`. Default: live free bytes on the volume that holds + /// `$HOME` (omitted from the projection if `statvfs` fails). #[arg(long, value_name = "BYTES")] fill_target: Option, #[arg(short, long, default_value_t = 50)] @@ -226,15 +226,24 @@ pub enum Command { }, /// Predict when the disk fills based on linear regression over all - /// snapshots in the data dir. Pass `--free-bytes ` so the fit - /// can compute a fill-by date. + /// snapshots in the data dir. Defaults the fill target to the live free + /// space on the `$HOME` volume — pass `--free-bytes ` to override. Predict { - /// Bytes currently free on the volume. Get via - /// `df -k / | tail -1 | awk '{print $4*1024}'`. + /// Bytes currently free on the volume. Default: probed live via + /// `statvfs` on the `$HOME` volume (see `disky free`). #[arg(long, value_name = "BYTES")] free_bytes: Option, }, + /// Live free space on a volume — no snapshot needed, no scan needed. + /// Reports total / free / used bytes and used percent for the volume + /// holding PATH. This is the number to gate a cleanup loop on; `stats` + /// reports what a past scan measured, which is a different question. + Free { + /// Path whose volume to probe. Default: `$HOME`. + path: Option, + }, + /// Per-directory churn — files modified within the last N hours/days. /// Identifies log generators and hot working directories. Churn { diff --git a/src/lib.rs b/src/lib.rs index b1a14d0..f8e82e5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,3 +21,4 @@ pub mod render; pub mod scan; pub mod schema; pub mod snapshots; +pub mod volume; diff --git a/src/main.rs b/src/main.rs index e60501b..5c1a0ab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -306,6 +306,8 @@ fn dispatch(cli: Cli, format: Format) -> anyhow::Result<()> { } // list_snapshots() returns oldest..newest; take the last N. let pick: Vec<(String, i64)> = parsed[parsed.len() - n..].to_vec(); + // Same default as `predict`: probe the volume when unset. + let fill_target = fill_target.or_else(|| disky::volume::free_bytes(None)); let rows = query::growth_over_n(&pick, limit, fill_target)?; if format.is_machine() { let payload = json!({ @@ -416,7 +418,10 @@ fn dispatch(cli: Cli, format: Format) -> anyhow::Result<()> { } } Command::Predict { free_bytes } => { - let rec = disky::predict::predict(physical, free_bytes)?; + // Without a budget the fit has nothing to extrapolate against, so + // fall back to the live volume rather than emitting a null date. + let budget = free_bytes.or_else(|| disky::volume::free_bytes(None)); + let rec = disky::predict::predict(physical, budget)?; if format.is_machine() { let payload = json!({ "schema_version": SCHEMA_VERSION, @@ -445,6 +450,27 @@ fn dispatch(cli: Cli, format: Format) -> anyhow::Result<()> { } } } + Command::Free { path } => { + let target = path + .or_else(dirs::home_dir) + .ok_or_else(|| DiskyError::new(ExitCode::Io, "no path", "home dir unavailable"))?; + let v = disky::volume::probe(&target) + .map_err(|e| DiskyError::new(ExitCode::Io, "statvfs failed", e.to_string()))?; + if format.is_machine() { + let payload = json!({ + "schema_version": SCHEMA_VERSION, + "kind": "volume", + "records": [&v], + }); + println!("{}", payload); + } else { + let gb = |b: u64| b as f64 / 1024.0_f64.powi(3); + println!("Path: {}", v.path); + println!("Total: {:>10.1} GB", gb(v.total_bytes)); + println!("Used: {:>10.1} GB ({:.0}%)", gb(v.used_bytes), v.used_pct); + println!("Free: {:>10.1} GB", gb(v.free_bytes)); + } + } Command::Churn { over, snapshot, diff --git a/src/schema.rs b/src/schema.rs index d1b4ded..0d781b4 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -43,6 +43,7 @@ fn commands() -> Value { { "name": "stats", "args": snapshot_with(&["summarize:bool", "raw:bool"]), "output": "Stats | Scalar (with --summarize) | raw u64 (with --raw)" }, { "name": "query", "args": snapshot_with(&["sql:string", "limit:int=1000"]), "output": "Object[]" }, { "name": "list", "args": {}, "output": "Snapshot[]" }, + { "name": "free", "args": { "path": "path? — default $HOME" }, "output": "VolumeRecord[] under kind='volume'. Record: {path, total_bytes:u64, free_bytes:u64, used_bytes:u64, used_pct:f64}. Live statvfs — no snapshot required." }, { "name": "diff", "args": { "a": "@latest||", "b": "@latest||", "limit": "int=100" }, diff --git a/src/volume.rs b/src/volume.rs new file mode 100644 index 0000000..93ca075 --- /dev/null +++ b/src/volume.rs @@ -0,0 +1,104 @@ +//! Live volume free-space probe. +//! +//! A snapshot cannot carry this number: `stats` reports what a scan measured +//! at scan time, while a cleanup guard needs what the filesystem says *right +//! now*. It is also the missing input for [`crate::predict`] and +//! `growth --over-n`, whose fill-by projections are inert without a byte +//! budget to extrapolate against — before this existed the caller had to +//! shell out to `df` and do the unit arithmetic by hand. + +use anyhow::{anyhow, Result}; +use serde::Serialize; +use std::ffi::CString; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Serialize)] +pub struct VolumeRecord { + /// Path that was probed (not the mount point — the volume is whichever + /// one holds this path). + pub path: String, + pub total_bytes: u64, + /// Blocks available to an unprivileged process. Smaller than the + /// root-only free space on filesystems that reserve a margin, which is + /// the honest number for "can I still write files". + pub free_bytes: u64, + pub used_bytes: u64, + /// 0..100, computed as `used / total`. Reads 1–2 points *above* `df`'s + /// Capacity column, which divides by `used + available` and so hides the + /// root-reserved margin. Neither is wrong; this one is the fraction of the + /// whole volume. + pub used_pct: f64, +} + +/// `statvfs(2)` on the volume holding `path`. +pub fn probe(path: &Path) -> Result { + let c_path = CString::new(path.as_os_str().as_encoded_bytes()) + .map_err(|_| anyhow!("path contains an interior NUL byte: {}", path.display()))?; + + // SAFETY: c_path outlives the call; statvfs only reads it. The buffer is + // fully written by the syscall on success and never read on failure. + let stat = unsafe { + let mut buf: libc::statvfs = std::mem::zeroed(); + if libc::statvfs(c_path.as_ptr(), &mut buf) != 0 { + return Err(std::io::Error::last_os_error()) + .map_err(|e| anyhow!("statvfs({}) failed: {}", path.display(), e)); + } + buf + }; + + // f_frsize is the fragment size the block counts are denominated in; + // f_bsize is the preferred I/O size and is the wrong multiplier here. + let unit = stat.f_frsize as u64; + let total_bytes = (stat.f_blocks as u64).saturating_mul(unit); + let free_bytes = (stat.f_bavail as u64).saturating_mul(unit); + let used_bytes = total_bytes.saturating_sub(free_bytes); + let used_pct = if total_bytes == 0 { + 0.0 + } else { + used_bytes as f64 / total_bytes as f64 * 100.0 + }; + + Ok(VolumeRecord { + path: path.display().to_string(), + total_bytes, + free_bytes, + used_bytes, + used_pct, + }) +} + +/// Free bytes on the volume holding `path`, or the `$HOME` volume when +/// `None`. Returns `None` on any failure — callers must treat that as +/// "unknown" and never as zero, or a probe error reads as a full disk. +pub fn free_bytes(path: Option<&Path>) -> Option { + let owned: Option = match path { + Some(p) => Some(p.to_path_buf()), + None => dirs::home_dir(), + }; + probe(owned?.as_path()).ok().map(|v| v.free_bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn probe_root_is_internally_consistent() { + let v = probe(Path::new("/")).expect("root volume must be probeable"); + assert!(v.total_bytes > 0, "total must be non-zero"); + assert!(v.free_bytes <= v.total_bytes); + assert_eq!(v.used_bytes, v.total_bytes - v.free_bytes); + assert!((0.0..=100.0).contains(&v.used_pct)); + } + + #[test] + fn probe_missing_path_errors() { + // Negative control: a bad path must fail loudly, not report 0 free. + assert!(probe(Path::new("/nonexistent-disky-probe-target")).is_err()); + } + + #[test] + fn free_bytes_defaults_to_home_volume() { + assert!(free_bytes(None).unwrap_or(0) > 0); + } +}