diff --git a/CLAUDE.md b/CLAUDE.md index ad19e08..e1fa3f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,18 +24,25 @@ Automated off conventional commits to `main`. See `docs/CONTRIBUTING.md` for the - `.github/workflows/release-pr.yml` -> `release-tag.yml` -> `release.yml` -- the three-stage pipeline driving PR -> tag -> build. - Requires `RELEASE_PAT` repo secret (fine-grained PAT with `contents:write` + `workflows`) so the tag push can trigger the build. +## Commands + +`map`, `sweep`, `config`, `free`, `volumes`/`vol`, `update`. Running `oops` with no subcommand runs `map` on the current directory. + ## Project Structure ``` crates/ -├── oops-core/ # Library: scanning, volumes, waste detection -│ ├── scan.rs # Parallel directory scanning (rayon) +├── oops-core/ # Library: scanning, volumes, config, rules, sweep +│ ├── scan.rs # Parallel directory scanning + TreeNode (rayon) │ ├── volume.rs # Volume detection via df -Pk -│ ├── sweep.rs # Waste pattern matching +│ ├── config.rs # XDG config loading (TOML) +│ ├── rules.rs # Sweep rules: built-in, user merge, matching +│ ├── sweep.rs # Rule-based sweep engine │ └── lib.rs # Public API + disk_size() helper └── oops-cli/ # Binary: commands, UI rendering - ├── commands/ # One file per subcommand (Op trait) - ├── ui.rs # ALL rendering lives here + ├── commands/ # One file/dir per subcommand (Op trait) + │ └── map/ # map command with its own render.rs + ├── ui/ # Shared rendering (colors, output, bars, tables) ├── op.rs # Op trait + Ctx + command_enum! macro └── main.rs # Entry point @@ -54,7 +61,7 @@ docs/ # Developer documentation - Use `thiserror` for typed errors in oops-core, per-command error enums in oops-cli - Each command implements the `Op` trait with typed `Error` and `Output` -- The `ui` module owns ALL rendering -- commands never format output directly +- Shared rendering lives in `ui/` module; per-command rendering in `commands//render.rs` - Use `disk_size()` (block-level via `stat.blocks * 512`) not `metadata.len()` - Status messages go to stderr, machine-readable output to stdout - CLI binary is named `oops` @@ -65,4 +72,4 @@ docs/ # Developer documentation - Skip CI checks with --no-verify - Add new dependencies without justification - Output ANSI color codes to stdout -- Put rendering logic in command files (it belongs in ui.rs) +- Put shared rendering logic in command files (it belongs in ui/) diff --git a/Cargo.toml b/Cargo.toml index 52dcd16..0f3ef82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,8 @@ rayon = "1" # Utilities bytesize = "2" +toml = "1" +dirs = "6" # Internal crates oops-core = { path = "crates/oops-core" } diff --git a/README.md b/README.md index a68a6db..2b12690 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ **Where did all my disk space go?** -Fast disk usage diagnostics for macOS and Unix. Drill into what's eating your drive, find waste, reclaim space -- in seconds. +Fast disk usage diagnostics for macOS and Unix. Map what's eating your drive, find waste, reclaim space — in seconds. ## Install @@ -29,84 +29,88 @@ oops update --force # reinstall even if already current ## Usage ```bash -oops # Size breakdown of current directory -oops drill ~ # Auto-follow the biggest child at each level -oops sweep ~ # Find reclaimable waste (node_modules, caches, build artifacts) -oops tree # Recursive size-weighted directory tree -oops top -n 30 # 30 largest files and directories +oops # Visual disk map of current directory +oops map ~/ # Map of home directory +oops map / # Full disk (APFS-aware) +oops sweep ~/ # Find reclaimable space (configurable rules) +oops sweep --exec ~/ # Interactive cleanup +oops config # Inspect/edit rule config +oops free # One-liner: how full am I? oops vol # Mounted volumes with capacity bars ``` ## What it looks like ``` -~/repos/oops (1.2 GiB) - - target/ 879.4 MiB 71.4% ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ - .git/ 194.8 MiB 15.8% ▓▓▓▓ - jig/ 142.1 MiB 11.5% ▓▓▓ - wiki/ 8.2 MiB 0.7% - crates/ 6.4 MiB 0.5% + / (460.4 GiB total, 27.0 GiB free) + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + scanned 270.6 GiB other 118.8 GiB free 71.0 GiB + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 270.6 GiB ~/ + ━━━━━━━━━━━━━ 91.7 GiB Library/ + ━━━━━ 32.8 GiB Application Support/ + ━━ 18.7 GiB Caches/ + ━━━━━━ 55.6 GiB repos/ + ━━━━━ 45.4 GiB zim/ ``` ## Commands | Command | What it does | |---------|-------------| -| `oops` | Proportional size breakdown of a directory | -| `oops drill` | Auto-follow the largest child at each level until space is evenly distributed | -| `oops sweep` | Detect node_modules, build artifacts, caches, Docker data, platform cruft | -| `oops tree` | Recursive size-weighted directory tree | -| `oops top` | Top-N largest files and directories | +| `oops` / `oops map` | Visual disk map — proportional bars, one entry per line | +| `oops sweep` | Find reclaimable space using configurable rules; `--exec` for interactive cleanup | +| `oops config` | Show or initialize config (`~/.config/oops/config.toml`) and rules | +| `oops free` | One-liner volume free-space summary | | `oops vol` | Mounted volumes with color-coded capacity bars | | `oops update` | Self-update to the latest GitHub release | ## Why oops? - **Fast.** Parallel scanning with rayon. Most directories render instantly. -- **Honest sizes.** Reports on-disk block usage, not apparent file sizes. A 1 TiB sparse Docker image that only uses 20 GiB of blocks shows as 20 GiB. -- **Terminal-native.** Colored output with progress bars, tree drawing, and proportional bars. Machine-readable output goes to stdout. -- **No config needed.** Just run `oops`. +- **Honest sizes.** Reports on-disk block usage, not apparent file sizes. A 1 TiB sparse Docker image using 20 GiB of blocks shows as 20 GiB. +- **APFS-aware.** `oops map /` handles macOS firmlinks correctly — no double-counting. +- **Configurable cleanup.** Built-in sweep rules plus your own custom patterns via TOML config. ## Global flags | Flag | Effect | |------|--------| -| `--plain` | No colors, no decorations -- for scripting | +| `--plain` | No colors, no decorations — for scripting | | `-v` / `--verbose` | Debug tracing output to stderr | ## Recipes **My disk is full and I don't know why:** ```bash -oops drill ~ +oops map ~/ ``` -**Where are all my node_modules?** +**What can I clean up right now?** ```bash -oops sweep ~ +oops sweep ~/ --exec ``` -**What are the biggest files on my machine?** +**Where are all my node_modules?** ```bash -oops top ~ --depth 8 --files-only --min-size 500MB +oops sweep ~/ --rule node_modules -v ``` **Is Docker eating my disk?** ```bash -oops drill ~/Library/Containers/com.docker.docker +oops map ~/Library/Containers/com.docker.docker ``` **Xcode is eating 50 GB again:** ```bash -oops drill ~/Library/Developer +oops sweep ~/ --rule xcode --exec ``` ## Architecture ``` crates/ -├── oops-core/ # Library: scanning, volumes, waste detection +├── oops-core/ # Library: scanning, volumes, config, rules, sweep engine └── oops-cli/ # Binary: commands, UI rendering, terminal output ``` diff --git a/crates/oops-cli/src/cli.rs b/crates/oops-cli/src/cli.rs index 3bf0cbc..53a6e9a 100644 --- a/crates/oops-cli/src/cli.rs +++ b/crates/oops-cli/src/cli.rs @@ -29,25 +29,20 @@ pub struct Cli { } crate::command_enum! { - #[command(visible_alias = "o")] - (Overview, commands::Overview), - - #[command(visible_alias = "vol")] - (Volumes, commands::Volumes), - - #[command(visible_alias = "t")] - (Top, commands::Top), - - (Tree, commands::Tree), + #[command(visible_alias = "m")] + (Map, commands::Map), #[command(visible_alias = "s")] (Sweep, commands::Sweep), - #[command(visible_alias = "d")] - (Drill, commands::Drill), + #[command(visible_alias = "c")] + (Config, commands::Config), #[command(visible_alias = "f")] (Free, commands::Free), + #[command(visible_alias = "vol")] + (Volumes, commands::Volumes), + (Update, commands::Update), } diff --git a/crates/oops-cli/src/commands/config.rs b/crates/oops-cli/src/commands/config.rs new file mode 100644 index 0000000..f6d20c0 --- /dev/null +++ b/crates/oops-cli/src/commands/config.rs @@ -0,0 +1,121 @@ +//! Config command -- inspect and initialize oops configuration + +use clap::{Args, Subcommand}; + +use oops_core::config::{self, OopsConfig}; +use oops_core::resolve_rules; + +use crate::op::{Ctx, NoOutput, Op}; +use crate::ui; + +/// Manage oops configuration +#[derive(Args, Debug, Clone)] +pub struct Config { + #[command(subcommand)] + pub action: Option, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum ConfigAction { + /// Create default config file + Init, +} + +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error(transparent)] + Core(#[from] oops_core::Error), +} + +impl Op for Config { + type Error = ConfigError; + type Output = NoOutput; + + fn run(&self, _ctx: &Ctx) -> Result { + match &self.action { + None => show_config(), + Some(ConfigAction::Init) => init_config(), + } + } +} + +fn show_config() -> Result { + let path = config::config_path(); + + if !path.exists() { + let dir = config::config_dir(); + std::fs::create_dir_all(&dir).map_err(oops_core::Error::Io)?; + std::fs::write(&path, config::DEFAULT_CONFIG_CONTENT).map_err(oops_core::Error::Io)?; + ui::success(&format!("created {}", path.display())); + } + + ui::header("oops config"); + eprintln!(); + eprintln!( + " {} {}", + ui::bold("path:"), + ui::highlight(&path.display().to_string()), + ); + + let config = OopsConfig::load()?; + let rules = resolve_rules(&config)?; + + let builtin_count = oops_core::builtin_rules().len(); + let disabled = config.disabled_builtins(); + let user_added = config + .rules + .iter() + .filter(|r| r.enabled != Some(false)) + .filter(|r| !oops_core::builtin_rules().iter().any(|b| b.name == r.name)) + .count(); + + eprintln!(); + ui::header("rules"); + eprintln!(); + eprintln!( + " {} {} built-in, {} active", + ui::bold("loaded:"), + builtin_count, + rules.len(), + ); + + if !disabled.is_empty() { + eprintln!(" {} {}", ui::bold("disabled:"), disabled.join(", "),); + } + + if user_added > 0 { + eprintln!(" {} {}", ui::bold("user-added:"), user_added,); + } + + eprintln!(); + for rule in &rules { + let clean_indicator = if rule.clean.is_some() { "+" } else { "-" }; + eprintln!( + " {} {} {} {}", + ui::dim(clean_indicator), + ui::highlight(&rule.name), + ui::dim(&format!("({})", rule.description)), + match &rule.match_mode { + oops_core::MatchMode::DirBasename(d) => format!("dir={d}"), + oops_core::MatchMode::AbsolutePath(p) => format!("path={}", ui::short_path(p)), + oops_core::MatchMode::FileGlob(g) => format!("glob={g}"), + }, + ); + } + + Ok(NoOutput) +} + +fn init_config() -> Result { + let path = config::config_path(); + + if path.exists() { + ui::detail(&format!("config already exists at {}", path.display())); + } else { + let dir = config::config_dir(); + std::fs::create_dir_all(&dir).map_err(oops_core::Error::Io)?; + std::fs::write(&path, config::DEFAULT_CONFIG_CONTENT).map_err(oops_core::Error::Io)?; + ui::success(&format!("created {}", path.display())); + } + Ok(NoOutput) +} diff --git a/crates/oops-cli/src/commands/drill.rs b/crates/oops-cli/src/commands/drill.rs deleted file mode 100644 index 0a2eb9e..0000000 --- a/crates/oops-cli/src/commands/drill.rs +++ /dev/null @@ -1,136 +0,0 @@ -//! Drill command -- follow the biggest child at each level - -use std::path::PathBuf; - -use clap::Args; - -use oops_core::{scan_top_entries, DirEntry, ScanOptions}; - -use crate::op::{Ctx, NoOutput, Op}; -use crate::ui; - -/// Drill into the largest subdirectory at each level -#[derive(Args, Debug, Clone)] -pub struct Drill { - /// Target path to drill into - pub path: Option, - - /// Maximum levels to drill down - #[arg(short, long, default_value = "10")] - pub depth: usize, - - /// Stop drilling when the largest child is below this percentage of its parent - #[arg(long, default_value = "25.0")] - pub threshold: f64, - - /// Number of sibling entries to show at each level - #[arg(short = 'n', long, default_value = "5")] - pub show: usize, -} - -#[derive(Debug, thiserror::Error)] -pub enum DrillError { - #[error(transparent)] - Core(#[from] oops_core::Error), -} - -/// One level of the drill-down trace. -pub struct DrillLevel { - pub dir_name: String, - pub total: u64, - pub entries: Vec, - pub show_count: usize, - pub stop_reason: Option, -} - -pub enum StopReason { - BelowThreshold { pct: f64, threshold: f64 }, - NoSubdirectories, - Empty, -} - -impl Op for Drill { - type Error = DrillError; - type Output = NoOutput; - - fn run(&self, ctx: &Ctx) -> Result { - let opts = ScanOptions::default(); - let start = self.path.as_ref().unwrap_or(&ctx.path); - let mut current = start.clone(); - let mut trail: Vec = Vec::new(); - - for depth in 0..self.depth { - let spinner = ui::Spinner::start("scanning..."); - let mut entries = scan_top_entries(¤t, &opts)?; - spinner.stop(); - entries.sort_by_key(|e| std::cmp::Reverse(e.size)); - - let total: u64 = entries.iter().map(|e| e.size).sum(); - let dir_name = dir_display_name(¤t, start); - - if total == 0 || entries.is_empty() { - let level = DrillLevel { - dir_name: dir_name.clone(), - total, - entries, - show_count: 0, - stop_reason: Some(StopReason::Empty), - }; - trail.push(dir_name); - ui::render_drill_level(&level, depth, false); - break; - } - - // Find biggest dir and compute stop reason before moving entries - let biggest_dir_path = entries.iter().find(|e| e.is_dir).map(|e| e.path.clone()); - let stop_reason = match &biggest_dir_path { - Some(_) => { - let biggest = entries.iter().find(|e| e.is_dir).unwrap(); - let pct = (biggest.size as f64 / total as f64) * 100.0; - if pct < self.threshold { - Some(StopReason::BelowThreshold { - pct, - threshold: self.threshold, - }) - } else { - None - } - } - None => Some(StopReason::NoSubdirectories), - }; - - let should_stop = stop_reason.is_some(); - - let level = DrillLevel { - dir_name: dir_name.clone(), - total, - entries, - show_count: self.show, - stop_reason, - }; - - trail.push(dir_name); - ui::render_drill_level(&level, depth, !should_stop); - - if should_stop { - break; - } - - current = biggest_dir_path.unwrap(); - } - - ui::render_drill_trail(&trail); - - Ok(NoOutput) - } -} - -fn dir_display_name(path: &PathBuf, root: &PathBuf) -> String { - if path == root { - ui::short_path(root) - } else { - path.file_name() - .map(|n| format!("{}/", n.to_string_lossy())) - .unwrap_or_else(|| ui::short_path(path)) - } -} diff --git a/crates/oops-cli/src/commands/map/mod.rs b/crates/oops-cli/src/commands/map/mod.rs new file mode 100644 index 0000000..f28d561 --- /dev/null +++ b/crates/oops-cli/src/commands/map/mod.rs @@ -0,0 +1,117 @@ +//! Map command -- visual disk usage map + +mod render; + +use std::path::PathBuf; + +use clap::Args; + +use oops_core::{list_volumes, scan_tree, ScanOptions, Volume}; + +use crate::op::{Ctx, NoOutput, Op}; +use crate::ui; + +/// Show where your disk space went +#[derive(Args, Debug, Clone)] +pub struct Map { + /// Target path to analyze + pub path: Option, + + /// Maximum depth levels to display + #[arg(short, long, default_value = "4")] + pub depth: usize, + + /// Max entries shown per directory level + #[arg(short = 'n', long, default_value = "5")] + pub show: usize, + + /// Max children to recurse into per level + #[arg(long, default_value = "3")] + pub expand: usize, + + /// Terminal width override (default: auto-detect) + #[arg(long)] + pub width: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum MapError { + #[error(transparent)] + Core(#[from] oops_core::Error), +} + +impl Op for Map { + type Error = MapError; + type Output = NoOutput; + + fn run(&self, ctx: &Ctx) -> Result { + let target = self.path.as_ref().unwrap_or(&ctx.path); + let scan_target = resolve_scan_target(target); + let canonical = std::fs::canonicalize(&scan_target).unwrap_or_else(|_| scan_target.clone()); + + let skip_paths = mount_points_except(&canonical); + + let opts = ScanOptions { + one_filesystem: true, + skip_paths, + ..Default::default() + }; + + let spinner = ui::Spinner::start("scanning..."); + let tree = scan_tree(&scan_target, self.depth, &opts)?; + spinner.stop(); + + let volume = find_volume_for(target); + + let tw = self.width.unwrap_or_else(ui::term_width); + + render::render( + &tree, + volume.as_ref(), + tw, + self.depth, + self.show, + self.expand, + ); + + Ok(NoOutput) + } +} + +fn find_volume_for(path: &std::path::Path) -> Option { + let volumes = list_volumes().ok()?; + let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + volumes + .into_iter() + .filter(|v| canonical.starts_with(&v.mount_point)) + .max_by_key(|v| v.mount_point.as_os_str().len()) +} + +/// Return paths to skip during scanning to avoid APFS double counting. +/// +/// On macOS with APFS, `/` is a sealed system volume and `/System/Volumes/Data` +/// is the writable data volume. Firmlinks from `/Users`, `/Library`, etc. point +/// into the data volume, so scanning `/` would count data twice. We skip +/// child mount points AND, when scanning `/`, redirect to the data volume. +fn mount_points_except(target: &std::path::Path) -> std::collections::HashSet { + let volumes = match list_volumes() { + Ok(v) => v, + Err(_) => return Default::default(), + }; + volumes + .iter() + .filter(|v| v.mount_point != target && v.mount_point.starts_with(target)) + .map(|v| v.mount_point.clone()) + .collect() +} + +/// If scanning `/` on macOS, redirect to `/System/Volumes/Data` to avoid +/// double counting via APFS firmlinks. +fn resolve_scan_target(target: &std::path::Path) -> std::path::PathBuf { + let data_vol = std::path::Path::new("/System/Volumes/Data"); + if target == std::path::Path::new("/") && data_vol.exists() { + data_vol.to_path_buf() + } else { + target.to_path_buf() + } +} diff --git a/crates/oops-cli/src/commands/map/render.rs b/crates/oops-cli/src/commands/map/render.rs new file mode 100644 index 0000000..c348832 --- /dev/null +++ b/crates/oops-cli/src/commands/map/render.rs @@ -0,0 +1,266 @@ +//! Proportional tree renderer for the map command. +//! +//! Each entry gets its own line: a proportional bar, size, and indented name. +//! The bar width encodes size relative to the root — your eye immediately +//! finds the biggest entries. Hierarchy is shown via indentation. + +use colored::Colorize; + +use oops_core::{TreeNode, Volume}; + +use crate::ui; + +const BAR_CHAR: &str = "\u{2501}"; // ━ +const BAR_CHAR_PLAIN: &str = "="; + +const DEPTH_COLORS: &[fn(&str) -> String] = &[ + |s: &str| s.cyan().to_string(), + |s: &str| s.green().to_string(), + |s: &str| s.yellow().to_string(), + |s: &str| s.magenta().to_string(), + |s: &str| s.blue().to_string(), + |s: &str| s.red().to_string(), +]; + +struct Row { + name: String, + size: u64, + depth: usize, + is_dir: bool, + is_aggregate: bool, +} + +pub fn render( + root: &TreeNode, + volume: Option<&Volume>, + term_width: usize, + max_depth: usize, + show: usize, + expand: usize, +) { + if root.size == 0 { + eprintln!(" (empty or inaccessible)"); + return; + } + + // Volume context + if let Some(vol) = volume { + render_disk_context(vol, root, term_width); + eprintln!(); + } + + // Build rows + let rows = build_rows(root, max_depth, show, expand); + + // Layout: 2 margin + bar + 2 gap + 10 size + 2 gap + indent+name + let bar_width = ((term_width as f64) * 0.38).round() as usize; + let bar_width = bar_width.clamp(12, 60); + let size_col = 10; + + for row in &rows { + render_row(row, root.size, bar_width, size_col, term_width); + } + eprintln!(); +} + +// --------------------------------------------------------------------------- +// Row collection +// --------------------------------------------------------------------------- + +fn build_rows(root: &TreeNode, max_depth: usize, show: usize, expand: usize) -> Vec { + let mut rows = Vec::new(); + + // Root row — show `/` instead of `/System/Volumes/Data/` (APFS redirect) + let short = ui::short_path(&root.path); + let root_name = if short == "/System/Volumes/Data" || short == "/System/Volumes/Data/" { + "/".to_string() + } else if root.is_dir && !short.ends_with('/') { + format!("{}/", short) + } else { + short + }; + rows.push(Row { + name: root_name, + size: root.size, + depth: 0, + is_dir: root.is_dir, + is_aggregate: false, + }); + + collect_children(&root.children, 1, max_depth, show, expand, &mut rows); + rows +} + +fn collect_children( + children: &[TreeNode], + depth: usize, + max_depth: usize, + show: usize, + expand: usize, + rows: &mut Vec, +) { + if depth > max_depth || children.is_empty() { + return; + } + + let visible_count = show.min(children.len()); + let visible = &children[..visible_count]; + let hidden = &children[visible_count..]; + + for (i, child) in visible.iter().enumerate() { + let name = if child.is_dir { + format!("{}/", child.name) + } else { + child.name.clone() + }; + + rows.push(Row { + name, + size: child.size, + depth, + is_dir: child.is_dir, + is_aggregate: false, + }); + + if i < expand && child.is_dir && !child.children.is_empty() { + collect_children(&child.children, depth + 1, max_depth, show, expand, rows); + } + } + + if !hidden.is_empty() { + let hidden_size: u64 = hidden.iter().map(|c| c.size).sum(); + rows.push(Row { + name: format!("({} others)", hidden.len()), + size: hidden_size, + depth, + is_dir: false, + is_aggregate: true, + }); + } +} + +// --------------------------------------------------------------------------- +// Row rendering +// --------------------------------------------------------------------------- + +fn render_row(row: &Row, root_size: u64, bar_width: usize, size_col: usize, term_width: usize) { + let frac = if root_size > 0 { + row.size as f64 / root_size as f64 + } else { + 0.0 + }; + let filled = (frac * bar_width as f64).round() as usize; + let empty = bar_width.saturating_sub(filled); + + let indent = " ".repeat(row.depth); + let size_str = ui::fmt_size(row.size); + + // Name budget: whatever is left after margin + bar + gaps + size + let overhead = 2 + bar_width + 2 + size_col + 2; + let name_budget = term_width.saturating_sub(overhead); + let full_name = format!("{}{}", indent, row.name); + let display_name = if full_name.len() > name_budget && name_budget > 4 { + ui::truncate(&full_name, name_budget) + } else { + full_name + }; + + if ui::is_plain() { + let bar = BAR_CHAR_PLAIN.repeat(filled); + eprintln!( + " {}{} {:>w$} {}", + bar, + " ".repeat(empty), + size_str, + display_name, + w = size_col, + ); + } else { + let color_fn = DEPTH_COLORS[row.depth % DEPTH_COLORS.len()]; + let bar = color_fn(&BAR_CHAR.repeat(filled)); + let pad = " ".repeat(empty); + + let name_display = if row.is_aggregate { + ui::dim(&display_name) + } else if row.is_dir { + ui::bold(&display_name) + } else { + display_name + }; + + let size_display = color_fn(&format!("{:>w$}", size_str, w = size_col)); + + eprintln!(" {}{} {} {}", bar, pad, size_display, name_display); + } +} + +// --------------------------------------------------------------------------- +// Disk context +// --------------------------------------------------------------------------- + +fn render_disk_context(vol: &Volume, root: &TreeNode, term_width: usize) { + let total = vol.total; + if total == 0 { + return; + } + + let scanned = root.size; + let free = vol.available; + // Scanned can exceed total on APFS (firmlinks cause double counting) + let used = total.saturating_sub(free); + let other = used.saturating_sub(scanned); + + eprintln!( + " {} {}", + ui::bold(&vol.mount_point.display().to_string()), + ui::dim(&format!( + "({} total, {} free)", + ui::fmt_size(total), + ui::fmt_size(free), + )), + ); + + let is_root_scan = root.path == vol.mount_point || root.path.to_str() == Some("/"); + let other_pct = other as f64 / total as f64; + if !is_root_scan && other_pct > 0.10 { + eprintln!( + " {}", + ui::dim(&format!( + "tip: {} unaccounted for — run 'oops map {}' to explore", + ui::fmt_size(other), + vol.mount_point.display(), + )), + ); + } + + // Compact context bar + let bar_w = (term_width as f64 * 0.38).round().clamp(12.0, 60.0) as usize; + let s_w = (scanned as f64 / total as f64 * bar_w as f64).round() as usize; + let f_w = (free as f64 / total as f64 * bar_w as f64).round() as usize; + let o_w = bar_w.saturating_sub(s_w).saturating_sub(f_w); + + if ui::is_plain() { + eprintln!( + " {}{}{}", + "#".repeat(s_w), + "=".repeat(o_w), + "-".repeat(f_w), + ); + } else { + eprintln!( + " {}{}{}", + BAR_CHAR.repeat(s_w).cyan(), + BAR_CHAR.repeat(o_w).dimmed(), + BAR_CHAR.repeat(f_w).green().dimmed(), + ); + } + + // Legend + let legend = format!( + "scanned {} other {} free {}", + ui::fmt_size(scanned), + ui::fmt_size(other), + ui::fmt_size(free), + ); + eprintln!(" {}", ui::dim(&legend)); +} diff --git a/crates/oops-cli/src/commands/mod.rs b/crates/oops-cli/src/commands/mod.rs index 82ef611..2c00995 100644 --- a/crates/oops-cli/src/commands/mod.rs +++ b/crates/oops-cli/src/commands/mod.rs @@ -1,17 +1,13 @@ -pub mod drill; +pub mod config; pub mod free; -pub mod overview; +pub mod map; pub mod sweep; -pub mod top; -pub mod tree; pub mod update; pub mod volumes; -pub use drill::Drill; +pub use config::Config; pub use free::Free; -pub use overview::Overview; +pub use map::Map; pub use sweep::Sweep; -pub use top::Top; -pub use tree::Tree; pub use update::Update; pub use volumes::Volumes; diff --git a/crates/oops-cli/src/commands/overview.rs b/crates/oops-cli/src/commands/overview.rs deleted file mode 100644 index f27fbc7..0000000 --- a/crates/oops-cli/src/commands/overview.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! Overview command -- default view showing volume summary + directory breakdown - -use std::path::PathBuf; - -use clap::Args; - -use oops_core::{list_volumes, scan_top_entries, ScanOptions}; - -use crate::op::{Ctx, NoOutput, Op}; -use crate::ui; - -/// Show disk usage overview (volumes + directory breakdown) -#[derive(Args, Debug, Clone)] -pub struct Overview { - /// Target path to analyze - pub path: Option, -} - -#[derive(Debug, thiserror::Error)] -pub enum OverviewError { - #[error(transparent)] - Core(#[from] oops_core::Error), -} - -impl Op for Overview { - type Error = OverviewError; - type Output = NoOutput; - - fn run(&self, ctx: &Ctx) -> Result { - let target = self.path.as_ref().unwrap_or(&ctx.path); - let explicit = self.path.is_some() || ctx.explicit_path; - - let _ = explicit; // reserved for future use - - let opts = ScanOptions::default(); - let spinner = ui::Spinner::start("scanning..."); - let mut entries = scan_top_entries(target, &opts)?; - spinner.stop(); - entries.sort_by_key(|e| std::cmp::Reverse(e.size)); - - let total_size: u64 = entries.iter().map(|e| e.size).sum(); - - // Find disk total for the volume containing the target path - let disk_total = std::fs::canonicalize(target).ok().and_then(|canonical| { - list_volumes().ok().and_then(|volumes| { - volumes - .iter() - .filter(|v| canonical.starts_with(&v.mount_point)) - .max_by_key(|v| v.mount_point.as_os_str().len()) - .map(|v| v.total) - }) - }); - - ui::render_dir_breakdown(target, &entries, total_size, disk_total); - - Ok(NoOutput) - } -} diff --git a/crates/oops-cli/src/commands/sweep.rs b/crates/oops-cli/src/commands/sweep.rs index 8e55c61..6f26f25 100644 --- a/crates/oops-cli/src/commands/sweep.rs +++ b/crates/oops-cli/src/commands/sweep.rs @@ -1,27 +1,37 @@ -//! Sweep command -- find common disk space wasters +//! Sweep command -- find reclaimable disk space using configurable rules +use std::io::{self, BufRead, Write}; use std::path::PathBuf; +use std::process::Command; use clap::Args; -use oops_core::sweep_directory; +use oops_core::{config::OopsConfig, resolve_rules, sweep_directory, SweepMatch}; use crate::op::{Ctx, NoOutput, Op}; use crate::ui; -/// Find common disk space wasters (node_modules, caches, build artifacts) +/// Find reclaimable disk space (caches, build artifacts, etc.) #[derive(Args, Debug, Clone)] pub struct Sweep { /// Target path to scan pub path: Option, - /// Maximum depth to scan for waste + /// Maximum depth to scan #[arg(short, long, default_value = "6")] pub depth: usize, /// Show individual entries (not just summaries) #[arg(short, long)] pub verbose: bool, + + /// Interactively confirm and execute cleanup commands + #[arg(short, long)] + pub exec: bool, + + /// Filter to specific rule(s) by name + #[arg(long)] + pub rule: Option>, } #[derive(Debug, thiserror::Error)] @@ -37,13 +47,194 @@ impl Op for Sweep { fn run(&self, ctx: &Ctx) -> Result { let target = self.path.as_ref().unwrap_or(&ctx.path); + let config = OopsConfig::load()?; + let mut rules = resolve_rules(&config)?; + + if let Some(ref filter) = self.rule { + rules.retain(|r| filter.iter().any(|f| r.name.contains(f))); + } + let spinner = ui::Spinner::start(&format!( - "sweeping {} for reclaimable space...", - ui::short_path(target) + "sweeping {} ({} rules)...", + ui::short_path(target), + rules.len() )); - let entries = sweep_directory(target, self.depth)?; + let matches = sweep_directory(target, &rules, self.depth)?; spinner.stop(); - ui::render_sweep_results(&entries, self.verbose); + + if self.exec { + run_interactive(&matches); + } else { + render_sweep_results(&matches, self.verbose); + } + Ok(NoOutput) } } + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +fn render_sweep_results(matches: &[SweepMatch], verbose: bool) { + if matches.is_empty() { + ui::success("No reclaimable space found."); + return; + } + + // Group by rule + let mut by_rule: Vec<(String, String, u64, usize, bool)> = Vec::new(); + for m in matches { + if let Some(entry) = by_rule.iter_mut().find(|e| e.0 == m.rule_name) { + entry.2 += m.size; + entry.3 += 1; + } else { + by_rule.push(( + m.rule_name.clone(), + m.description.clone(), + m.size, + 1, + m.clean_command.is_some(), + )); + } + } + by_rule.sort_by_key(|e| std::cmp::Reverse(e.2)); + + let total: u64 = by_rule.iter().map(|e| e.2).sum(); + + ui::header("Reclaimable Space"); + eprintln!(); + + let mut table = ui::new_table(&["RULE", "SIZE", "COUNT", "DESCRIPTION"]); + for (name, desc, size, count, _) in &by_rule { + use comfy_table::{Cell, CellAlignment, Color}; + table.add_row(vec![ + Cell::new(name).fg(Color::Cyan), + Cell::new(ui::fmt_size(*size)) + .fg(Color::Yellow) + .set_alignment(CellAlignment::Right), + Cell::new(count).set_alignment(CellAlignment::Right), + Cell::new(desc).fg(Color::DarkGrey), + ]); + } + eprintln!("{table}"); + eprintln!(); + eprintln!( + " {} {}", + ui::bold("Total reclaimable:"), + ui::highlight(&ui::fmt_size(total)), + ); + + if verbose { + eprintln!(); + ui::header("Individual Entries"); + eprintln!(); + let mut detail = ui::new_table(&["SIZE", "RULE", "PATH"]); + for m in matches.iter().take(50) { + use comfy_table::{Cell, CellAlignment, Color}; + detail.add_row(vec![ + Cell::new(ui::fmt_size(m.size)) + .fg(Color::Yellow) + .set_alignment(CellAlignment::Right), + Cell::new(&m.rule_name).fg(Color::Cyan), + Cell::new(ui::short_path(&m.path)).fg(Color::White), + ]); + } + eprintln!("{detail}"); + if matches.len() > 50 { + eprintln!( + " {} ({} more not shown)", + ui::dim("..."), + matches.len() - 50, + ); + } + } + + let cleanable: usize = matches.iter().filter(|m| m.clean_command.is_some()).count(); + if cleanable > 0 { + eprintln!(); + eprintln!( + " {}", + ui::dim(&format!( + "{cleanable} entries have cleanup commands — run with --exec to clean interactively" + )), + ); + } +} + +// --------------------------------------------------------------------------- +// Interactive execution +// --------------------------------------------------------------------------- + +fn run_interactive(matches: &[SweepMatch]) { + if matches.is_empty() { + ui::success("No reclaimable space found."); + return; + } + + let mut run_all = false; + let stdin = io::stdin(); + let mut stdout = io::stderr(); + + for m in matches { + eprintln!(); + eprintln!( + " {} {} {}", + ui::bold(&ui::short_path(&m.path)), + ui::dim(&format!("({})", ui::fmt_size(m.size))), + ui::highlight(&m.rule_name), + ); + + let cmd = match &m.clean_command { + Some(c) => c, + None => { + eprintln!(" {}", ui::dim("(no cleanup command — remove manually)")); + continue; + } + }; + + eprintln!(" {} {}", ui::dim("run:"), cmd); + + if !run_all { + let _ = write!(stdout, " execute? [y/N/a/q] "); + let _ = stdout.flush(); + + let mut input = String::new(); + if stdin.lock().read_line(&mut input).is_err() { + break; + } + + match input.trim().to_lowercase().as_str() { + "a" => run_all = true, + "q" => { + eprintln!(" {}", ui::dim("quit")); + break; + } + "y" => {} + _ => { + eprintln!(" {}", ui::dim("skipped")); + continue; + } + } + } + + // Execute + let result = Command::new("sh").arg("-c").arg(cmd).status(); + + match result { + Ok(status) if status.success() => { + ui::success(&format!("done ({})", ui::fmt_size(m.size))); + } + Ok(status) => { + eprintln!( + " {} exited with {}", + ui::dim("command"), + status.code().unwrap_or(-1), + ); + } + Err(e) => { + eprintln!(" {} {}", ui::dim("error:"), e); + } + } + } +} diff --git a/crates/oops-cli/src/commands/top.rs b/crates/oops-cli/src/commands/top.rs deleted file mode 100644 index 5295f74..0000000 --- a/crates/oops-cli/src/commands/top.rs +++ /dev/null @@ -1,112 +0,0 @@ -//! Top command -- find largest files and directories - -use std::path::PathBuf; - -use clap::Args; - -use oops_core::{scan_directory, ScanOptions}; - -use crate::op::{Ctx, NoOutput, Op}; -use crate::ui; - -/// Find the largest files and directories -#[derive(Args, Debug, Clone)] -pub struct Top { - /// Target path to scan - pub path: Option, - - /// Number of results to show - #[arg(short = 'n', long, default_value = "20")] - pub count: usize, - - /// Maximum depth to scan - #[arg(short, long, default_value = "5")] - pub depth: usize, - - /// Show only files (no directories) - #[arg(long)] - pub files_only: bool, - - /// Show only directories - #[arg(long)] - pub dirs_only: bool, - - /// Minimum size to show (e.g. "100MB", "1GB") - #[arg(long)] - pub min_size: Option, -} - -#[derive(Debug, thiserror::Error)] -pub enum TopError { - #[error(transparent)] - Core(#[from] oops_core::Error), - #[error("invalid size: {0}")] - InvalidSize(String), -} - -impl Op for Top { - type Error = TopError; - type Output = NoOutput; - - fn run(&self, ctx: &Ctx) -> Result { - let target = self.path.as_ref().unwrap_or(&ctx.path); - let min_bytes = self.parse_min_size()?; - - let opts = ScanOptions { - max_depth: Some(self.depth), - ..Default::default() - }; - - let spinner = ui::Spinner::start(&format!( - "scanning {} (depth {})...", - ui::short_path(target), - self.depth - )); - let mut entries = scan_directory(target, &opts)?; - spinner.stop(); - - if self.files_only { - entries.retain(|e| !e.is_dir); - } - if self.dirs_only { - entries.retain(|e| e.is_dir); - } - if let Some(min) = min_bytes { - entries.retain(|e| e.size >= min); - } - - entries.sort_by_key(|e| std::cmp::Reverse(e.size)); - entries.truncate(self.count); - - ui::render_top_entries(&entries, target); - Ok(NoOutput) - } -} - -impl Top { - fn parse_min_size(&self) -> Result, TopError> { - let s = match &self.min_size { - Some(s) => s, - None => return Ok(None), - }; - - let s = s.trim().to_uppercase(); - let (num_str, multiplier) = if let Some(n) = s.strip_suffix("GB") { - (n, 1024u64 * 1024 * 1024) - } else if let Some(n) = s.strip_suffix("MB") { - (n, 1024 * 1024) - } else if let Some(n) = s.strip_suffix("KB") { - (n, 1024) - } else if let Some(n) = s.strip_suffix('B') { - (n, 1) - } else { - (s.as_str(), 1) - }; - - let num: f64 = num_str - .trim() - .parse() - .map_err(|_| TopError::InvalidSize(s.clone()))?; - Ok(Some((num * multiplier as f64) as u64)) - } -} diff --git a/crates/oops-cli/src/commands/tree.rs b/crates/oops-cli/src/commands/tree.rs deleted file mode 100644 index 3da27e0..0000000 --- a/crates/oops-cli/src/commands/tree.rs +++ /dev/null @@ -1,85 +0,0 @@ -//! Tree command -- visual directory size tree - -use std::path::PathBuf; - -use clap::Args; - -use oops_core::{scan_top_entries, ScanOptions}; - -use crate::op::{Ctx, NoOutput, Op}; -use crate::ui; - -/// Show a visual directory size tree -#[derive(Args, Debug, Clone)] -pub struct Tree { - /// Target path to analyze - pub path: Option, - - /// Maximum depth to display - #[arg(short, long, default_value = "3")] - pub depth: usize, - - /// Minimum percentage of parent to show - #[arg(long, default_value = "1.0")] - pub min_pct: f64, -} - -#[derive(Debug, thiserror::Error)] -pub enum TreeError { - #[error(transparent)] - Core(#[from] oops_core::Error), -} - -impl Op for Tree { - type Error = TreeError; - type Output = NoOutput; - - fn run(&self, ctx: &Ctx) -> Result { - let target = self.path.as_ref().unwrap_or(&ctx.path); - let opts = ScanOptions::default(); - - let spinner = ui::Spinner::start("scanning..."); - let entries = scan_top_entries(target, &opts)?; - spinner.stop(); - let total: u64 = entries.iter().map(|e| e.size).sum(); - - ui::header(&ui::short_path(target).to_string()); - eprintln!(" {} total", ui::highlight(&ui::fmt_size(total))); - eprintln!(); - - self.print_level(target, &opts, 0)?; - Ok(NoOutput) - } -} - -impl Tree { - fn print_level( - &self, - path: &std::path::Path, - opts: &ScanOptions, - depth: usize, - ) -> Result<(), TreeError> { - if depth >= self.depth { - return Ok(()); - } - - let mut entries = scan_top_entries(path, opts)?; - entries.sort_by_key(|e| std::cmp::Reverse(e.size)); - - let parent_total: u64 = entries.iter().map(|e| e.size).sum(); - - ui::render_tree_node(&entries, parent_total, depth, self.min_pct); - - // Recurse into directories that passed the filter - for entry in &entries { - if entry.is_dir && parent_total > 0 { - let pct = (entry.size as f64 / parent_total as f64) * 100.0; - if pct >= self.min_pct { - self.print_level(&entry.path, opts, depth + 1)?; - } - } - } - - Ok(()) - } -} diff --git a/crates/oops-cli/src/main.rs b/crates/oops-cli/src/main.rs index 6b745e0..f6779f0 100644 --- a/crates/oops-cli/src/main.rs +++ b/crates/oops-cli/src/main.rs @@ -46,16 +46,20 @@ fn run(cli: Cli) -> Result<(), Box> { colored::control::set_override(true); } - let explicit_path = cli.path.is_some(); let ctx = Ctx { path: resolve_path(cli.path), - explicit_path, }; match cli.command { None => { - let overview = commands::Overview { path: None }; - overview.run(&ctx)?; + let map = commands::Map { + path: None, + depth: 4, + show: 5, + expand: 3, + width: None, + }; + map.run(&ctx)?; Ok(()) } Some(ref command) => { diff --git a/crates/oops-cli/src/op.rs b/crates/oops-cli/src/op.rs index 4dbac78..03aedd4 100644 --- a/crates/oops-cli/src/op.rs +++ b/crates/oops-cli/src/op.rs @@ -6,7 +6,6 @@ use std::path::PathBuf; pub struct Ctx { pub path: PathBuf, - pub explicit_path: bool, } pub trait Op { diff --git a/crates/oops-cli/src/ui.rs b/crates/oops-cli/src/ui.rs deleted file mode 100644 index 199931c..0000000 --- a/crates/oops-cli/src/ui.rs +++ /dev/null @@ -1,848 +0,0 @@ -//! Shared rendering utilities for CLI output. -//! -//! This module owns ALL visual output: status messages, color helpers, -//! bar rendering, table construction, and formatting. Commands call into -//! here rather than building tables or formatting directly. - -use std::collections::HashMap; -use std::io::Write; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; - -use bytesize::ByteSize; -use colored::Colorize; -use comfy_table::{presets, Attribute, Cell, CellAlignment, Color, ContentArrangement, Table}; - -use oops_core::{DirEntry, Volume, WasteCategory, WasteEntry}; - -use crate::commands::drill::{DrillLevel, StopReason}; - -// --------------------------------------------------------------------------- -// Plain mode -// --------------------------------------------------------------------------- - -static PLAIN_MODE: AtomicBool = AtomicBool::new(false); - -pub fn set_plain(enabled: bool) { - PLAIN_MODE.store(enabled, Ordering::Relaxed); -} - -pub fn is_plain() -> bool { - PLAIN_MODE.load(Ordering::Relaxed) -} - -// --------------------------------------------------------------------------- -// Symbols -// --------------------------------------------------------------------------- - -pub const SYM_OK: &str = "\u{2713}"; -pub const SYM_BLOCK: &str = "\u{2588}"; -pub const SYM_BLOCK_MED: &str = "\u{2593}"; -pub const SYM_BLOCK_LIGHT: &str = "\u{2591}"; - -// --------------------------------------------------------------------------- -// Status helpers (stderr) -// --------------------------------------------------------------------------- - -pub fn success(msg: &str) { - if is_plain() { - eprintln!("{}", msg); - } else { - eprintln!("{} {}", SYM_OK.green(), msg); - } -} - -pub fn header(msg: &str) { - if is_plain() { - eprintln!("{}", msg); - } else { - eprintln!("{}", msg.bold()); - } -} - -pub fn progress(msg: &str) { - if is_plain() { - eprintln!("-> {}", msg); - } else { - eprintln!("{} {}", "\u{2192}".cyan(), msg); - } -} - -pub fn detail(msg: &str) { - if is_plain() { - eprintln!(" {}", msg); - } else { - eprintln!(" {}", msg.dimmed()); - } -} - -// --------------------------------------------------------------------------- -// Color helpers -// --------------------------------------------------------------------------- - -pub fn highlight(s: &str) -> String { - if is_plain() { - s.to_string() - } else { - s.cyan().to_string() - } -} - -pub fn bold(s: &str) -> String { - if is_plain() { - s.to_string() - } else { - s.bold().to_string() - } -} - -pub fn dim(s: &str) -> String { - if is_plain() { - s.to_string() - } else { - s.dimmed().to_string() - } -} - -// --------------------------------------------------------------------------- -// Size formatting -// --------------------------------------------------------------------------- - -pub fn fmt_size(bytes: u64) -> String { - ByteSize(bytes).to_string() -} - -// --------------------------------------------------------------------------- -// Bar rendering -// --------------------------------------------------------------------------- - -enum BarColor { - Green, - Yellow, - Red, -} - -fn capacity_color(fraction: f64) -> BarColor { - if fraction >= 0.90 { - BarColor::Red - } else if fraction >= 0.70 { - BarColor::Yellow - } else { - BarColor::Green - } -} - -fn capacity_cell_color(fraction: f64) -> Color { - if fraction >= 0.90 { - Color::Red - } else if fraction >= 0.70 { - Color::Yellow - } else { - Color::Green - } -} - -/// Render a usage bar: [########--------] 65% -pub fn usage_bar(fraction: f64, width: usize) -> String { - let filled = (fraction * width as f64).round() as usize; - let empty = width.saturating_sub(filled); - - if is_plain() { - return format!( - "[{}{}] {:>3.0}%", - "#".repeat(filled), - "-".repeat(empty), - fraction * 100.0 - ); - } - - let color = capacity_color(fraction); - let filled_str = SYM_BLOCK.repeat(filled); - let empty_str = SYM_BLOCK_LIGHT.repeat(empty); - - let colored_bar = match color { - BarColor::Green => filled_str.green().to_string(), - BarColor::Yellow => filled_str.yellow().to_string(), - BarColor::Red => filled_str.red().to_string(), - }; - - let pct_str = format!("{:>3.0}%", fraction * 100.0); - let colored_pct = match color { - BarColor::Green => pct_str.green().to_string(), - BarColor::Yellow => pct_str.yellow().to_string(), - BarColor::Red => pct_str.red().bold().to_string(), - }; - - format!("{}{} {}", colored_bar, empty_str.dimmed(), colored_pct) -} - -/// Render a proportional bar segment for size visualization. -pub fn proportion_bar(fraction: f64, width: usize) -> String { - let filled = (fraction * width as f64).round().max(0.0).min(width as f64) as usize; - let empty = width - filled; - if is_plain() { - format!("{}{}", "#".repeat(filled), " ".repeat(empty)) - } else { - format!( - "{}{}", - SYM_BLOCK_MED.repeat(filled).cyan(), - " ".repeat(empty), - ) - } -} - -// --------------------------------------------------------------------------- -// Path helpers -// --------------------------------------------------------------------------- - -pub fn short_path(path: &std::path::Path) -> String { - if let Ok(home) = std::env::var("HOME") { - let home_path = std::path::Path::new(&home); - if let Ok(relative) = path.strip_prefix(home_path) { - return format!("~/{}", relative.display()); - } - } - path.display().to_string() -} - -pub fn truncate(s: &str, max: usize) -> String { - if s.chars().count() <= max { - s.to_string() - } else { - let end = s - .char_indices() - .nth(max - 1) - .map(|(i, _)| i) - .unwrap_or(s.len()); - format!("{}\u{2026}", &s[..end]) - } -} - -// --------------------------------------------------------------------------- -// Error display -// --------------------------------------------------------------------------- - -pub fn print_error(e: &dyn std::error::Error) { - if is_plain() { - eprintln!("error: {e}"); - } else { - eprintln!("{} {e}", "error:".red().bold()); - } - let mut source = e.source(); - while let Some(cause) = source { - if is_plain() { - eprintln!(" caused by: {cause}"); - } else { - eprintln!(" {} {cause}", "caused by:".yellow()); - } - source = cause.source(); - } -} - -// --------------------------------------------------------------------------- -// Table builders — all table construction lives here -// --------------------------------------------------------------------------- - -/// Render a one-liner free-space summary for a single volume to stderr. -pub fn render_free(volume: &Volume) { - // Derive used-fraction from available/total so the bar matches the - // "X free of Y" copy above it. APFS's df "Capacity" column can - // otherwise disagree because of shared container space. - let fraction = if volume.total > 0 { - 1.0 - (volume.available as f64 / volume.total as f64) - } else { - 0.0 - }; - let mount = volume.mount_point.display().to_string(); - let free = fmt_size(volume.available); - let total = fmt_size(volume.total); - let bar = usage_bar(fraction, 24); - - eprintln!(); - eprintln!( - " {} free of {} on {}", - bold(&highlight(&free)), - total, - highlight(&mount), - ); - eprintln!(" {}", bar); - eprintln!(); -} - -/// Render the volumes table to stderr. -pub fn render_volumes(volumes: &[Volume]) { - if volumes.is_empty() { - eprintln!("No volumes found."); - return; - } - - let mut table = Table::new(); - table - .load_preset(presets::NOTHING) - .set_content_arrangement(ContentArrangement::Dynamic) - .set_header(vec![ - Cell::new("MOUNT").add_attribute(Attribute::Bold), - Cell::new("FILESYSTEM").add_attribute(Attribute::Bold), - Cell::new("SIZE").add_attribute(Attribute::Bold), - Cell::new("USED").add_attribute(Attribute::Bold), - Cell::new("FREE").add_attribute(Attribute::Bold), - Cell::new("").add_attribute(Attribute::Bold), - ]); - - for vol in volumes { - let fraction = vol.capacity_pct / 100.0; - let color = capacity_cell_color(fraction); - let bar = usage_bar(fraction, 20); - - table.add_row(vec![ - Cell::new(vol.mount_point.display().to_string()).fg(Color::White), - Cell::new(truncate(&vol.filesystem, 24)).fg(Color::DarkGrey), - Cell::new(fmt_size(vol.total)).set_alignment(CellAlignment::Right), - Cell::new(fmt_size(vol.used)) - .fg(color) - .set_alignment(CellAlignment::Right), - Cell::new(fmt_size(vol.available)).set_alignment(CellAlignment::Right), - Cell::new(bar), - ]); - } - - eprintln!("{table}"); -} - -/// Render directory entries as a proportional breakdown to stderr. -pub fn render_dir_breakdown( - path: &std::path::Path, - entries: &[DirEntry], - total_size: u64, - disk_total: Option, -) { - let size_label = match disk_total { - Some(dt) => format!("({} / {})", fmt_size(total_size), fmt_size(dt)), - None => format!("({})", fmt_size(total_size)), - }; - eprintln!("{} {}", bold(&short_path(path)), dim(&size_label),); - eprintln!(); - - if entries.is_empty() { - eprintln!(" (empty directory)"); - return; - } - - let bar_width = 24; - let max_name_len = entries - .iter() - .take(20) - .map(|e| e.name.len() + 1) - .max() - .unwrap_or(10) - .min(40); - - for entry in entries.iter().take(20) { - let fraction = if total_size > 0 { - entry.size as f64 / total_size as f64 - } else { - 0.0 - }; - let bar = proportion_bar(fraction, bar_width); - let name = if entry.is_dir { - format!("{}/", entry.name) - } else { - entry.name.clone() - }; - let name_display = truncate(&name, max_name_len); - let size_str = fmt_size(entry.size); - let pct_str = format!("{:>5.1}%", fraction * 100.0); - - if is_plain() { - eprintln!( - " {:10} {}", - name_display, - size_str, - pct_str, - width = max_name_len - ); - } else { - eprintln!( - " {:10} {} {}", - name_display, - size_str, - dim(&pct_str), - bar, - width = max_name_len, - ); - } - } - - if entries.len() > 20 { - let rest_size: u64 = entries.iter().skip(20).map(|e| e.size).sum(); - eprintln!( - " {} ({} more items, {})", - dim("..."), - entries.len() - 20, - fmt_size(rest_size) - ); - } -} - -/// Render the top-N largest entries table to stderr. -pub fn render_top_entries(entries: &[DirEntry], base_path: &std::path::Path) { - if entries.is_empty() { - eprintln!("No entries found matching criteria."); - return; - } - - let mut table = Table::new(); - table - .load_preset(presets::NOTHING) - .set_content_arrangement(ContentArrangement::Dynamic) - .set_header(vec![ - Cell::new("#").add_attribute(Attribute::Bold), - Cell::new("SIZE").add_attribute(Attribute::Bold), - Cell::new("TYPE").add_attribute(Attribute::Bold), - Cell::new("PATH").add_attribute(Attribute::Bold), - ]); - - for (i, entry) in entries.iter().enumerate() { - let type_str = if entry.is_dir { "dir" } else { "file" }; - let type_color = if entry.is_dir { - Color::Cyan - } else { - Color::White - }; - let rel_path = entry.path.strip_prefix(base_path).unwrap_or(&entry.path); - - table.add_row(vec![ - Cell::new(i + 1) - .fg(Color::DarkGrey) - .set_alignment(CellAlignment::Right), - Cell::new(fmt_size(entry.size)).set_alignment(CellAlignment::Right), - Cell::new(type_str).fg(type_color), - Cell::new(truncate(&rel_path.display().to_string(), 72)).fg(Color::White), - ]); - } - - eprintln!("{table}"); - - let total: u64 = entries.iter().map(|e| e.size).sum(); - eprintln!(); - eprintln!( - " {} {} across {} entries", - bold("Total:"), - highlight(&fmt_size(total)), - entries.len() - ); -} - -/// Render a tree node (recursive, called by the tree command). -pub fn render_tree_node(entries: &[DirEntry], parent_total: u64, depth: usize, min_pct: f64) { - let indent = " ".repeat(depth + 1); - let connector_mid = "\u{251c}\u{2500}\u{2500}"; - let connector_end = "\u{2514}\u{2500}\u{2500}"; - - let shown: Vec<_> = entries - .iter() - .filter(|e| parent_total > 0 && (e.size as f64 / parent_total as f64) * 100.0 >= min_pct) - .collect(); - - let hidden_count = entries.len() - shown.len(); - let hidden_size: u64 = entries - .iter() - .filter(|e| parent_total == 0 || (e.size as f64 / parent_total as f64) * 100.0 < min_pct) - .map(|e| e.size) - .sum(); - - for (i, entry) in shown.iter().enumerate() { - let is_last = i == shown.len() - 1 && hidden_count == 0; - let connector = if is_last { - connector_end - } else { - connector_mid - }; - - let pct = if parent_total > 0 { - (entry.size as f64 / parent_total as f64) * 100.0 - } else { - 0.0 - }; - - let bar = proportion_bar(pct / 100.0, 16); - let name = if entry.is_dir { - format!("{}/", entry.name) - } else { - entry.name.clone() - }; - - if is_plain() { - eprintln!( - "{}{} {:>10} {:>5.1}% {}", - indent, - connector, - fmt_size(entry.size), - pct, - name - ); - } else { - eprintln!( - "{}{} {:>10} {} {} {}", - indent, - dim(connector), - fmt_size(entry.size), - dim(&format!("{:>5.1}%", pct)), - bar, - if entry.is_dir { bold(&name) } else { name }, - ); - } - } - - if hidden_count > 0 && hidden_size > 0 { - eprintln!( - "{}{} {:>10} {:>16} {} ({} items below {:.1}%)", - indent, - dim(connector_end), - fmt_size(hidden_size), - "", - dim("..."), - hidden_count, - min_pct, - ); - } -} - -/// Render sweep results: summary by category + optional detail table. -pub fn render_sweep_results(entries: &[WasteEntry], verbose: bool) { - if entries.is_empty() { - success("No significant waste found."); - return; - } - - // Group by category - let mut by_category: HashMap = HashMap::new(); - for entry in entries { - let key = entry.category.label().to_string(); - let e = by_category - .entry(key) - .or_insert_with(|| (entry.category.clone(), 0, 0)); - e.1 += entry.size; - e.2 += 1; - } - - let mut categories: Vec<_> = by_category.into_values().collect(); - categories.sort_by_key(|b| std::cmp::Reverse(b.1)); - - header("Reclaimable Space by Category"); - eprintln!(); - - let mut table = Table::new(); - table - .load_preset(presets::NOTHING) - .set_content_arrangement(ContentArrangement::Dynamic) - .set_header(vec![ - Cell::new("CATEGORY").add_attribute(Attribute::Bold), - Cell::new("SIZE").add_attribute(Attribute::Bold), - Cell::new("COUNT").add_attribute(Attribute::Bold), - Cell::new("DESCRIPTION").add_attribute(Attribute::Bold), - ]); - - let total_waste: u64 = categories.iter().map(|(_, size, _)| size).sum(); - - for (cat, size, count) in &categories { - table.add_row(vec![ - Cell::new(cat.label()).fg(Color::Cyan), - Cell::new(fmt_size(*size)) - .fg(Color::Yellow) - .set_alignment(CellAlignment::Right), - Cell::new(count).set_alignment(CellAlignment::Right), - Cell::new(cat.description()).fg(Color::DarkGrey), - ]); - } - - eprintln!("{table}"); - eprintln!(); - eprintln!( - " {} {}", - bold("Total reclaimable:"), - highlight(&fmt_size(total_waste)) - ); - - if verbose { - eprintln!(); - header("Individual Entries"); - eprintln!(); - - let mut detail_table = Table::new(); - detail_table - .load_preset(presets::NOTHING) - .set_content_arrangement(ContentArrangement::Dynamic) - .set_header(vec![ - Cell::new("SIZE").add_attribute(Attribute::Bold), - Cell::new("CATEGORY").add_attribute(Attribute::Bold), - Cell::new("PATH").add_attribute(Attribute::Bold), - ]); - - for entry in entries.iter().take(50) { - detail_table.add_row(vec![ - Cell::new(fmt_size(entry.size)) - .fg(Color::Yellow) - .set_alignment(CellAlignment::Right), - Cell::new(entry.category.label()).fg(Color::Cyan), - Cell::new(short_path(&entry.path)).fg(Color::White), - ]); - } - - eprintln!("{detail_table}"); - - if entries.len() > 50 { - eprintln!(); - eprintln!( - " {} ({} more entries not shown)", - dim("..."), - entries.len() - 50 - ); - } - } -} - -// --------------------------------------------------------------------------- -// Drill rendering -// --------------------------------------------------------------------------- - -// Box-drawing characters for tree rendering -const PIPE: &str = "\u{2502}"; // │ -const TEE: &str = "\u{251c}\u{2500}"; // ├─ -const ELL: &str = "\u{2514}\u{2500}"; // └─ -const ARROW_DOWN: &str = "\u{25bc}"; // ▼ - -/// Render a single drill level. `depth` is 0-based. `has_next` indicates -/// whether the drill will continue (controls the ▼ marker on the biggest dir). -pub fn render_drill_level(level: &DrillLevel, depth: usize, has_next: bool) { - let prefix = build_prefix(depth); - - // Directory header - let size_str = if level.total > 0 { - fmt_size(level.total) - } else { - "empty".to_string() - }; - - if depth == 0 { - eprintln!( - "{} {}", - bold(&level.dir_name), - dim(&format!("({})", size_str)), - ); - } else { - let arrow = if is_plain() { - ARROW_DOWN.to_string() - } else { - ARROW_DOWN.cyan().to_string() - }; - eprintln!( - "{}{} {} {}", - prefix, - arrow, - bold(&level.dir_name), - dim(&format!("({})", size_str)), - ); - } - - if level.total == 0 || level.entries.is_empty() { - return; - } - - // Render children with tree connectors - let show_count = level.show_count.min(level.entries.len()); - let remaining = level.entries.len().saturating_sub(show_count); - let has_more = remaining > 0; - - // Find the drilled-into entry (first dir, which is biggest since sorted) - let drilled_idx = if has_next { - level.entries.iter().position(|e| e.is_dir) - } else { - None - }; - - for (i, entry) in level.entries.iter().take(show_count).enumerate() { - let is_last_entry = i == show_count - 1 && !has_more; - let connector = if is_last_entry { ELL } else { TEE }; - let is_drilled = drilled_idx == Some(i); - - let frac = if level.total > 0 { - entry.size as f64 / level.total as f64 - } else { - 0.0 - }; - let pct = frac * 100.0; - let bar = proportion_bar(frac, 16); - - let name = if entry.is_dir { - format!("{}/", entry.name) - } else { - entry.name.clone() - }; - - // Highlight the drilled entry - let name_display = if is_drilled && !is_plain() { - name.cyan().bold().to_string() - } else if entry.is_dir { - bold(&name) - } else { - name - }; - - let marker = if is_drilled && !is_plain() { - format!(" {}", ARROW_DOWN.cyan()) - } else { - String::new() - }; - - if is_plain() { - eprintln!( - "{}{} {:>10} {:>5.1}% {}{}", - prefix, - connector, - fmt_size(entry.size), - pct, - name_display, - marker, - ); - } else { - eprintln!( - "{}{} {:>10} {} {} {}{}", - prefix, - dim(connector), - fmt_size(entry.size), - dim(&format!("{:>5.1}%", pct)), - bar, - name_display, - marker, - ); - } - } - - if has_more { - let rest_size: u64 = level.entries.iter().skip(show_count).map(|e| e.size).sum(); - if is_plain() { - eprintln!( - "{}{} {:>10} {:>16} ... ({} more)", - prefix, - ELL, - fmt_size(rest_size), - "", - remaining, - ); - } else { - eprintln!( - "{}{} {:>10} {:>16} {} ({} more)", - prefix, - dim(ELL), - fmt_size(rest_size), - "", - dim("..."), - remaining, - ); - } - } - - // Stop reason - if let Some(ref reason) = level.stop_reason { - eprintln!(); - match reason { - StopReason::BelowThreshold { pct, threshold } => { - eprintln!( - "{}{}", - prefix, - dim(&format!( - "stopped: largest child is {:.1}% (below {:.0}% threshold)", - pct, threshold, - )), - ); - } - StopReason::NoSubdirectories => { - eprintln!("{}{}", prefix, dim("(no subdirectories)")); - } - StopReason::Empty => {} - } - } -} - -/// Render the trail summary at the end of a drill. -pub fn render_drill_trail(names: &[String]) { - eprintln!(); - let trail: Vec<&str> = names.iter().map(|n| n.trim_end_matches('/')).collect(); - eprintln!("{} {}", bold("Trail:"), trail.join(" > ")); -} - -fn build_prefix(depth: usize) -> String { - if depth == 0 { - return String::new(); - } - if is_plain() { - " ".repeat(depth) - } else { - let segment = format!("{} ", dim(PIPE)); - segment.repeat(depth) - } -} - -// --------------------------------------------------------------------------- -// Spinner -// --------------------------------------------------------------------------- - -const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - -/// A terminal spinner that renders below existing output on stderr. -/// Clears itself when stopped or dropped. No-ops in plain mode. -pub struct Spinner { - running: Arc, - handle: Option>, -} - -impl Spinner { - pub fn start(msg: &str) -> Self { - use std::io::IsTerminal; - let running = Arc::new(AtomicBool::new(true)); - - if is_plain() || !std::io::stderr().is_terminal() { - return Spinner { - running, - handle: None, - }; - } - - let r = Arc::clone(&running); - let msg = msg.to_string(); - let handle = std::thread::spawn(move || { - let mut i = 0; - let mut stderr = std::io::stderr(); - while r.load(Ordering::Relaxed) { - let frame = SPINNER_FRAMES[i % SPINNER_FRAMES.len()]; - let _ = write!(stderr, "\r{} {}", frame.cyan(), msg.dimmed()); - let _ = stderr.flush(); - i += 1; - std::thread::sleep(std::time::Duration::from_millis(80)); - } - // Clear the spinner line - let _ = write!(stderr, "\r\x1b[2K"); - let _ = stderr.flush(); - }); - - Spinner { - running, - handle: Some(handle), - } - } - - pub fn stop(self) { - // drop triggers cleanup - drop(self); - } -} - -impl Drop for Spinner { - fn drop(&mut self) { - self.running.store(false, Ordering::Relaxed); - if let Some(h) = self.handle.take() { - let _ = h.join(); - } - } -} diff --git a/crates/oops-cli/src/ui/colors.rs b/crates/oops-cli/src/ui/colors.rs new file mode 100644 index 0000000..8a6ccff --- /dev/null +++ b/crates/oops-cli/src/ui/colors.rs @@ -0,0 +1,30 @@ +use colored::Colorize; + +use super::is_plain; + +/// Highlight a name/value (cyan). +pub fn highlight(s: &str) -> String { + if is_plain() { + s.to_string() + } else { + s.cyan().to_string() + } +} + +/// Bold text. +pub fn bold(s: &str) -> String { + if is_plain() { + s.to_string() + } else { + s.bold().to_string() + } +} + +/// Dimmed text for secondary info. +pub fn dim(s: &str) -> String { + if is_plain() { + s.to_string() + } else { + s.dimmed().to_string() + } +} diff --git a/crates/oops-cli/src/ui/mod.rs b/crates/oops-cli/src/ui/mod.rs new file mode 100644 index 0000000..fd795ca --- /dev/null +++ b/crates/oops-cli/src/ui/mod.rs @@ -0,0 +1,301 @@ +//! Shared rendering utilities for CLI output. +//! +//! Provides consistent status symbols, color helpers, truncation, table builders, +//! and a global plain-mode flag for scriptable output. + +use std::io::Write; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use bytesize::ByteSize; +use colored::Colorize; +use comfy_table::{presets, Attribute, Cell, CellAlignment, Color, ContentArrangement, Table}; + +use oops_core::Volume; + +pub mod colors; +pub mod output; + +pub use colors::*; +pub use output::*; + +// --------------------------------------------------------------------------- +// Plain mode +// --------------------------------------------------------------------------- + +static PLAIN_MODE: AtomicBool = AtomicBool::new(false); + +pub fn set_plain(enabled: bool) { + PLAIN_MODE.store(enabled, Ordering::Relaxed); +} + +pub fn is_plain() -> bool { + PLAIN_MODE.load(Ordering::Relaxed) +} + +// --------------------------------------------------------------------------- +// Terminal width +// --------------------------------------------------------------------------- + +/// Current terminal width (columns), with a sane fallback. +pub fn term_width() -> usize { + crossterm::terminal::size() + .map(|(w, _)| w as usize) + .unwrap_or(80) +} + +/// Compute a bar width that scales to terminal size. +/// Returns roughly `fraction` of the terminal width, clamped to [min, max]. +pub fn scaled_bar(fraction: f64, min: usize, max: usize) -> usize { + let w = (term_width() as f64 * fraction).round() as usize; + w.clamp(min, max) +} + +// --------------------------------------------------------------------------- +// Size formatting +// --------------------------------------------------------------------------- + +pub fn fmt_size(bytes: u64) -> String { + ByteSize(bytes).to_string() +} + +// --------------------------------------------------------------------------- +// Bar rendering +// --------------------------------------------------------------------------- + +pub const SYM_BLOCK: &str = "\u{2588}"; +pub const SYM_BLOCK_LIGHT: &str = "\u{2591}"; + +enum BarColor { + Green, + Yellow, + Red, +} + +fn capacity_color(fraction: f64) -> BarColor { + if fraction >= 0.90 { + BarColor::Red + } else if fraction >= 0.70 { + BarColor::Yellow + } else { + BarColor::Green + } +} + +fn capacity_cell_color(fraction: f64) -> Color { + if fraction >= 0.90 { + Color::Red + } else if fraction >= 0.70 { + Color::Yellow + } else { + Color::Green + } +} + +/// Render a usage bar: [########--------] 65% +pub fn usage_bar(fraction: f64, width: usize) -> String { + let filled = (fraction * width as f64).round() as usize; + let empty = width.saturating_sub(filled); + + if is_plain() { + return format!( + "[{}{}] {:>3.0}%", + "#".repeat(filled), + "-".repeat(empty), + fraction * 100.0 + ); + } + + let color = capacity_color(fraction); + let filled_str = SYM_BLOCK.repeat(filled); + let empty_str = SYM_BLOCK_LIGHT.repeat(empty); + + let colored_bar = match color { + BarColor::Green => filled_str.green().to_string(), + BarColor::Yellow => filled_str.yellow().to_string(), + BarColor::Red => filled_str.red().to_string(), + }; + + let pct_str = format!("{:>3.0}%", fraction * 100.0); + let colored_pct = match color { + BarColor::Green => pct_str.green().to_string(), + BarColor::Yellow => pct_str.yellow().to_string(), + BarColor::Red => pct_str.red().bold().to_string(), + }; + + format!("{}{} {}", colored_bar, empty_str.dimmed(), colored_pct) +} + +// --------------------------------------------------------------------------- +// Path helpers +// --------------------------------------------------------------------------- + +pub fn short_path(path: &std::path::Path) -> String { + if let Ok(home) = std::env::var("HOME") { + let home_path = std::path::Path::new(&home); + if let Ok(relative) = path.strip_prefix(home_path) { + return format!("~/{}", relative.display()); + } + } + path.display().to_string() +} + +// --------------------------------------------------------------------------- +// Truncation +// --------------------------------------------------------------------------- + +pub fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let end = s + .char_indices() + .nth(max - 1) + .map(|(i, _)| i) + .unwrap_or(s.len()); + format!("{}\u{2026}", &s[..end]) + } +} + +// --------------------------------------------------------------------------- +// Table helpers +// --------------------------------------------------------------------------- + +/// Create a new table with the standard preset (no borders) and dynamic arrangement. +pub fn new_table(headers: &[&str]) -> Table { + let mut table = Table::new(); + table + .load_preset(presets::NOTHING) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header( + headers + .iter() + .map(|h| Cell::new(*h).add_attribute(Attribute::Bold)) + .collect::>(), + ); + table +} + +// --------------------------------------------------------------------------- +// Spinner +// --------------------------------------------------------------------------- + +const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +pub struct Spinner { + running: Arc, + handle: Option>, +} + +impl Spinner { + pub fn start(msg: &str) -> Self { + use std::io::IsTerminal; + let running = Arc::new(AtomicBool::new(true)); + + if is_plain() || !std::io::stderr().is_terminal() { + return Spinner { + running, + handle: None, + }; + } + + let r = Arc::clone(&running); + let msg = msg.to_string(); + let handle = std::thread::spawn(move || { + let mut i = 0; + let mut stderr = std::io::stderr(); + while r.load(Ordering::Relaxed) { + let frame = SPINNER_FRAMES[i % SPINNER_FRAMES.len()]; + let _ = write!(stderr, "\r{} {}", frame.cyan(), msg.dimmed()); + let _ = stderr.flush(); + i += 1; + std::thread::sleep(std::time::Duration::from_millis(80)); + } + let _ = write!(stderr, "\r\x1b[2K"); + let _ = stderr.flush(); + }); + + Spinner { + running, + handle: Some(handle), + } + } + + pub fn stop(self) { + drop(self); + } +} + +impl Drop for Spinner { + fn drop(&mut self) { + self.running.store(false, Ordering::Relaxed); + if let Some(h) = self.handle.take() { + let _ = h.join(); + } + } +} + +// --------------------------------------------------------------------------- +// Command-specific renderers (legacy — move to per-command render.rs over time) +// --------------------------------------------------------------------------- + +pub fn render_free(volume: &Volume) { + let fraction = if volume.total > 0 { + 1.0 - (volume.available as f64 / volume.total as f64) + } else { + 0.0 + }; + let mount = volume.mount_point.display().to_string(); + let free = fmt_size(volume.available); + let total = fmt_size(volume.total); + let bar = usage_bar(fraction, scaled_bar(0.3, 16, 60)); + + eprintln!(); + eprintln!( + " {} free of {} on {}", + bold(&highlight(&free)), + total, + highlight(&mount), + ); + eprintln!(" {}", bar); + eprintln!(); +} + +pub fn render_volumes(volumes: &[Volume]) { + if volumes.is_empty() { + eprintln!("No volumes found."); + return; + } + + let mut table = Table::new(); + table + .load_preset(presets::NOTHING) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(vec![ + Cell::new("MOUNT").add_attribute(Attribute::Bold), + Cell::new("FILESYSTEM").add_attribute(Attribute::Bold), + Cell::new("SIZE").add_attribute(Attribute::Bold), + Cell::new("USED").add_attribute(Attribute::Bold), + Cell::new("FREE").add_attribute(Attribute::Bold), + Cell::new("").add_attribute(Attribute::Bold), + ]); + + for vol in volumes { + let fraction = vol.capacity_pct / 100.0; + let color = capacity_cell_color(fraction); + let bar = usage_bar(fraction, scaled_bar(0.25, 12, 50)); + + table.add_row(vec![ + Cell::new(vol.mount_point.display().to_string()).fg(Color::White), + Cell::new(truncate(&vol.filesystem, scaled_bar(0.15, 16, 40))).fg(Color::DarkGrey), + Cell::new(fmt_size(vol.total)).set_alignment(CellAlignment::Right), + Cell::new(fmt_size(vol.used)) + .fg(color) + .set_alignment(CellAlignment::Right), + Cell::new(fmt_size(vol.available)).set_alignment(CellAlignment::Right), + Cell::new(bar), + ]); + } + + eprintln!("{table}"); +} diff --git a/crates/oops-cli/src/ui/output.rs b/crates/oops-cli/src/ui/output.rs new file mode 100644 index 0000000..7e81c8e --- /dev/null +++ b/crates/oops-cli/src/ui/output.rs @@ -0,0 +1,68 @@ +use colored::Colorize; + +use super::is_plain; + +// --------------------------------------------------------------------------- +// Symbols +// --------------------------------------------------------------------------- + +pub const SYM_OK: &str = "\u{2713}"; +pub const SYM_ARROW: &str = "\u{2192}"; + +// --------------------------------------------------------------------------- +// Status helpers (stderr) +// --------------------------------------------------------------------------- + +/// Print a success line to stderr: `✓ message` +pub fn success(msg: &str) { + if is_plain() { + eprintln!("{}", msg); + } else { + eprintln!("{} {}", SYM_OK.green(), msg); + } +} + +/// Print a section header to stderr. +pub fn header(msg: &str) { + if is_plain() { + eprintln!("{}", msg); + } else { + eprintln!("{}", msg.bold()); + } +} + +/// Print a progress line to stderr: `→ message` +pub fn progress(msg: &str) { + if is_plain() { + eprintln!("-> {}", msg); + } else { + eprintln!("{} {}", SYM_ARROW.cyan(), msg); + } +} + +/// Print an indented detail line to stderr. +pub fn detail(msg: &str) { + if is_plain() { + eprintln!(" {}", msg); + } else { + eprintln!(" {}", msg.dimmed()); + } +} + +/// Print a formatted error chain to stderr. +pub fn print_error(e: &dyn std::error::Error) { + if is_plain() { + eprintln!("error: {e}"); + } else { + eprintln!("{} {e}", "error:".red().bold()); + } + let mut source = e.source(); + while let Some(cause) = source { + if is_plain() { + eprintln!(" caused by: {cause}"); + } else { + eprintln!(" {} {cause}", "caused by:".yellow()); + } + source = cause.source(); + } +} diff --git a/crates/oops-core/Cargo.toml b/crates/oops-core/Cargo.toml index b28e4a3..76a4bd8 100644 --- a/crates/oops-core/Cargo.toml +++ b/crates/oops-core/Cargo.toml @@ -12,3 +12,5 @@ serde.workspace = true serde_json.workspace = true bytesize.workspace = true tracing.workspace = true +toml.workspace = true +dirs.workspace = true diff --git a/crates/oops-core/src/config.rs b/crates/oops-core/src/config.rs new file mode 100644 index 0000000..f9199ae --- /dev/null +++ b/crates/oops-core/src/config.rs @@ -0,0 +1,104 @@ +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +#[derive(Debug, Deserialize, Default)] +pub struct OopsConfig { + #[serde(default)] + pub general: GeneralConfig, + #[serde(default, rename = "rules")] + pub rules: Vec, +} + +#[derive(Debug, Deserialize, Default)] +pub struct GeneralConfig { + pub scan_depth: Option, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct RuleConfig { + pub name: String, + pub enabled: Option, + pub dir: Option, + pub path: Option, + pub file_glob: Option, + pub when_parent_has: Option, + pub min_size: Option, + pub clean: Option, + pub description: Option, +} + +pub fn config_dir() -> PathBuf { + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + PathBuf::from(xdg).join("oops") + } else { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".config") + .join("oops") + } +} + +pub fn config_path() -> PathBuf { + config_dir().join("config.toml") +} + +impl OopsConfig { + pub fn load() -> Result { + Self::load_from(&config_path()) + } + + pub fn load_from(path: &Path) -> Result { + if !path.exists() { + return Ok(Self::default()); + } + let content = std::fs::read_to_string(path).map_err(crate::Error::Io)?; + toml::from_str(&content).map_err(|e| crate::Error::ConfigParse(e.to_string())) + } + + pub fn user_rule_count(&self) -> usize { + self.rules.len() + } + + pub fn disabled_builtins(&self) -> Vec<&str> { + self.rules + .iter() + .filter(|r| r.enabled == Some(false)) + .map(|r| r.name.as_str()) + .collect() + } +} + +pub const DEFAULT_CONFIG_CONTENT: &str = r#"# oops configuration +# Location: ~/.config/oops/config.toml + +[general] +# scan_depth = 6 + +# Rules define what `oops sweep` looks for. +# Built-in rules are active by default — run `oops config` to see them. +# +# To disable a built-in rule: +# [[rules]] +# name = "node_modules" +# enabled = false +# +# To add a custom rule: +# [[rules]] +# name = "my-build-output" +# dir = "dist" +# when_parent_has = "package.json" +# min_size = "100 MB" +# clean = "rm -rf {path}" +# description = "Frontend build output" +# +# Match modes (use exactly one per rule): +# dir = "name" Match directories by basename +# path = "~/some/path" Match a specific absolute path (~ expanded) +# file_glob = "*.log" Match files by extension +# +# Template variables for clean commands: +# {path} Full path of the matched entry +# {parent} Parent directory +# {name} Entry basename +"#; diff --git a/crates/oops-core/src/lib.rs b/crates/oops-core/src/lib.rs index 4821868..3bb7cd7 100644 --- a/crates/oops-core/src/lib.rs +++ b/crates/oops-core/src/lib.rs @@ -1,17 +1,17 @@ +pub mod config; +pub mod rules; mod scan; mod sweep; mod volume; -pub use scan::{scan_directory, scan_top_entries, DirEntry, ScanOptions}; -pub use sweep::{sweep_directory, WasteCategory, WasteEntry}; +pub use rules::{builtin_rules, resolve_rules, MatchMode, ResolvedRule}; +pub use scan::{scan_directory, scan_top_entries, scan_tree, DirEntry, ScanOptions, TreeNode}; +pub use sweep::{sweep_directory, SweepMatch}; pub use volume::{list_volumes, Volume}; use std::os::unix::fs::MetadataExt; use thiserror::Error; -/// On-disk size from allocated blocks (what `du` reports). -/// Sparse files report logical size >> actual disk usage; -/// this returns the real footprint. fn disk_size(meta: &std::fs::Metadata) -> u64 { let blocks = meta.blocks(); if blocks > 0 { @@ -29,4 +29,8 @@ pub enum Error { PathNotFound(String), #[error("permission denied: {0}")] PermissionDenied(String), + #[error("config parse error: {0}")] + ConfigParse(String), + #[error("invalid rule: {0}")] + InvalidRule(String), } diff --git a/crates/oops-core/src/rules.rs b/crates/oops-core/src/rules.rs new file mode 100644 index 0000000..682dae9 --- /dev/null +++ b/crates/oops-core/src/rules.rs @@ -0,0 +1,352 @@ +use std::path::{Path, PathBuf}; + +use crate::config::{OopsConfig, RuleConfig}; + +#[derive(Debug, Clone)] +pub enum MatchMode { + DirBasename(String), + AbsolutePath(PathBuf), + FileGlob(String), +} + +#[derive(Debug, Clone)] +pub struct ResolvedRule { + pub name: String, + pub match_mode: MatchMode, + pub when_parent_has: Option, + pub min_size: u64, + pub clean: Option, + pub description: String, +} + +impl ResolvedRule { + pub fn matches_entry(&self, entry_name: &str, entry_path: &Path, is_dir: bool) -> bool { + match &self.match_mode { + MatchMode::DirBasename(name) => is_dir && entry_name == name, + MatchMode::AbsolutePath(p) => entry_path == p, + MatchMode::FileGlob(pattern) => { + if is_dir { + return false; + } + if let Some(ext) = pattern.strip_prefix("*.") { + entry_name + .rsplit('.') + .next() + .is_some_and(|e| e.eq_ignore_ascii_case(ext)) + } else { + entry_name == pattern + } + } + } + } + + pub fn check_parent_condition(&self, parent: &Path) -> bool { + match &self.when_parent_has { + Some(required_file) => parent.join(required_file).exists(), + None => true, + } + } + + pub fn is_absolute_path(&self) -> bool { + matches!(self.match_mode, MatchMode::AbsolutePath(_)) + } + + pub fn expand_clean_command(&self, matched_path: &Path) -> Option { + self.clean.as_ref().map(|tmpl| { + tmpl.replace("{path}", &matched_path.display().to_string()) + .replace( + "{parent}", + &matched_path + .parent() + .unwrap_or(matched_path) + .display() + .to_string(), + ) + .replace( + "{name}", + &matched_path + .file_name() + .unwrap_or_default() + .to_string_lossy(), + ) + }) + } +} + +fn expand_tilde(path: &str) -> PathBuf { + if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest); + } + } + PathBuf::from(path) +} + +fn parse_size(s: &str) -> Result { + let s = s.trim(); + // bytesize parses "50 MB", "1 GiB", etc. + s.parse::() + .map(|b| b.as_u64()) + .map_err(|_| crate::Error::InvalidRule(format!("invalid size: {s}"))) +} + +fn rule_from_config(rc: &RuleConfig) -> Result { + let match_mode = if let Some(ref dir) = rc.dir { + MatchMode::DirBasename(dir.clone()) + } else if let Some(ref path) = rc.path { + MatchMode::AbsolutePath(expand_tilde(path)) + } else if let Some(ref glob) = rc.file_glob { + MatchMode::FileGlob(glob.clone()) + } else { + return Err(crate::Error::InvalidRule(format!( + "rule '{}': must have exactly one of dir, path, or file_glob", + rc.name + ))); + }; + + let min_size = match &rc.min_size { + Some(s) => parse_size(s)?, + None => 0, + }; + + Ok(ResolvedRule { + name: rc.name.clone(), + match_mode, + when_parent_has: rc.when_parent_has.clone(), + min_size, + clean: rc.clean.clone(), + description: rc.description.clone().unwrap_or_else(|| rc.name.clone()), + }) +} + +pub fn builtin_rules() -> Vec { + let mut rules = vec![ + ResolvedRule { + name: "node_modules".into(), + match_mode: MatchMode::DirBasename("node_modules".into()), + when_parent_has: None, + min_size: 1024 * 1024, + clean: Some("rm -rf {path}".into()), + description: "npm/yarn/pnpm dependency directories".into(), + }, + ResolvedRule { + name: "rust-target".into(), + match_mode: MatchMode::DirBasename("target".into()), + when_parent_has: Some("Cargo.toml".into()), + min_size: 50 * 1024 * 1024, + clean: Some("cargo clean --manifest-path {parent}/Cargo.toml".into()), + description: "Rust compilation artifacts".into(), + }, + ResolvedRule { + name: "cmake-build".into(), + match_mode: MatchMode::DirBasename("build".into()), + when_parent_has: Some("CMakeLists.txt".into()), + min_size: 50 * 1024 * 1024, + clean: Some("rm -rf {path}".into()), + description: "CMake build output".into(), + }, + ResolvedRule { + name: "python-venv".into(), + match_mode: MatchMode::DirBasename(".venv".into()), + when_parent_has: None, + min_size: 10 * 1024 * 1024, + clean: Some("rm -rf {path}".into()), + description: "Python virtual environments".into(), + }, + ResolvedRule { + name: "pycache".into(), + match_mode: MatchMode::DirBasename("__pycache__".into()), + when_parent_has: None, + min_size: 1024 * 1024, + clean: Some("rm -rf {path}".into()), + description: "Python bytecode cache".into(), + }, + ResolvedRule { + name: "log-files".into(), + match_mode: MatchMode::FileGlob("*.log".into()), + when_parent_has: None, + min_size: 10 * 1024 * 1024, + clean: Some("rm {path}".into()), + description: "Large log files".into(), + }, + ResolvedRule { + name: "dot-cache".into(), + match_mode: MatchMode::DirBasename(".cache".into()), + when_parent_has: None, + min_size: 50 * 1024 * 1024, + clean: None, + description: "Cache directories".into(), + }, + ]; + + // macOS-specific absolute path rules + if cfg!(target_os = "macos") { + if let Some(home) = dirs::home_dir() { + let macos_rules = vec![ + ResolvedRule { + name: "macos-caches".into(), + match_mode: MatchMode::AbsolutePath(home.join("Library/Caches")), + when_parent_has: None, + min_size: 50 * 1024 * 1024, + clean: None, + description: "macOS application caches".into(), + }, + ResolvedRule { + name: "xcode-derived".into(), + match_mode: MatchMode::AbsolutePath( + home.join("Library/Developer/Xcode/DerivedData"), + ), + when_parent_has: None, + min_size: 50 * 1024 * 1024, + clean: Some("rm -rf {path}".into()), + description: "Xcode build cache".into(), + }, + ResolvedRule { + name: "xcode-simulators".into(), + match_mode: MatchMode::AbsolutePath( + home.join("Library/Developer/CoreSimulator"), + ), + when_parent_has: None, + min_size: 50 * 1024 * 1024, + clean: None, + description: "iOS Simulator data".into(), + }, + ResolvedRule { + name: "npm-cache".into(), + match_mode: MatchMode::AbsolutePath(home.join(".npm/_cacache")), + when_parent_has: None, + min_size: 50 * 1024 * 1024, + clean: Some("npm cache clean --force".into()), + description: "npm download cache".into(), + }, + ResolvedRule { + name: "pnpm-store".into(), + match_mode: MatchMode::AbsolutePath(home.join("Library/pnpm")), + when_parent_has: None, + min_size: 50 * 1024 * 1024, + clean: Some("pnpm store prune".into()), + description: "pnpm content-addressable store".into(), + }, + ResolvedRule { + name: "cargo-registry".into(), + match_mode: MatchMode::AbsolutePath(home.join(".cargo/registry")), + when_parent_has: None, + min_size: 50 * 1024 * 1024, + clean: None, + description: "Cargo crate registry cache".into(), + }, + ResolvedRule { + name: "docker-data".into(), + match_mode: MatchMode::AbsolutePath( + home.join("Library/Containers/com.docker.docker"), + ), + when_parent_has: None, + min_size: 100 * 1024 * 1024, + clean: None, + description: "Docker Desktop data".into(), + }, + ]; + rules.extend(macos_rules); + } + } + + rules +} + +pub fn resolve_rules(config: &OopsConfig) -> Result, crate::Error> { + let mut rules = builtin_rules(); + + for user_rule in &config.rules { + if let Some(idx) = rules.iter().position(|r| r.name == user_rule.name) { + if user_rule.enabled == Some(false) { + rules.remove(idx); + continue; + } + // Override: replace built-in with user version + let resolved = rule_from_config(user_rule)?; + rules[idx] = resolved; + } else { + if user_rule.enabled == Some(false) { + continue; + } + let resolved = rule_from_config(user_rule)?; + rules.push(resolved); + } + } + + Ok(rules) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builtin_rules_are_valid() { + let rules = builtin_rules(); + assert!(!rules.is_empty()); + for rule in &rules { + assert!(!rule.name.is_empty()); + assert!(!rule.description.is_empty()); + } + } + + #[test] + fn resolve_disables_builtin() { + let config = OopsConfig { + rules: vec![RuleConfig { + name: "node_modules".into(), + enabled: Some(false), + dir: None, + path: None, + file_glob: None, + when_parent_has: None, + min_size: None, + clean: None, + description: None, + }], + ..Default::default() + }; + let rules = resolve_rules(&config).unwrap(); + assert!(!rules.iter().any(|r| r.name == "node_modules")); + } + + #[test] + fn resolve_adds_user_rule() { + let config = OopsConfig { + rules: vec![RuleConfig { + name: "my-custom".into(), + enabled: None, + dir: Some("dist".into()), + path: None, + file_glob: None, + when_parent_has: Some("package.json".into()), + min_size: Some("100 MB".into()), + clean: Some("rm -rf {path}".into()), + description: Some("Frontend build output".into()), + }], + ..Default::default() + }; + let rules = resolve_rules(&config).unwrap(); + assert!(rules.iter().any(|r| r.name == "my-custom")); + } + + #[test] + fn expand_template() { + let rule = ResolvedRule { + name: "test".into(), + match_mode: MatchMode::DirBasename("target".into()), + when_parent_has: None, + min_size: 0, + clean: Some("cargo clean --manifest-path {parent}/Cargo.toml".into()), + description: "test".into(), + }; + let cmd = rule + .expand_clean_command(Path::new("/home/user/project/target")) + .unwrap(); + assert_eq!( + cmd, + "cargo clean --manifest-path /home/user/project/Cargo.toml" + ); + } +} diff --git a/crates/oops-core/src/scan.rs b/crates/oops-core/src/scan.rs index f14a83d..fd44313 100644 --- a/crates/oops-core/src/scan.rs +++ b/crates/oops-core/src/scan.rs @@ -5,12 +5,19 @@ use std::sync::atomic::{AtomicU64, Ordering}; use rayon::prelude::*; use tracing::{debug, debug_span}; +use std::collections::HashSet; +use std::os::unix::fs::MetadataExt; + use crate::disk_size; #[derive(Debug, Clone, Default)] pub struct ScanOptions { pub max_depth: Option, pub follow_symlinks: bool, + /// Stay on the same filesystem (like `du -x`). Default: false. + pub one_filesystem: bool, + /// Absolute paths to skip during scanning (e.g. APFS synthetic mounts). + pub skip_paths: HashSet, } #[derive(Debug, Clone)] @@ -181,6 +188,162 @@ fn scan_recursive(path: &Path, opts: &ScanOptions, depth: usize, results: &mut V } } +// --------------------------------------------------------------------------- +// Tree scan — single-pass recursive tree for icicle/map rendering +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +pub struct TreeNode { + pub name: String, + pub path: PathBuf, + pub size: u64, + pub is_dir: bool, + pub children: Vec, +} + +/// Build a size-annotated tree up to `max_depth` levels deep. +/// Children beyond `max_depth` are still included in the parent's size +/// but not retained as nodes. Uses rayon for parallelism. +pub fn scan_tree( + path: &Path, + max_depth: usize, + opts: &ScanOptions, +) -> Result { + if !path.exists() { + return Err(crate::Error::PathNotFound(path.display().to_string())); + } + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| path.display().to_string()); + + let root_dev = if opts.one_filesystem { + fs::symlink_metadata(path).ok().map(|m| m.dev()) + } else { + None + }; + + Ok(build_tree_node(path, &name, max_depth, 0, opts, root_dev)) +} + +fn build_tree_node( + path: &Path, + name: &str, + max_depth: usize, + depth: usize, + opts: &ScanOptions, + root_dev: Option, +) -> TreeNode { + let meta = if opts.follow_symlinks { + fs::metadata(path) + } else { + fs::symlink_metadata(path) + }; + + let meta = match meta { + Ok(m) => m, + Err(_) => { + return TreeNode { + name: name.to_string(), + path: path.to_path_buf(), + size: 0, + is_dir: false, + children: vec![], + }; + } + }; + + // Skip explicitly excluded paths (e.g. APFS synthetic mounts) + if !opts.skip_paths.is_empty() && opts.skip_paths.contains(path) { + return TreeNode { + name: name.to_string(), + path: path.to_path_buf(), + size: 0, + is_dir: meta.is_dir(), + children: vec![], + }; + } + + // Skip entries on a different filesystem + if let Some(rd) = root_dev { + if meta.dev() != rd { + return TreeNode { + name: name.to_string(), + path: path.to_path_buf(), + size: 0, + is_dir: meta.is_dir(), + children: vec![], + }; + } + } + + if !meta.is_dir() { + return TreeNode { + name: name.to_string(), + path: path.to_path_buf(), + size: crate::disk_size(&meta), + is_dir: false, + children: vec![], + }; + } + + // Beyond max_depth: aggregate size only, don't retain children + if depth >= max_depth { + let file_count = AtomicU64::new(0); + let error_count = AtomicU64::new(0); + let size = dir_size_recursive(path, opts, 0, &file_count, &error_count); + return TreeNode { + name: name.to_string(), + path: path.to_path_buf(), + size, + is_dir: true, + children: vec![], + }; + } + + let read_dir = match fs::read_dir(path) { + Ok(rd) => rd, + Err(_) => { + return TreeNode { + name: name.to_string(), + path: path.to_path_buf(), + size: 0, + is_dir: true, + children: vec![], + }; + } + }; + + let entries: Vec = read_dir.filter_map(|e| e.ok().map(|e| e.path())).collect(); + + let mut children: Vec = entries + .par_iter() + .map(|p| { + let child_name = p + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + build_tree_node(p, &child_name, max_depth, depth + 1, opts, root_dev) + }) + .collect(); + + children.sort_by_key(|c| std::cmp::Reverse(c.size)); + + let size: u64 = children.iter().map(|c| c.size).sum(); + + TreeNode { + name: name.to_string(), + path: path.to_path_buf(), + size, + is_dir: true, + children, + } +} + +// --------------------------------------------------------------------------- +// Size helpers +// --------------------------------------------------------------------------- + fn dir_size_recursive( path: &Path, opts: &ScanOptions, diff --git a/crates/oops-core/src/sweep.rs b/crates/oops-core/src/sweep.rs index 491d7d3..d7aa18d 100644 --- a/crates/oops-core/src/sweep.rs +++ b/crates/oops-core/src/sweep.rs @@ -3,124 +3,70 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use rayon::prelude::*; -use tracing::{debug, debug_span}; use crate::disk_size; - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum WasteCategory { - NodeModules, - GitObjects, - BuildArtifacts, - CacheFiles, - LogFiles, - VirtualEnvs, - ContainerImages, - PlatformCache, -} - -impl WasteCategory { - pub fn label(&self) -> &'static str { - match self { - Self::NodeModules => "node_modules", - Self::GitObjects => ".git (large repos)", - Self::BuildArtifacts => "build artifacts", - Self::CacheFiles => "caches", - Self::LogFiles => "log files", - Self::VirtualEnvs => "virtual environments", - Self::ContainerImages => "container data", - Self::PlatformCache => "platform caches", - } - } - - pub fn description(&self) -> &'static str { - match self { - Self::NodeModules => "npm/yarn/pnpm dependency directories", - Self::GitObjects => "Git object stores in large repositories", - Self::BuildArtifacts => "Rust target/, Go bin/, CMake build/", - Self::CacheFiles => ".cache directories, pip cache, cargo registry", - Self::LogFiles => "*.log files and log directories", - Self::VirtualEnvs => "Python venv/, .venv/, conda envs", - Self::ContainerImages => "Docker/OCI image layers and volumes", - Self::PlatformCache => "~/Library/Caches, Xcode DerivedData", - } - } -} +use crate::rules::ResolvedRule; #[derive(Debug, Clone)] -pub struct WasteEntry { +pub struct SweepMatch { pub path: PathBuf, - pub category: WasteCategory, + pub rule_name: String, + pub description: String, pub size: u64, + pub clean_command: Option, } -pub fn sweep_directory(path: &Path, max_depth: usize) -> Result, crate::Error> { - let _span = debug_span!("sweep", path = %path.display(), max_depth).entered(); - debug!("starting sweep"); - +pub fn sweep_directory( + path: &Path, + rules: &[ResolvedRule], + max_depth: usize, +) -> Result, crate::Error> { if !path.exists() { return Err(crate::Error::PathNotFound(path.display().to_string())); } let mut results = Vec::new(); - sweep_recursive(path, 0, max_depth, &mut results); - - if let Some(home) = home_hint(path) { - debug!("checking platform caches"); - check_platform_caches(&home, &mut results); - } - - results.sort_by_key(|e| std::cmp::Reverse(e.size)); - debug!(entries = results.len(), "sweep complete"); - Ok(results) -} - -fn home_hint(path: &Path) -> Option { - let home = std::env::var("HOME").ok().map(PathBuf::from)?; - if path == home || path.starts_with(&home) { - Some(home) - } else { - None - } -} -fn check_platform_caches(home: &Path, results: &mut Vec) { - let candidates = [ - (home.join("Library/Caches"), WasteCategory::PlatformCache), - ( - home.join("Library/Developer/Xcode/DerivedData"), - WasteCategory::BuildArtifacts, - ), - ( - home.join("Library/Developer/CoreSimulator"), - WasteCategory::PlatformCache, - ), - (home.join(".cargo/registry"), WasteCategory::CacheFiles), - (home.join(".npm/_cacache"), WasteCategory::CacheFiles), - ( - home.join("Library/Containers/com.docker.docker"), - WasteCategory::ContainerImages, - ), - ]; - - for (candidate_path, category) in &candidates { - if candidate_path.is_dir() { - if results.iter().any(|r| r.path == *candidate_path) { - continue; + // Walk-based rules (DirBasename, FileGlob) + let walk_rules: Vec<&ResolvedRule> = rules.iter().filter(|r| !r.is_absolute_path()).collect(); + sweep_recursive(path, &walk_rules, 0, max_depth, &mut results); + + // Absolute path rules — check directly + for rule in rules.iter().filter(|r| r.is_absolute_path()) { + if let crate::rules::MatchMode::AbsolutePath(ref target) = rule.match_mode { + if !target.starts_with(path) && !path.starts_with(target) { + // Only check if the absolute path is under the scan root, + // or the scan root is under the absolute path + if !target.exists() { + continue; + } } - let size = quick_dir_size(candidate_path); - if size > 50 * 1024 * 1024 { - results.push(WasteEntry { - path: candidate_path.clone(), - category: category.clone(), - size, - }); + if target.exists() { + let size = quick_dir_size(target); + if size >= rule.min_size { + results.push(SweepMatch { + path: target.clone(), + rule_name: rule.name.clone(), + description: rule.description.clone(), + size, + clean_command: rule.expand_clean_command(target), + }); + } } } } + + results.sort_by_key(|m| std::cmp::Reverse(m.size)); + Ok(results) } -fn sweep_recursive(path: &Path, depth: usize, max_depth: usize, results: &mut Vec) { +fn sweep_recursive( + path: &Path, + rules: &[&ResolvedRule], + depth: usize, + max_depth: usize, + results: &mut Vec, +) { if depth > max_depth { return; } @@ -135,67 +81,43 @@ fn sweep_recursive(path: &Path, depth: usize, max_depth: usize, results: &mut Ve let name = entry.file_name().to_string_lossy().to_string(); let is_dir = entry_path.is_dir(); - if is_dir { - let category = match name.as_str() { - "node_modules" => Some(WasteCategory::NodeModules), - ".git" => { - let size = quick_dir_size(&entry_path); - if size > 100 * 1024 * 1024 { - Some(WasteCategory::GitObjects) - } else { - None - } - } - "target" if path.join("Cargo.toml").exists() => Some(WasteCategory::BuildArtifacts), - "build" if path.join("CMakeLists.txt").exists() => { - Some(WasteCategory::BuildArtifacts) - } - ".cache" => Some(WasteCategory::CacheFiles), - "venv" | ".venv" | "env" if has_python_marker(path) => { - Some(WasteCategory::VirtualEnvs) - } - "__pycache__" => Some(WasteCategory::CacheFiles), - _ => None, - }; - - if let Some(cat) = category { - let size = quick_dir_size(&entry_path); - if size > 1024 * 1024 { - results.push(WasteEntry { + // Test against each rule + let mut matched = false; + for rule in rules { + if rule.matches_entry(&name, &entry_path, is_dir) && rule.check_parent_condition(path) { + let size = if is_dir { + quick_dir_size(&entry_path) + } else { + fs::symlink_metadata(&entry_path) + .map(|m| disk_size(&m)) + .unwrap_or(0) + }; + + if size >= rule.min_size { + results.push(SweepMatch { path: entry_path.clone(), - category: cat, + rule_name: rule.name.clone(), + description: rule.description.clone(), size, + clean_command: rule.expand_clean_command(&entry_path), }); } - continue; + matched = true; + break; } + } - if name.starts_with('.') && name != ".git" { - continue; - } + if matched { + continue; + } - sweep_recursive(&entry_path, depth + 1, max_depth, results); - } else if name.ends_with(".log") { - if let Ok(m) = fs::symlink_metadata(&entry_path) { - let size = disk_size(&m); - if size > 10 * 1024 * 1024 { - results.push(WasteEntry { - path: entry_path, - category: WasteCategory::LogFiles, - size, - }); - } - } + // Recurse into unmatched directories (skip hidden dirs) + if is_dir && !name.starts_with('.') { + sweep_recursive(&entry_path, rules, depth + 1, max_depth, results); } } } -fn has_python_marker(dir: &Path) -> bool { - dir.join("setup.py").exists() - || dir.join("pyproject.toml").exists() - || dir.join("requirements.txt").exists() -} - fn quick_dir_size(path: &Path) -> u64 { let counter = AtomicU64::new(0); quick_dir_size_inner(path, &counter); diff --git a/docs/PATTERNS.md b/docs/PATTERNS.md index a659eec..9dcc8c2 100644 --- a/docs/PATTERNS.md +++ b/docs/PATTERNS.md @@ -54,20 +54,16 @@ pub trait Op { - `command_enum!` macro generates dispatch from individual command structs - Doc comments on `Args` structs become CLI help text -## UI Module (ui.rs) +## UI Module (ui/) -**All rendering lives in `crates/oops-cli/src/ui.rs`.** Commands never format output directly. +Shared rendering lives in `crates/oops-cli/src/ui/`. Per-command rendering goes in `commands//render.rs` (jig pattern). -- `render_dir_breakdown()` -- Overview proportional bars -- `render_drill_level()` / `render_drill_trail()` -- Drill output -- `render_tree_node()` -- Tree nodes (recursive) -- `render_top_entries()` -- Top-N table -- `render_volumes()` -- Volume capacity bars -- `render_sweep_results()` -- Waste summary + detail -- `proportion_bar()` / `usage_bar()` -- Bar rendering -- `Spinner` -- Loading indicator (TTY-aware, respects plain mode) - -Helper functions: `fmt_size()`, `short_path()`, `truncate()`, `bold()`, `dim()`, `highlight()` +- `ui/mod.rs` — plain mode toggle, table builder (`new_table`), bar rendering (`usage_bar`), size formatting (`fmt_size`), path helpers (`short_path`), truncation, terminal width, `Spinner` +- `ui/colors.rs` — `bold()`, `dim()`, `highlight()` (gated on plain mode) +- `ui/output.rs` — `success()`, `header()`, `progress()`, `detail()`, `print_error()` (stderr) +- `commands/map/render.rs` — icicle map renderer +- `render_volumes()` (in `ui/mod.rs`) — Volume capacity bars +- Sweep rendering — inlined in `commands/sweep.rs` (uses shared `ui::new_table` + helpers) ## On-Disk Sizing @@ -87,7 +83,7 @@ This reports actual block usage. Sparse files (Docker.raw) show real footprint, - **stderr**: All UI output -- status messages, tables, bars, spinners - **stdout**: Machine-readable output only (reserved for future use) - **`--plain` flag**: Disables colors and decorations for scripting -- All color/formatting goes through `ui.rs` helpers, never inline `colored` calls +- All shared color/formatting goes through `ui/` helpers, never inline `colored` calls ## Naming Conventions @@ -95,12 +91,12 @@ This reports actual block usage. Sparse files (Docker.raw) show real footprint, - **Types/structs**: `PascalCase` - **Functions/methods**: `snake_case` - **Constants**: `SCREAMING_SNAKE_CASE` -- **CLI subcommands**: lowercase (drill, sweep, tree, top, vol) +- **CLI subcommands**: lowercase (map, sweep, config, free, vol) ## Path Handling - Use `PathBuf` for owned paths, `&Path` for references -- never `String` -- Use `short_path()` from ui.rs to display `~/` prefix for home-relative paths +- Use `short_path()` from ui to display `~/` prefix for home-relative paths ## Testing diff --git a/docs/index.md b/docs/index.md index 4c70c91..3c33d37 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,8 +11,8 @@ cargo clippy # Lint cargo fmt --check # Check formatting make check # All of the above cargo run -- --help # Run CLI -cargo run -- # Default overview of current directory -cargo run -- drill ~ # Drill into home directory +cargo run -- # Map of current directory (default) +cargo run -- map ~ # Map of home directory cargo run -- sweep ~ # Find reclaimable waste ``` diff --git a/wiki/_docs/commands.md b/wiki/_docs/commands.md index caad499..955c541 100644 --- a/wiki/_docs/commands.md +++ b/wiki/_docs/commands.md @@ -5,108 +5,129 @@ slug: commands ## oops (default) -Run with no subcommand to get the overview: a proportional size breakdown of the current directory. +With no subcommand, `oops` runs `map` on the current (or given) directory. ```bash -oops # Current directory -oops /some/path # Specific path +oops # map of current directory +oops /some/path # map of a specific path ``` -Shows the largest entries with proportional bars, sorted by size. Use `oops vol` to see mounted volumes. - --- -## drill +## map -Follow the largest child at each level, drilling down automatically until the space is evenly distributed. +Visual disk usage map — shows where your space went with proportional bars, one entry per line. ```bash -oops drill # From current directory -oops drill /Users/al # From a specific path -oops drill --threshold 10 # Keep going until biggest child < 10% -oops drill -n 8 # Show 8 siblings at each level -oops drill --depth 20 # Go up to 20 levels deep +oops map ~/ # Where's my space going? +oops map / # Full disk breakdown (APFS-aware) +oops map --depth 2 ~/ # Shallow overview +oops map -n 10 --expand 10 ~/ # Show everything +oops map ~/Library/Caches # Drill into a specific suspect ``` | Flag | Default | Effect | |------|---------|--------| -| `--threshold` | 25% | Stop when largest child is below this % of parent | -| `-n` / `--show` | 5 | Number of sibling entries to show per level | -| `-d` / `--depth` | 10 | Maximum levels to drill | +| `-d` / `--depth` | 4 | Maximum depth levels | +| `-n` / `--show` | 5 | Max entries shown per directory level | +| `--expand` | 3 | Max children to recurse into per level | +| `--width` | auto | Terminal width override | -Output streams progressively — each level renders as soon as its scan completes. A spinner shows during scanning. +On macOS, `oops map /` automatically redirects to the APFS data volume to avoid double-counting via firmlinks. Shows a volume context bar at the top with scanned/other/free breakdown. --- ## sweep -Scan for common disk space wasters: `node_modules`, build artifacts, caches, Docker data, virtual environments, and macOS platform caches. +Find reclaimable disk space using configurable rules. Ships with 14 built-in rules; extend via `~/.config/oops/config.toml`. ```bash -oops sweep -oops sweep /Users/al -oops sweep --verbose # Show individual entries, not just category totals -oops sweep --depth 8 # Scan deeper +oops sweep ~/ # Scan home directory +oops sweep ~/ -v # Show individual entries +oops sweep ~/ --exec # Interactive cleanup (y/N/a/q per match) +oops sweep ~/ --rule rust-target # Filter to specific rule(s) ``` | Flag | Default | Effect | |------|---------|--------| | `-d` / `--depth` | 6 | Max depth to scan | -| `-v` / `--verbose` | off | Show individual waste entries | - -Categories detected: - -| Category | What it matches | -|----------|----------------| -| node_modules | npm/yarn/pnpm dependency trees | -| .git (large) | Git object stores > 100 MB | -| build artifacts | Rust `target/`, CMake `build/` | -| caches | `.cache/`, pip cache, cargo registry | -| log files | `*.log` files > 10 MB | -| virtual envs | Python `venv/`, `.venv/` | -| container data | Docker/OCI layers and volumes | -| platform caches | `~/Library/Caches`, Xcode DerivedData | +| `-v` / `--verbose` | off | Show individual entries | +| `-e` / `--exec` | off | Interactively confirm and execute cleanup commands | +| `--rule` | all | Filter to specific rule name(s) | + +Built-in rules: + +| Rule | Matches | Has cleanup command | +|------|---------|:---:| +| node_modules | `node_modules/` directories | ✓ | +| rust-target | `target/` when `Cargo.toml` in parent | ✓ | +| cmake-build | `build/` when `CMakeLists.txt` in parent | ✓ | +| python-venv | `.venv/` directories | ✓ | +| pycache | `__pycache__/` directories | ✓ | +| log-files | `*.log` files > 10 MB | ✓ | +| dot-cache | `.cache/` directories | — | +| macos-caches | `~/Library/Caches` | — | +| xcode-derived | `~/Library/Developer/Xcode/DerivedData` | ✓ | +| xcode-simulators | `~/Library/Developer/CoreSimulator` | — | +| npm-cache | `~/.npm/_cacache` | ✓ | +| pnpm-store | `~/Library/pnpm` | ✓ | +| cargo-registry | `~/.cargo/registry` | — | +| docker-data | `~/Library/Containers/com.docker.docker` | — | + +Rules marked with ✓ can be auto-cleaned with `--exec`. Rules without a cleanup command are reported but must be cleaned manually. --- -## tree +## config -Recursive size-weighted directory tree. Shows all entries above a minimum percentage of their parent. +Inspect and manage oops configuration. Creates the config file automatically if it doesn't exist. ```bash -oops tree -oops tree --depth 5 # Go deeper -oops tree --min-pct 5 # Only show items > 5% of parent +oops config # Show config path + loaded rules +oops config init # Explicitly create default config ``` -| Flag | Default | Effect | -|------|---------|--------| -| `-d` / `--depth` | 3 | Max depth | -| `--min-pct` | 1.0 | Minimum % of parent to display | +Config lives at `$XDG_CONFIG_HOME/oops/config.toml` (default: `~/.config/oops/config.toml`). + +Add custom rules or disable built-ins: + +```toml +# Add a custom rule +[[rules]] +name = "jig-worktrees" +dir = "worktrees" +min_size = "100 MB" +clean = "rm -rf {path}" +description = "stale jig worker worktrees" + +# Disable a built-in +[[rules]] +name = "node_modules" +enabled = false +``` + +Rule match modes (use exactly one per rule): +- `dir = "name"` — match directories by basename +- `path = "~/some/path"` — match a specific absolute path (tilde expanded) +- `file_glob = "*.ext"` — match files by extension + +Optional fields: +- `when_parent_has = "filename"` — parent directory must contain this file +- `min_size = "50 MB"` — skip entries below this threshold +- `clean = "command {path}"` — cleanup command template (`{path}`, `{parent}`, `{name}`) +- `description = "..."` — human-readable description --- -## top +## free -Find the N largest files and directories, like a disk-aware `ls`. +One-liner: how much space is left on the volume containing the current (or specified) path. ```bash -oops top -oops top -n 50 # Top 50 -oops top --files-only # Only files -oops top --dirs-only # Only directories -oops top --min-size 1GB # Only items > 1 GB -oops top --depth 8 # Scan deeper +oops free +oops free /Volumes/External ``` -| Flag | Default | Effect | -|------|---------|--------| -| `-n` / `--count` | 20 | Number of results | -| `-d` / `--depth` | 5 | Max scan depth | -| `--files-only` | off | Exclude directories | -| `--dirs-only` | off | Exclude files | -| `--min-size` | none | Minimum size filter (e.g. `100MB`, `1GB`) | - --- ## volumes / vol @@ -114,8 +135,17 @@ oops top --depth 8 # Scan deeper Show all mounted filesystems with capacity bars. ```bash -oops volumes oops vol ``` -Filters out pseudo-filesystems (devfs, map auto, none). Shows mount point, filesystem type, total/used/free space, and a color-coded capacity bar (green < 70%, yellow < 90%, red >= 90%). +Color-coded: green (< 70%), yellow (< 90%), red (>= 90%). + +--- + +## update + +Self-update to the latest release. + +```bash +oops update +``` diff --git a/wiki/_docs/install.md b/wiki/_docs/install.md index 2bb1d94..027d760 100644 --- a/wiki/_docs/install.md +++ b/wiki/_docs/install.md @@ -56,14 +56,14 @@ oops You'll get a size breakdown of the current directory. From there: ```bash -# Drill into the biggest space hog -oops drill +# See where your space went +oops map ~/ # Find reclaimable waste (node_modules, caches, etc.) -oops sweep +oops sweep ~/ -# See the full size tree -oops tree +# Clean up interactively +oops sweep ~/ --exec ``` ## Flags diff --git a/wiki/_docs/overview.md b/wiki/_docs/overview.md index 683affc..dec50f3 100644 --- a/wiki/_docs/overview.md +++ b/wiki/_docs/overview.md @@ -11,8 +11,9 @@ The design principles: - **Fast by default.** Parallel scanning with rayon. Most directories render instantly. - **Honest sizes.** Reports on-disk block usage, not apparent file sizes. A 1 TiB sparse Docker image that only uses 20 GiB of blocks shows as 20 GiB. -- **Terminal-native.** Colored output with progress bars, tree drawing, and proportional bars — all to stderr. Machine-readable output goes to stdout. -- **No config needed.** Run `oops` and get a directory breakdown immediately. Use `oops vol` when you need volume info. +- **Terminal-native.** Colored output with proportional bars and tree drawing — all to stderr. Machine-readable output goes to stdout. +- **Configurable.** Built-in sweep rules ship by default; extend with your own via `~/.config/oops/config.toml`. +- **APFS-aware.** Handles macOS firmlinks correctly when scanning `/` to avoid double-counting. ## Architecture @@ -20,25 +21,42 @@ oops is a Rust workspace with two crates: ``` crates/ -├── oops-core/ # Library: scanning, volumes, waste detection +├── oops-core/ # Library: scanning, volumes, config, rules, sweep engine └── oops-cli/ # Binary: commands, UI rendering, terminal output ``` **oops-core** handles the heavy lifting: - `scan_top_entries()` — parallel scan of immediate children with aggregated sizes -- `scan_directory()` — recursive flat scan up to a max depth +- `scan_tree()` — recursive tree with sizes, used by `map` - `list_volumes()` — portable volume detection via `df -Pk` -- `sweep_directory()` — waste pattern matching (node_modules, caches, build artifacts) +- `sweep_directory()` — rule-based matching engine for reclaimable space +- `config` module — XDG config loading, TOML parsing +- `rules` module — built-in rules, user rule merging, template expansion **oops-cli** handles presentation: - Each command implements an `Op` trait with typed errors and output -- All rendering lives in a single `ui` module — commands never format output directly +- Shared rendering lives in a `ui/` module (colors, output, tables, bars) +- Per-command rendering lives in `commands//render.rs` - A `command_enum!` macro generates the dispatch enum from individual command structs +## Key commands + +| Command | Purpose | +|---------|---------| +| `oops map` (default) | Visual disk usage map — proportional bars, one line per entry | +| `oops sweep` | Find reclaimable space using configurable rules | +| `oops config` | Inspect/manage config and sweep rules | +| `oops free` | One-liner: how much space is left? | +| `oops vol` | Mounted filesystems with capacity bars | + ## On-disk sizing -Most disk usage tools report **apparent size** — what `metadata.len()` returns. This is the logical file size. For normal files, this is fine. But for **sparse files** (like Docker.raw on macOS), the apparent size can be wildly larger than the actual disk blocks allocated. +Most disk usage tools report **apparent size** — what `metadata.len()` returns. For **sparse files** (like Docker.raw on macOS), the apparent size can be wildly larger than the actual disk blocks allocated. oops uses `stat.blocks * 512` — the same metric `du` reports by default. This gives you the real on-disk footprint. > A Docker.raw file might report 1 TiB apparent size but only consume 20 GiB of actual disk blocks. oops shows you the 20 GiB. + +## APFS handling + +On macOS, `oops map /` redirects to `/System/Volumes/Data` and skips child mount points to avoid double-counting via APFS firmlinks. The volume context bar at the top shows the breakdown: scanned data, system/other, and free space. diff --git a/wiki/_docs/recipes.md b/wiki/_docs/recipes.md index afaf3d6..d1ed8b2 100644 --- a/wiki/_docs/recipes.md +++ b/wiki/_docs/recipes.md @@ -7,178 +7,175 @@ Real-world scenarios and the fastest way to diagnose them with oops. ## "My disk is full and I don't know why" -Start with the overview, then drill: - ```bash -oops -oops drill ~ +oops map ~/ ``` -The drill auto-follows the biggest child at each level. In one command you'll trace the path from your home directory to the specific file or directory eating all your space — often Docker data, Xcode DerivedData, or a forgotten game install. +One command. Shows your full disk breakdown with volume context at the top (scanned / other / free), then drills into the biggest space consumers with proportional bars. You'll immediately see whether it's repos, Library, Docker, or something unexpected. -## "What's taking up space in this repo?" +To see the full disk including system files: ```bash -cd ~/repos/my-project -oops +oops map / ``` -The default overview shows a proportional breakdown. Usually it's `target/`, `node_modules/`, or `.git/`. For deeper visibility: +## "What can I clean up right now?" ```bash -oops tree --depth 4 +oops sweep ~/ --exec ``` -## "Is Docker eating my disk?" +Finds reclaimable space (build artifacts, caches, node_modules, etc.) and walks you through cleanup interactively — shows each match with the cleanup command and asks y/N before running it. + +For a preview without cleaning: ```bash -oops drill ~/Library/Containers/com.docker.docker +oops sweep ~/ +oops sweep ~/ -v # Show individual paths ``` -This drills through `Data/vms/0/data/` and shows you `Docker.raw`'s actual on-disk size (not the inflated apparent size). If it's huge: +## "What's taking up space in this repo?" ```bash -# In Docker Desktop: Settings → Resources → Virtual disk limit -# Or prune unused data: -docker system prune -a +cd ~/repos/my-project +oops map . ``` -## "Where are all my node_modules?" +Usually it's `target/`, `node_modules/`, or `.git/`. For a quick cleanup: ```bash -oops sweep ~ +oops sweep . --exec ``` -Sweep detects `node_modules` directories across your entire home folder. The summary shows total reclaimable space by category. For details: +## "Where are all my node_modules?" ```bash -oops sweep ~ --verbose +oops sweep ~/ --rule node_modules -v ``` -This lists every individual waste entry with its path, so you can decide what to nuke: +Lists every `node_modules/` with its path and size. Add `--exec` to interactively remove them: ```bash -# Delete a specific one -rm -rf ~/old-project/node_modules - -# Or nuke them all (careful!) -oops sweep ~ --verbose | grep node_modules +oops sweep ~/ --rule node_modules --exec ``` -## "Xcode is eating 50 GB again" +## "Is Docker eating my disk?" ```bash -oops sweep ~ +oops map ~/Library/Containers/com.docker.docker ``` -Sweep checks `~/Library/Developer/Xcode/DerivedData` and `~/Library/Developer/CoreSimulator` automatically. To see just how bad it is: +Shows you Docker.raw's actual on-disk size (not the inflated apparent size). If it's huge: ```bash -oops drill ~/Library/Developer +docker system prune -a ``` -Clean up: +## "Xcode is eating 50 GB again" ```bash -rm -rf ~/Library/Developer/Xcode/DerivedData -# Xcode will rebuild what it needs +oops sweep ~/ --rule xcode -v --exec ``` -## "What are the biggest files on my machine?" +The `--rule xcode` flag matches both `xcode-derived` and `xcode-simulators` rules. For a full picture: ```bash -oops top ~ --depth 8 -n 30 +oops map ~/Library/Developer ``` -This does a deep recursive scan and shows the 30 largest items. Filter to just files: +Clean up: ```bash -oops top ~ --depth 8 --files-only --min-size 500MB +rm -rf ~/Library/Developer/Xcode/DerivedData +xcrun simctl delete unavailable +xcrun simctl runtime delete all ``` -## "Which volume is running out of space?" +## "What's in ~/Library?" + +`~/Library` is a black box. Map it: ```bash -oops vol +oops map ~/Library --depth 3 ``` -Color-coded capacity bars: green (< 70%), yellow (< 90%), red (>= 90%). Shows all mounted filesystems with used/total/free. - -## "I freed space but disk still shows full" +Common offenders: +- `Application Support/` — app data (Claude VMs, Steam games, Slack) +- `Caches/` — safe to delete, apps rebuild them +- `Containers/` — sandboxed app data (Docker lives here) +- `Developer/` — Xcode caches and simulators +- `pnpm/` — pnpm global store -macOS uses APFS snapshots and purgeable space. Check with: +## "Cargo registry is huge" ```bash -oops vol +oops sweep ~/ --rule cargo -v ``` -If the volume still shows full after deleting files, Time Machine snapshots may be holding references. macOS will purge these eventually, or you can force it: +The registry cache grows with every unique dependency version. Clean old versions: ```bash -tmutil listlocalsnapshots / -tmutil deletelocalsnapshots +cargo cache --autoclean +# or +rm -rf ~/.cargo/registry/cache ``` -## "What's in ~/Library?" +## "I freed space but disk still shows full" -`~/Library` is a black box. Drill into it: +macOS uses APFS purgeable space. Check with: ```bash -oops drill ~/Library +oops free ``` -Common offenders: -- `Application Support/` — app data (Steam games, Slack, etc.) -- `Caches/` — safe to delete, apps rebuild them -- `Containers/` — sandboxed app data (Docker lives here) -- `Developer/` — Xcode caches and simulators -- `pnpm/` — pnpm global store - -## "Compare two directories" - -Run the overview on each: +If the volume still shows full after deleting files, Time Machine snapshots may be holding references. macOS will purge these under pressure, or force it: ```bash -oops /path/to/dir-a -oops /path/to/dir-b +tmutil listlocalsnapshots / +tmutil deletelocalsnapshots ``` -Or use top to find the biggest items in each: +## "Where do I start on a new machine?" ```bash -oops top /path/to/dir-a -n 10 -oops top /path/to/dir-b -n 10 +oops map ~/ # Where's my space going? +oops sweep ~/ --exec # Clean up the obvious stuff +oops config # See and customize sweep rules ``` -## "Automated disk monitoring" +Three commands. You know your disk layout, you've reclaimed the easy wins, and the tool is configured for next time. -Use `--plain` mode for scripts: +## "I want to track custom waste patterns" ```bash -# Alert if any volume > 90% -oops vol --plain 2>&1 | awk '{print $5}' | grep -q '9[0-9]%' && echo "DISK ALERT" +oops config # Creates ~/.config/oops/config.toml if needed ``` -## "Cargo registry is huge" +Edit the config to add rules: -```bash -oops drill ~/.cargo +```toml +[[rules]] +name = "jig-worktrees" +dir = "worktrees" +min_size = "100 MB" +clean = "rm -rf {path}" +description = "stale jig worker worktrees" + +[[rules]] +name = "dist-builds" +dir = "dist" +when_parent_has = "package.json" +min_size = "50 MB" +clean = "rm -rf {path}" +description = "frontend build output" ``` -The registry cache grows with every unique dependency version you've ever built. Clean old versions: +Now `oops sweep` picks up your custom rules alongside the built-ins. -```bash -cargo cache --autoclean -# or -rm -rf ~/.cargo/registry/cache -``` +## "Automated disk monitoring" -## "Where do I start on a new machine?" +Use `--plain` mode for scripts: ```bash -oops vol -oops drill ~ -oops sweep ~ +oops free --plain 2>&1 | head -1 ``` - -Three commands. You now know your volume health, your biggest space consumer, and all the reclaimable waste. Takes about 30 seconds total.