# Low-Level Technical Design Specification (LLD): `dev-prune`
This document defines the Low-Level Design (LLD) specification for **`dev-prune`** (`devp`), detailing crate module layouts, data schemas, trait contracts, atomic algorithms, and path resolution mechanics.
---
## 🔬 1. Crate Module Map & Visibility
```
src/
├── main.rs # Executable entry point delegating to dev_prune::run_cli()
├── lib.rs # Primary library root, CLI Clap parser, subcommands & normalizer
├── config.rs # Registry struct, Settings struct, RepoEntry & atomic I/O
├── engine.rs # Core pruning coordinator, restore executor & space metrics
├── constants.rs # Application version, default paths, timeouts & file names
├── output.rs # Terminal styling, banners, human-readable byte formats & shell fix formatters
├── workspace.rs # Bounded intra-repository project discovery (monorepo support)
├── commands/ # One module per CLI subcommand
│ ├── mod.rs # Subcommand router
│ ├── init.rs # Bulk repository registration
│ ├── link.rs # Single-repository registration (also used by the Git hook)
│ ├── run.rs # Prune execution & interactive selection entry point
│ ├── status.rs # Registry summary & TUI dashboard entry point
│ ├── restore.rs # Dependency restoration
│ ├── undo.rs # Last-run reversal
│ ├── config.rs # Settings, per-repo config & daemon/icon subcommands
│ ├── daemon.rs # Scheduler enable/disable/status
│ ├── hook.rs # Global Git hook install/uninstall/status
│ ├── icon.rs # Icon & JSON Schema registration
│ ├── skill.rs # Agent skill file emission
│ ├── update.rs # Self-update check
│ └── uninstall.rs # Full removal of installed state
├── scanner/ # Git repository discovery & activity solver
│ ├── mod.rs # Directory walker & git repository validator
│ └── git.rs # Git commit log parser & mtime fallback scanner
├── adapters/ # Multi-ecosystem package manager adapters
│ ├── mod.rs # PackageManager trait, BloatDir struct & lockfile helpers
│ ├── npm.rs # npm lockfile enforcement & restore
│ ├── pnpm.rs # pnpm lockfile enforcement & restore
│ ├── yarn.rs # yarn lockfile enforcement & restore
│ ├── bun.rs # bun lockfile enforcement & restore
│ ├── uv.rs # uv lockfile enforcement & restore
│ ├── venv.rs # standard Python venv restore & detection
│ ├── cargo_adapter.rs # Rust Cargo target/ cleanup & lockfile guard
│ └── go.rs # Go mod vendor/ cleanup & go.sum sync
├── daemon/ # Cross-platform background scheduling
│ ├── mod.rs # Scheduler router & status inspector
│ ├── windows.rs # Windows Task Scheduler (schtasks) integration
│ ├── macos.rs # macOS LaunchAgent (plist) integration
│ └── linux.rs # Linux systemd user service & timer integration
└── tui/ # Terminal Dashboard UI
├── mod.rs # TUI submodule root
├── selection_view.rs # Interactive pruning candidate selection view
└── status_view.rs # Ratatui dashboard renderer & keyboard listener
```
---
## 💾 2. Data Schemas & Persistence Contracts
### 2.1 Global Registry Schema (`~/.config/dev-prune/registry.json` / `%APPDATA%\dev-prune\registry.json`)
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Registry {
pub version: String,
pub settings: Settings,
pub repositories: HashMap<PathBuf, RepoEntry>,
/// Cumulative bytes freed across every prune pass.
#[serde(default)]
pub total_freed_bytes: u64,
/// How many prune passes have deleted something, ever. One per *pass*, not per
/// repository and not per directory. Incremented only in `Registry::record_prune`.
#[serde(default)]
pub total_pruned_count: u64,
/// A summary of each recent pass, oldest first, capped at `PRUNE_HISTORY_LIMIT`.
/// Written from 1.1.0 onward; `devp stats` reads it.
#[serde(default)]
pub prune_history: Vec<PruneRunSummary>,
/// Repositories added by the most recent `init` / `link`, for `devp undo`.
#[serde(default)]
pub last_added_repos: Vec<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
pub idle_days: u64,
pub check_interval_days: u64,
/// Whether the setup pass installs the OS scheduler. On by default.
pub auto_daemon: bool,
/// Whether the setup pass installs the global Git hooks. On by default.
#[serde(default = "default_auto_hooks")]
pub auto_hooks: bool,
/// Whether dev-prune installs its own missing integrations. On by default.
#[serde(default = "default_auto_setup")]
pub auto_setup: bool,
#[serde(default = "default_require_confirmation")]
pub require_confirmation: bool,
#[serde(default = "default_command_timeout_secs")]
pub command_timeout_secs: u64,
/// Smallest bloat directory worth deleting, in MiB. `0` disables the floor.
#[serde(default = "default_min_size_mb")]
pub min_size_mb: u64,
/// Whether dev-prune asks GitHub for the latest release. On by default, opt-out.
#[serde(default = "default_update_check")]
pub update_check: bool,
/// How many directory levels below a repository root discovery descends.
/// Six by default; clamped to `constants::MAX_SCAN_DEPTH_LIMIT`.
#[serde(default = "default_scan_depth")]
pub scan_depth: usize,
/// Whether cargo and go may run the sync command that rewrites tracked
/// manifests. Off by default.
#[serde(default = "default_allow_manifest_rewrite")]
pub allow_manifest_rewrite: bool,
/// Days between automatic release checks (default 7). Only the automatic
/// check honours this; `devp update` always asks.
#[serde(default = "default_update_check_interval_days")]
pub update_check_interval_days: i64,
/// How long the release check waits for GitHub before giving up (default 5s).
#[serde(default = "default_update_check_timeout_secs")]
pub update_check_timeout_secs: u64,
/// Whether the setup pass may install the Git hooks in front of another
/// tool's `core.hooksPath`, forwarding to the displaced directory. Off by
/// default; same thing as `devp hook install --chain`.
#[serde(default = "default_auto_hooks_chain")]
pub auto_hooks_chain: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepoEntry {
pub added_at: DateTime<Utc>,
pub last_pruned_at: Option<DateTime<Utc>>,
pub override_idle_days: Option<u64>,
pub enabled: bool,
/// Bytes this one repository has given back, accumulated across passes.
/// Written from 1.1.0 onward; `devp stats` ranks by it.
#[serde(default)]
pub total_freed_bytes: u64,
}
/// One entry in `Registry::prune_history` — what a single pass deleted.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PruneRunSummary {
pub at: DateTime<Utc>,
pub bytes_freed: u64,
pub dirs_removed: usize,
pub repos_touched: usize,
}
```
### 2.2 Per-Repository Config Schema (`.devprune.json`)
```json
{
"$schema": "https://devprune.vkrishna04.me/schemas/v1/devprune.schema.json",
"project_name": null,
"ignore": false,
"disable_daemon": false,
"disable_hooks": false,
"override_idle_days": 30
}
```
---
## ⚙️ 3. `PackageManager` Trait Contract
All ecosystem adapters in `src/adapters/` implement the [`PackageManager`](../../src/adapters/mod.rs) trait:
```rust
pub trait PackageManager: Send + Sync {
/// Unique human-readable name of the adapter (e.g. "npm", "uv", "cargo", "go").
fn name(&self) -> &'static str;
/// Check if this adapter applies to a given project directory path.
fn detect(&self, project_path: &Path) -> bool;
/// List existing bloat directories managed by this adapter.
fn bloat_dirs(&self, project_path: &Path) -> Vec<BloatDir>;
/// Lockfile enforcement: MUST prove the lockfile can rebuild the tree before any
/// bloat directory is deleted. If this returns an Err, pruning for this adapter is
/// ABORTED. `policy` carries `allow_manifest_rewrite` and `command_timeout_secs`.
fn enforce_lockfile(&self, project_path: &Path, policy: EnforcePolicy) -> Result<()>;
/// Restore dependencies from lockfile (used by `devp restore`). `timeout` is
/// the user's `command_timeout_secs`, threaded explicitly.
fn restore(&self, project_path: &Path, timeout: std::time::Duration) -> Result<()>;
/// `restore`, told the name the pruned directory had. Default impl ignores the
/// name and calls `restore`; venv overrides it to rebuild under the original
/// directory name instead of always `.venv`.
fn restore_named(
&self,
project_path: &Path,
dir_name: &str,
timeout: std::time::Duration,
) -> Result<()> { let _ = dir_name; self.restore(project_path, timeout) }
/// Lockfiles that identify this manager. Used only to break ties between
/// adapters that share a bloat directory; defaults to an empty slice.
fn lockfiles(&self) -> &'static [&'static str] { &[] }
}
```
### 3.1 Multiple Adapters per Directory
`detect_adapters()` returns **every** adapter that applies. A directory holding
`package-lock.json`, `uv.lock` and `Cargo.toml` has three managers, each owning a
different bloat directory, and all three run.
The exception is adapters that would fight over the *same* directory.
`resolve_conflicts()` reduces those to exactly one owner before anything runs:
**JavaScript (`node_modules`)** — npm, pnpm, yarn and bun, resolved strongest signal
first:
1. The `packageManager` field in `package.json` (Corepack) — the maintainers said so.
2. Bookkeeping files inside `node_modules` — `.pnpm` / `.modules.yaml` (pnpm),
`.yarn-state.yml` / `.yarn-integrity` (yarn), `.package-lock.json` (npm). Whoever
built the tree about to be deleted is the manager whose lockfile must rebuild it.
pnpm and yarn are tested before npm, because a project migrated away from npm can
still carry npm's marker inside a tree the new manager rebuilt around it.
3. The most recently written lockfile, as a last resort.
```mermaid
flowchart TD
Det["detect_adapters(dir)<br/>every adapter whose detect() is true"] --> Count{"2 or more of<br/>npm / pnpm / yarn / bun?"}
Count -->|No| Py
Count -->|Yes| S1{"package.json has packageManager,<br/>and that manager was detected?"}
S1 -->|Yes| Win["retain only the winner<br/>among the JS managers"]
S1 -->|No| S2{"node_modules/ exists with a marker?<br/>pnpm → yarn → npm, in that order"}
S2 -->|Yes| Win
S2 -->|No| S3{"any detected JS lockfile<br/>readable on disk?"}
S3 -->|Yes| Newest["newest mtime wins;<br/>ties keep get_all_adapters() order"]
Newest --> Win
S3 -->|No| Keep["leave all of them —<br/>no evidence to choose on"]
Win --> Py
Keep --> Py
Py{"uv detected?"} -->|Yes| DropVenv["drop the venv adapter"]
Py -->|No| Done
DropVenv --> Done(["final adapter set"])
```
*Owner selection. Non-JS, non-Python adapters are never touched by this pass — cargo,
go and uv can all coexist with a JS manager in the same directory.*
**Python (the virtual environment)** — uv wins over the plain-venv adapter whenever it
detects, because it has a real lockfile and can reproduce the environment exactly. The
`requirements.txt` + `pyvenv.cfg` adapter takes everything else.
### 3.2 Intra-Repository Project Discovery
[`workspace::discover()`](../../src/workspace.rs) walks a registered repository once and
returns every directory where at least one adapter applies, so projects at different
depths are pruned, verified and restored independently.
```mermaid
flowchart TD
Root["WalkDir(repo_root)<br/>follow_links(false), max_depth(6)"] --> D{"depth == 0?"}
D -->|Yes| Test
D -->|No| Scan["is_scannable(entry)"]
Scan --> F1{"not a directory?"}
F1 -->|Yes| Pass["descend / pass through"]
F1 -->|No| F2{"name starts with '.'?"}
F2 -->|Yes| Prune["do not descend"]
F2 -->|No| F3{"node_modules, target,<br/>vendor, bower_components?"}
F3 -->|Yes| Prune
F3 -->|No| F4{"contains pyvenv.cfg?"}
F4 -->|Yes| Prune
F4 -->|No| F5{"contains .git?"}
F5 -->|Yes| Prune
F5 -->|No| Pass
Pass --> Test{"detect_adapters(dir)<br/>non-empty?"}
Test -->|Yes| Emit["emit Project { path, relative, adapters }"]
Test -->|No| Skip["not a project"]
```
The walk is bounded: `scan_depth` levels below the repository root — six by default,
`devp config set scan_depth N` globally or `"scan_depth"` in a repository's
`.devprune.json`, clamped to `MAX_SCAN_DEPTH_LIMIT` (32) — never into `node_modules` /
`target` / `vendor` / `bower_components` / any directory containing `pyvenv.cfg` / any
hidden directory, and never into a nested `.git` (a submodule or vendored checkout owns
its own history and idle state).
Note the asymmetry at the root: `filter_entry` lets depth 0 through unconditionally, so
a repository whose own root is hidden or named `vendor` is still scanned when it is the
directory you registered. The exclusions apply to what is *inside* it.
---
## 🔒 4. Core Algorithms
### 4.1 Atomic File Storage Swap
To prevent state file corruption during abrupt system power loss:
```rust
pub fn save(&self) -> Result<()> {
let path = Self::registry_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp_path = path.with_extension("json.tmp");
let content = serde_json::to_string_pretty(self)?;
std::fs::write(&tmp_path, content)?;
std::fs::rename(&tmp_path, &path)?;
Ok(())
}
```
### 4.2 Binary Aliasing Link Mechanism (`dev-prune` <-> `devp`)
On binary execution, `dev_prune::ensure_devp_alias()` automatically resolves executable location via `std::env::current_exe()`. If `devp` (or `devp.exe`) does not exist next to `dev-prune`, it creates a hard link or file copy so both aliases work interchangeably.
### 4.3 Current Working Directory (CWD) Determinism
`dev-prune` is 100% CWD-independent:
1. `Registry::registry_path()` computes global path via `dirs::config_dir()`, referencing user profile root (`%APPDATA%` or `~/.config/`).
2. Relative CLI target paths (e.g. `devp run .`) are immediately canonicalized into absolute filesystem paths before querying the registry or running scanner passes.