Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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/<cmd>/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`
Expand All @@ -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/)
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ rayon = "1"

# Utilities
bytesize = "2"
toml = "1"
dirs = "6"

# Internal crates
oops-core = { path = "crates/oops-core" }
Expand Down
64 changes: 34 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
```

Expand Down
19 changes: 7 additions & 12 deletions crates/oops-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
121 changes: 121 additions & 0 deletions crates/oops-cli/src/commands/config.rs
Original file line number Diff line number Diff line change
@@ -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<ConfigAction>,
}

#[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<Self::Output, Self::Error> {
match &self.action {
None => show_config(),
Some(ConfigAction::Init) => init_config(),
}
}
}

fn show_config() -> Result<NoOutput, ConfigError> {
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<NoOutput, ConfigError> {
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)
}
Loading
Loading