Skip to main content

dev_prune/
lib.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Copyright 2026 VKrishna04
5//
6// Licensed under the Apache License, Version 2.0 (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10//     http://www.apache.org/licenses/LICENSE-2.0
11//
12// Unless required by applicable law or agreed to in writing, software
13// distributed under the License is distributed on an "AS IS" BASIS,
14// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15// See the License for the specific language governing permissions and
16// limitations under the License.
17
18pub mod adapters;
19pub mod commands;
20pub mod config;
21pub mod constants;
22pub mod daemon;
23pub mod engine;
24pub mod json;
25pub mod output;
26pub mod scanner;
27pub mod setup;
28pub mod tui;
29pub mod workspace;
30
31use clap::{Parser, Subcommand};
32
33/// Process exit codes, so scripts and CI can branch on the outcome.
34///
35/// These are part of the tool's contract and are documented in `docs/CLI_REFERENCE.md`;
36/// changing one is a breaking change.
37pub mod exit_code {
38    /// The command did what it was asked to do. A prune that deleted nothing because
39    /// nothing was idle is still a success.
40    pub const OK: i32 = 0;
41    /// The command failed. The reason is on stderr.
42    pub const FAILURE: i32 = 1;
43    /// The arguments were not usable. Emitted by clap, listed here so the set is complete.
44    pub const USAGE: i32 = 2;
45}
46
47/// Restore the default disposition for `SIGPIPE`.
48///
49/// Rust ignores `SIGPIPE` at startup, which turns `devp status | head` into a panic —
50/// "failed printing to stdout" plus a backtrace — where every other Unix tool simply
51/// stops. Putting the default back makes dev-prune behave like `ls` in a pipeline.
52#[cfg(unix)]
53fn restore_sigpipe() {
54    // SAFETY: `signal` with SIG_DFL is async-signal-safe and this runs before any
55    // thread is spawned.
56    unsafe {
57        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
58    }
59}
60
61#[cfg(not(unix))]
62fn restore_sigpipe() {}
63
64/// Explain the rename, then answer the question the user was really asking.
65///
66/// Nobody types `--force` for fun. They type it because something was not pruned and
67/// they want the tool to stop arguing — so a bare "the flag moved" note would leave
68/// them exactly as stuck as before. The list below is every reason a directory gets
69/// skipped, with the fix, because six of the seven are not what `--force` was for.
70///
71/// Goes to stderr with the rest of the diagnostics, so `--json` stays parseable.
72fn print_force_help() {
73    output::print_notice(
74        "`--force` is now `--ignore-idle`, which is what it has always actually done. \
75         The old spelling still works.",
76    );
77    eprintln!(
78        "
79  Reaching for --force usually means something did not get pruned. It is one of these:
80
81    Not idle yet          A commit or a source edit inside idle_days (15 by default).
82                          This is the one --ignore-idle is for.
83    Lockfile unusable     The package manager could not confirm it. Run the command
84                          dev-prune printed, then try again. No flag skips this check.
85    Opted out             `ignore.devprune.json` in the root, or `\"ignore\": true`
86                          in `.devprune.json`.
87    Under the size floor  Smaller than min_size_mb. `--min-size 0` includes it.
88    Not registered        `devp link .` first; `devp status` shows what is tracked.
89    Nested or symlinked   A submodule is pruned as itself, never as part of its
90                          parent, and a linked directory is refused. By design.
91    Too deep              Beyond scan_depth (6 levels). `devp config set scan_depth N`.
92
93  `devp run --dry-run` names the actual reason, per repository.
94
95  Still stuck? Ask your AI assistant — `devp skill` hands it the full troubleshooting
96  tree, including this list. It has read it. It wrote it.
97"
98    );
99}
100
101/// Whether a failure is just the reader at the other end of a pipe hanging up.
102///
103/// `devp status | head -5` is a normal thing to type, and the closed pipe it produces is
104/// not an error worth printing — printing it would itself fail.
105fn is_broken_pipe(err: &anyhow::Error) -> bool {
106    err.chain().any(|cause| {
107        cause
108            .downcast_ref::<std::io::Error>()
109            .is_some_and(|io_err| io_err.kind() == std::io::ErrorKind::BrokenPipe)
110    })
111}
112
113/// Universal, lockfile-safe workspace pruner and background dependency cleaner.
114///
115/// Note: `dev-prune` and `devp` are interchangeable binary aliases.
116#[derive(Parser, Debug)]
117#[command(name = constants::APP_NAME)]
118#[command(version = constants::VERSION)]
119#[command(
120    about = "Universal, lockfile-safe workspace pruner and background dependency cleaner\nNote: `dev-prune` and `devp` are interchangeable binary aliases."
121)]
122#[command(
123    after_help = "EXAMPLES:\n  devp init ~/Code          Scan directory trees & onboard workspaces\n  devp link                 Register current repository\n  devp run                  Execute prune pass across inactive repositories\n  devp status               View system status dashboard\n  devp caches               Size every package manager cache (deletes nothing)\n  devp status daemon        Check background daemon status (alias for `devp config daemon status`)\n  devp status . hook        Check workspace Git hook status (alias for `devp config . hook status`)\n  devp config . daemon disable  Disable daemon background pass for current workspace\n  devp restore .            Restore missing node_modules/.venv via lockfile\n  devp undo                 Revert most recent init or link action\n\nBINARY ALIAS:\n  `dev-prune` and `devp` invoke the exact same executable."
124)]
125pub struct Cli {
126    #[command(subcommand)]
127    command: Commands,
128
129    /// Simulate pruning without deleting any files.
130    #[arg(long, global = true)]
131    dry_run: bool,
132
133    /// Prune repositories you are still working in, ignoring the idle-day threshold.
134    ///
135    /// This is the *only* check it lifts. Lockfile verification, `ignore.devprune.json`,
136    /// `"ignore": true`, symlink refusal and nested-repository refusal all still apply.
137    #[arg(long, global = true)]
138    ignore_idle: bool,
139
140    /// Deprecated spelling of `--ignore-idle`.
141    ///
142    /// Renamed because "force" reads like "override the safety checks", which it never
143    /// did — it only ever skipped the idle-day wait. Still accepted; prints a note.
144    #[arg(long, global = true)]
145    force: bool,
146
147    /// Bypass interactive confirmation prompts.
148    #[arg(long, short = 'y', global = true)]
149    yes: bool,
150}
151
152#[derive(Subcommand, Debug)]
153pub enum Commands {
154    /// Workspace onboarding & discovery: crawl paths for Git repositories and register them.
155    #[command(alias = "scan", alias = "onboard")]
156    Init {
157        /// Paths to scan for Git repositories (defaults to current directory).
158        #[arg(default_value = ".")]
159        paths: Vec<String>,
160    },
161
162    /// Register a single Git repository for pruning (defaults to current directory `.`).
163    Link {
164        /// Path to the Git repository to register.
165        #[arg(default_value = ".")]
166        path: String,
167
168        /// Suppress output and skip repos that set `disable_hooks`. Used by the Git hook.
169        #[arg(long)]
170        quiet: bool,
171    },
172
173    /// Remove a repository from the dev-prune registry (does not delete workspace files).
174    Unlink {
175        /// Path to the Git repository to unregister.
176        #[arg(default_value = ".")]
177        path: String,
178
179        /// Unregister every path that no longer exists, instead of one named repository.
180        #[arg(long, conflicts_with = "path")]
181        missing: bool,
182    },
183
184    /// Revert the most recent init or link action.
185    Undo,
186
187    /// Run a prune pass across all registered repositories or a target directory (`devp run .`).
188    Run {
189        /// Optional target workspace path. If omitted, runs across all registered repositories.
190        target_path: Option<String>,
191
192        /// Mark this as the scheduled background pass. Repositories that set
193        /// `disable_daemon` in `.devprune.json` are skipped. Set by the installed scheduler.
194        #[arg(long)]
195        daemon: bool,
196
197        /// Act only on these package managers (comma-separated),
198        /// e.g. `--only npm,pnpm`. Unknown names are an error.
199        #[arg(long, value_name = "ADAPTERS", conflicts_with = "skip")]
200        only: Option<String>,
201
202        /// Leave these package managers alone (comma-separated), e.g. `--skip cargo`.
203        #[arg(long, value_name = "ADAPTERS")]
204        skip: Option<String>,
205
206        /// Ignore bloat directories smaller than this many MiB. Overrides `min_size_mb`.
207        #[arg(long, value_name = "MIB")]
208        min_size: Option<u64>,
209
210        /// Prune everything except these repositories (comma-separated paths or names).
211        ///
212        /// The safe way to express "clean up but keep the API project": that project is
213        /// never verified, never deleted and never reinstalled, instead of being pruned
214        /// and then restored over the network.
215        #[arg(long, value_name = "REPOS")]
216        except: Option<String>,
217
218        /// Emit one JSON document instead of the human report. Implies non-interactive.
219        #[arg(long)]
220        json: bool,
221    },
222
223    /// View system dashboard: registered repos, background daemon, Git hooks & space metrics.
224    Status {
225        /// Emit the dashboard as one JSON document instead of the TUI or text table.
226        #[arg(long)]
227        json: bool,
228    },
229
230    /// Report the size of every package manager cache on this machine (read-only, deletes nothing).
231    Caches {
232        /// Emit the report as one JSON document instead of the table.
233        #[arg(long)]
234        json: bool,
235    },
236
237    /// Manage global settings, background daemon, Git hooks, custom icons, or per-project .devprune.json.
238    Config {
239        #[command(subcommand)]
240        action: Option<ConfigAction>,
241    },
242
243    /// Restore dependencies in a project using its lockfile (npm ci, pnpm install, uv sync).
244    Restore {
245        /// Path to the project to restore (defaults to current directory).
246        path: Option<String>,
247
248        /// Put back exactly what the most recent prune pass deleted, in every repository
249        /// it touched. The undo for a `run`.
250        #[arg(long, conflicts_with = "path")]
251        last_run: bool,
252    },
253
254    /// Print the installed version, check for a newer release, and show how to upgrade.
255    Update {
256        /// Skip the release check for this run. The check is the only thing in dev-prune
257        /// that opens a network connection; `devp config set update_check false` turns
258        /// it off for good.
259        #[arg(long)]
260        offline: bool,
261    },
262
263    /// Export SKILL.md and display ready-to-copy AI Agent onboarding & skill import prompts.
264    Skill,
265
266    /// Install whatever dev-prune integration is missing: alias, SKILL.md, Git hooks, scheduler.
267    Setup {
268        /// Report what is installed without changing anything.
269        #[arg(long)]
270        status: bool,
271    },
272
273    /// Diagnose the installation, or one repository if given a path (`devp doctor .`).
274    Doctor {
275        /// Repository to diagnose. Omit to check the installation itself.
276        path: Option<String>,
277    },
278
279    /// Uninstall background daemon, Git hooks, and optionally wipe configuration.
280    Uninstall {
281        /// Perform a deep uninstall (wipe configuration folder and .devprune.json files).
282        #[arg(long)]
283        deep: bool,
284    },
285}
286
287#[derive(Subcommand, Debug)]
288pub enum ConfigAction {
289    /// Display a global configuration value.
290    Get {
291        /// Any key `devp config show` lists — idle_days, min_size_mb, scan_depth,
292        /// require_confirmation, allow_manifest_rewrite, command_timeout_secs,
293        /// auto_setup, auto_daemon, check_interval_days, auto_hooks, auto_hooks_chain,
294        /// update_check, update_check_interval_days, update_check_timeout_secs.
295        key: String,
296    },
297    /// Set a global configuration value.
298    Set {
299        /// Configuration key.
300        key: String,
301        /// New value.
302        value: String,
303    },
304    /// Show all global configuration values or sync per-repo configurations.
305    Show {
306        /// Force update/sync pass across all registered repos.
307        #[arg(long, short)]
308        update: bool,
309    },
310    /// Inspect or initialize per-repository config (.devprune.json) for a workspace path.
311    Project {
312        /// Path to the repository (defaults to current directory).
313        #[arg(default_value = ".")]
314        path: String,
315        /// Force update/sync pass on this project config.
316        #[arg(long, short)]
317        update: bool,
318    },
319    /// Configure OS background daemon scheduler globally or for a workspace path.
320    Daemon {
321        /// Optional workspace path or sub-action (enable, disable, status).
322        target: Option<String>,
323        /// Sub-action if path was provided (enable, disable, status).
324        sub_action: Option<String>,
325    },
326    /// Configure non-blocking global Git background auto-registration hooks globally or for a workspace path.
327    Hook {
328        /// Optional workspace path or sub-action (enable, disable, status).
329        target: Option<String>,
330        /// Sub-action if path was provided (enable, disable, status).
331        sub_action: Option<String>,
332        /// Install in front of the hooks directory already configured, forwarding to it,
333        /// instead of refusing to take a slot another tool is using.
334        #[arg(long)]
335        chain: bool,
336    },
337    /// Register a file-manager icon for .devprune.json, and print an editor snippet.
338    Icon,
339    /// Walk through every global setting, confirming or changing each one.
340    Wizard,
341}
342
343/// Create the `devp` executable alias next to `dev-prune`, and keep it current.
344///
345/// Runs on every invocation because it is two `stat` calls in the settled case, and
346/// because the alias is how most people invoke this tool — it must never be the stale
347/// half of an upgrade.
348///
349/// `DEV_PRUNE_NO_AUTO_SETUP` suppresses it, because writing a second executable next to
350/// the first is a self-installation like any other. `devp setup` still creates the alias
351/// when the variable is set: the variable governs the unattended pass, not the explicit
352/// request.
353pub fn ensure_devp_alias() {
354    if std::env::var_os(setup::ENV_NO_AUTO_SETUP).is_some() {
355        return;
356    }
357    let _ = setup::ensure_alias();
358}
359
360/// Print rich version & system environment details for -v / -V / --version.
361pub fn print_version_info() {
362    output::print_banner();
363    println!("dev-prune (devp) v{}", constants::VERSION);
364    println!("  Binary Aliases:  dev-prune | devp (interchangeable)");
365    println!("  Target OS:       {}", std::env::consts::OS);
366    println!("  Architecture:    {}", std::env::consts::ARCH);
367    println!("  Compiler:        Rust 1.85+ (edition 2024)");
368    println!("  License:         Apache-2.0 (no analytics, no diagnostics)");
369    println!();
370    let reg_path = config::Registry::registry_path()
371        .map(|p| output::clean_path(&p))
372        .unwrap_or_else(|_| "unknown".to_string());
373    println!("  Config Path:     {reg_path}");
374
375    if let Ok(exe) = std::env::current_exe() {
376        if let Some(exe_dir) = exe.parent() {
377            let exe_dir_str = output::clean_path(exe_dir);
378            let path_var = std::env::var("PATH").unwrap_or_default();
379            let is_in_path = path_var
380                .split(if cfg!(windows) { ';' } else { ':' })
381                .any(|p| std::path::Path::new(p) == exe_dir);
382
383            println!("  Binary Dir:      {exe_dir_str}");
384            if is_in_path {
385                println!("  PATH Audit:      ✓ Executable directory is active in system PATH.");
386            } else {
387                println!("  PATH Audit:      ⚠ Executable directory is NOT in system PATH!");
388                println!("                   Add `{exe_dir_str}` to Environment Variables.");
389            }
390        }
391    }
392}
393
394/// Case-insensitive subcommand normalizer and status alias router.
395fn normalize_args() -> Vec<String> {
396    let args: Vec<String> = std::env::args().collect();
397    if args.len() == 2 && (args[1] == "-v" || args[1] == "-V" || args[1] == "--version") {
398        print_version_info();
399        std::process::exit(exit_code::OK);
400    }
401    if args.len() <= 1
402        || args
403            .iter()
404            .any(|a| a == "-h" || a == "--help" || a == "help")
405    {
406        output::print_banner();
407    }
408    if args.len() <= 1 {
409        return args;
410    }
411
412    let mut normalized = vec![args[0].clone()];
413    for (i, arg) in args.iter().enumerate().skip(1) {
414        if i == 1 && !arg.starts_with('-') {
415            normalized.push(arg.to_lowercase());
416        } else {
417            normalized.push(arg.clone());
418        }
419    }
420
421    // Map `devp daemon|hook|icon [ARGS...]` -> `devp config daemon|hook|icon [ARGS...]`
422    //
423    // These live under `config` because that is where the rest of the persistent
424    // settings live, but nobody types `devp config hook install` when they mean
425    // "install the hook" — and the tool's own output has always said `devp hook
426    // install`. Accepting both costs one insert and removes a papercut.
427    if matches!(normalized[1].as_str(), "daemon" | "hook" | "icon") {
428        normalized.insert(1, "config".to_string());
429    }
430
431    // Map `devp status [PATH] daemon` -> `devp config daemon [PATH] status`
432    // Map `devp status [PATH] hook`   -> `devp config hook [PATH] status`
433    if normalized.len() >= 3 && normalized[1] == "status" {
434        let last = normalized
435            .last()
436            .map(|s| s.to_lowercase())
437            .unwrap_or_default();
438        if last == "daemon" || last == "hook" {
439            let mut rewrited = vec![normalized[0].clone(), "config".to_string(), last];
440            if normalized.len() > 3 {
441                rewrited.push(normalized[2].clone());
442            }
443            rewrited.push("status".to_string());
444            return rewrited;
445        }
446    }
447
448    // Map `devp config [PATH] daemon [ACTION]` -> `devp config daemon [PATH] [ACTION]`
449    // Map `devp config [PATH] hook [ACTION]`   -> `devp config hook [PATH] [ACTION]`
450    if normalized.len() >= 4 && normalized[1] == "config" {
451        let third = normalized[3].to_lowercase();
452        if third == "daemon" || third == "hook" {
453            let mut rewrited = vec![
454                normalized[0].clone(),
455                "config".to_string(),
456                third,
457                normalized[2].clone(),
458            ];
459            for extra in &normalized[4..] {
460                rewrited.push(extra.clone());
461            }
462            return rewrited;
463        }
464    }
465
466    normalized
467}
468
469/// Whether the automatic setup pass may run for this invocation.
470///
471/// Two callers are excluded on purpose. The Git hook runs `link --quiet` with no
472/// terminal attached and inside someone's commit; the scheduler runs `run --daemon` the
473/// same way. An integration pass nobody can see is one nobody can refuse, so both wait
474/// for the next command a human types. `uninstall` is excluded for the obvious reason,
475/// and `setup` because it is the pass, run deliberately.
476fn auto_setup_allowed(args: &[String]) -> bool {
477    let subcommand = args.get(1).map(String::as_str).unwrap_or("");
478    !matches!(subcommand, "uninstall" | "setup")
479        && !args.iter().any(|a| a == "--quiet" || a == "--daemon")
480}
481
482/// Run the CLI application.
483pub fn run_cli() {
484    restore_sigpipe();
485    ensure_devp_alias();
486
487    let args = normalize_args();
488    if auto_setup_allowed(&args) {
489        setup::auto_setup_if_due();
490    }
491    let cli = Cli::parse_from(args);
492
493    // Both spellings mean the same thing; the old one just says so first.
494    let ignore_idle = cli.ignore_idle || cli.force;
495    if cli.force {
496        print_force_help();
497    }
498
499    // Every path the user typed passes through `expand_tilde` on the way in. PowerShell
500    // and cmd hand us `~/Code` verbatim, so without this the documented one-liner
501    // registers a directory literally named `~`.
502    let result = match cli.command {
503        Commands::Init { paths } => {
504            let paths: Vec<String> = paths.iter().map(|p| config::expand_tilde(p)).collect();
505            commands::init::run(&paths, cli.dry_run)
506        }
507        Commands::Link { path, quiet } => {
508            commands::link::run_link(&config::expand_tilde(&path), quiet)
509        }
510        Commands::Unlink { path, missing } => {
511            if missing {
512                commands::link::run_unlink_missing()
513            } else {
514                commands::link::run_unlink(&config::expand_tilde(&path))
515            }
516        }
517        Commands::Undo => commands::undo::run(),
518        Commands::Run {
519            target_path,
520            daemon,
521            only,
522            skip,
523            min_size,
524            except,
525            json,
526        } => {
527            let target_path = target_path.map(|p| config::expand_tilde(&p));
528            commands::run::run(commands::run::RunArgs {
529                target_path: target_path.as_deref(),
530                dry_run: cli.dry_run,
531                force: ignore_idle,
532                yes: cli.yes,
533                daemon,
534                only: only.as_deref(),
535                skip: skip.as_deref(),
536                min_size_mb: min_size,
537                except: except.as_deref(),
538                json,
539            })
540        }
541        Commands::Status { json } => commands::status::run(json),
542        Commands::Caches { json } => commands::caches::run(json),
543        Commands::Config { action } => match action {
544            Some(ConfigAction::Get { key }) => commands::config::run_get(&key),
545            Some(ConfigAction::Set { key, value }) => commands::config::run_set(&key, &value),
546            Some(ConfigAction::Show { update: true }) => commands::config::run_global_update(),
547            Some(ConfigAction::Show { update: false }) | None => commands::config::run_show(),
548            Some(ConfigAction::Project { path, update }) => {
549                commands::config::run_path_config(&config::expand_tilde(&path), update)
550            }
551            Some(ConfigAction::Daemon { target, sub_action }) => {
552                // A toggle word (`on`, `off`) never starts with `~`, so expanding the
553                // target before the match cannot turn one into a path.
554                let target = target.map(|t| config::expand_tilde(&t));
555                let (path, action) = match (target.as_deref(), sub_action.as_deref()) {
556                    (Some(t), Some(a)) => (Some(t), a),
557                    (Some(t), None) if commands::config::is_toggle_word(t) => (None, t),
558                    (Some(t), None) => (Some(t), "status"),
559                    (None, Some(a)) => (None, a),
560                    (None, None) => (None, "status"),
561                };
562                commands::config::run_daemon_toggle(path, action)
563            }
564            Some(ConfigAction::Hook {
565                target,
566                sub_action,
567                chain,
568            }) => {
569                let target = target.map(|t| config::expand_tilde(&t));
570                let (path, action) = match (target.as_deref(), sub_action.as_deref()) {
571                    (Some(t), Some(a)) => (Some(t), a),
572                    (Some(t), None) if commands::config::is_toggle_word(t) => (None, t),
573                    (Some(t), None) => (Some(t), "status"),
574                    (None, Some(a)) => (None, a),
575                    // `--chain` on its own is an install instruction, not a status query.
576                    (None, None) if chain => (None, "install"),
577                    (None, None) => (None, "status"),
578                };
579                commands::config::run_hook_toggle(path, action, chain)
580            }
581            Some(ConfigAction::Icon) => commands::icon::run_install(),
582            Some(ConfigAction::Wizard) => commands::config::run_wizard(),
583        },
584        Commands::Restore { path, last_run } => {
585            if last_run {
586                commands::restore::run_last_run()
587            } else {
588                commands::restore::run(&config::expand_tilde(path.as_deref().unwrap_or(".")))
589            }
590        }
591        Commands::Update { offline } => commands::update::run(offline),
592        Commands::Skill => commands::skill::run(),
593        Commands::Setup { status } => commands::setup::run(status),
594        Commands::Doctor { path } => {
595            let path = path.map(|p| config::expand_tilde(&p));
596            commands::doctor::run(path.as_deref())
597        }
598        Commands::Uninstall { deep } => commands::uninstall::run(deep, cli.yes),
599    };
600
601    if let Err(e) = result {
602        if is_broken_pipe(&e) {
603            std::process::exit(exit_code::OK);
604        }
605        output::print_error(&format!("{e:#}"));
606        std::process::exit(exit_code::FAILURE);
607    }
608}