Skip to main content

dev_prune/
lib.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4pub mod adapters;
5pub mod channel;
6pub mod commands;
7pub mod config;
8pub mod constants;
9pub mod daemon;
10pub mod declared;
11pub mod discovery;
12pub mod engine;
13pub mod help;
14pub mod history;
15pub mod i18n;
16pub mod json;
17pub mod output;
18pub mod pathenv;
19pub mod receipt;
20pub mod scanner;
21pub mod setup;
22pub mod spawn;
23pub mod tui;
24pub mod workspace;
25
26use clap::{Parser, Subcommand};
27
28/// Process exit codes, so scripts and CI can branch on the outcome.
29///
30/// These are part of the tool's contract and are documented in `docs/CLI_REFERENCE.md`;
31/// changing one is a breaking change.
32pub mod exit_code {
33    /// The command did what it was asked to do. A prune that deleted nothing because
34    /// nothing was idle is still a success.
35    pub const OK: i32 = 0;
36    /// The command failed. The reason is on stderr.
37    pub const FAILURE: i32 = 1;
38    /// The arguments were not usable. Emitted by clap, listed here so the set is complete.
39    pub const USAGE: i32 = 2;
40}
41
42/// The machine's own architecture, reported only when it differs from this build's.
43///
44/// `std::env::consts::ARCH` is baked in at compile time, so a 32-bit build on a 64-bit
45/// machine reports `x86` and looks, to anyone reading it, like a claim about the
46/// hardware. Windows sets [`constants::ENV_NATIVE_ARCH`] under WOW64 and under ARM64
47/// emulation; it is the only thing an emulated process can ask. The names are mapped to
48/// Rust's spellings so the two halves of "x86, but this machine is x86_64" match.
49///
50/// `None` means the build and the machine agree, or the question cannot be answered —
51/// both of which are reported as nothing at all rather than as a guess.
52pub fn native_arch_if_emulated() -> Option<String> {
53    let native = std::env::var(constants::ENV_NATIVE_ARCH).ok()?;
54    let native = native.trim();
55    if native.is_empty() {
56        return None;
57    }
58    let mapped = match native.to_ascii_uppercase().as_str() {
59        "AMD64" => "x86_64".to_string(),
60        "ARM64" => "aarch64".to_string(),
61        "X86" => "x86".to_string(),
62        other => other.to_ascii_lowercase(),
63    };
64    (mapped != std::env::consts::ARCH).then_some(mapped)
65}
66
67/// Marker for errors that are usage mistakes rather than runtime failures.
68///
69/// clap exits `USAGE` for conflicts it can see at parse time; combinations only the
70/// command logic can judge — `run --json` with neither `--dry-run` nor `--yes` — used
71/// to exit `FAILURE`, which told a script "the prune broke" when the truth was "the
72/// command line was incomplete". Raising this instead routes them to `USAGE`.
73#[derive(Debug)]
74pub struct UsageError(pub String);
75
76impl std::fmt::Display for UsageError {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        f.write_str(&self.0)
79    }
80}
81
82impl std::error::Error for UsageError {}
83
84/// Restore the default disposition for `SIGPIPE`.
85///
86/// Rust ignores `SIGPIPE` at startup, which turns `devp status | head` into a panic —
87/// "failed printing to stdout" plus a backtrace — where every other Unix tool simply
88/// stops. Putting the default back makes dev-prune behave like `ls` in a pipeline.
89#[cfg(unix)]
90fn restore_sigpipe() {
91    // SAFETY: `signal` with SIG_DFL is async-signal-safe and this runs before any
92    // thread is spawned.
93    unsafe {
94        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
95    }
96}
97
98#[cfg(not(unix))]
99fn restore_sigpipe() {}
100
101/// Explain the rename, then answer the question the user was really asking.
102///
103/// Nobody types `--force` for fun. They type it because something was not pruned and
104/// they want the tool to stop arguing — so a bare "the flag moved" note would leave
105/// them exactly as stuck as before. The list below is every reason a directory gets
106/// skipped, with the fix, because six of the seven are not what `--force` was for.
107///
108/// Goes to stderr with the rest of the diagnostics, so `--json` stays parseable.
109fn print_force_help() {
110    output::print_notice(
111        "`--force` is now `--ignore-idle`, which is what it has always actually done. \
112         The old spelling still works.",
113    );
114    eprintln!(
115        "
116  Reaching for --force usually means something did not get pruned. It is one of these:
117
118    Not idle yet          A commit or a source edit inside idle_days (15 by default).
119                          This is the one --ignore-idle is for.
120    Lockfile unusable     The package manager could not confirm it. Run the command
121                          dev-prune printed, then try again. No flag skips this check.
122    Opted out             `ignore.devprune.json` in the root, or `\"ignore\": true`
123                          in `.devprune.json`.
124    Under the size floor  Smaller than min_size_mb. `--min-size 0` includes it.
125    Not registered        `devp link .` first; `devp status` shows what is tracked.
126    Nested or symlinked   A submodule is pruned as itself, never as part of its
127                          parent, and a linked directory is refused. By design.
128    Too deep              Beyond scan_depth (6 levels). `devp config set scan_depth N`.
129
130  `devp run --dry-run` names the actual reason, per repository.
131
132  Still stuck? Ask your AI assistant — `devp skill` hands it the full troubleshooting
133  tree, including this list. It has read it. It wrote it.
134"
135    );
136}
137
138/// Whether a failure is just the reader at the other end of a pipe hanging up.
139///
140/// `devp status | head -5` is a normal thing to type, and the closed pipe it produces is
141/// not an error worth printing — printing it would itself fail.
142fn is_broken_pipe(err: &anyhow::Error) -> bool {
143    err.chain().any(|cause| {
144        cause
145            .downcast_ref::<std::io::Error>()
146            .is_some_and(|io_err| io_err.kind() == std::io::ErrorKind::BrokenPipe)
147    })
148}
149
150/// Universal, lockfile-safe workspace pruner and background dependency cleaner.
151///
152/// Note: `dev-prune` and `devp` are interchangeable binary aliases.
153#[derive(Parser, Debug)]
154#[command(name = constants::APP_NAME)]
155#[command(version = constants::VERSION)]
156#[command(author = constants::AUTHOR)]
157#[command(long_version = constants::LONG_VERSION.as_str())]
158// The default help lists twenty commands as one flat block. The template swaps
159// clap's subcommand list for the grouped, coloured one `help.rs` builds — the same
160// grouping as the manual's contents page — and the styles colour every page.
161#[command(styles = help::HELP_STYLES)]
162#[command(help_template = help::ROOT_HELP_TEMPLATE.as_str())]
163#[command(
164    about = "Universal, lockfile-safe workspace pruner and background dependency cleaner\nNote: `dev-prune` and `devp` are interchangeable binary aliases."
165)]
166#[command(after_help = help::ROOT_AFTER_HELP.as_str())]
167pub struct Cli {
168    #[command(subcommand)]
169    command: Commands,
170
171    /// Simulate pruning without deleting any files.
172    #[arg(long, global = true)]
173    dry_run: bool,
174
175    /// Prune repositories you are still working in, ignoring the idle-day threshold.
176    ///
177    /// This is the *only* check it lifts. Lockfile verification, `ignore.devprune.json`,
178    /// `"ignore": true`, symlink refusal and nested-repository refusal all still apply.
179    #[arg(long, global = true)]
180    ignore_idle: bool,
181
182    /// Deprecated spelling of `--ignore-idle`.
183    ///
184    /// Renamed because "force" reads like "override the safety checks", which it never
185    /// did — it only ever skipped the idle-day wait. Still accepted; prints a note.
186    #[arg(long, global = true)]
187    force: bool,
188
189    /// Bypass interactive confirmation prompts.
190    #[arg(long, short = 'y', global = true)]
191    yes: bool,
192}
193
194#[derive(Subcommand, Debug)]
195pub enum Commands {
196    /// Workspace onboarding & discovery: crawl paths for Git repositories and register them.
197    #[command(alias = "scan", alias = "onboard")]
198    #[command(long_about = help::INIT_LONG, after_long_help = help::INIT_EXAMPLES)]
199    Init {
200        /// Paths to scan for Git repositories (defaults to current directory).
201        #[arg(default_value = ".")]
202        paths: Vec<String>,
203
204        /// Work out where the repositories are instead of being told, and register
205        /// everything found. Ignores PATHS.
206        #[arg(long)]
207        auto: bool,
208    },
209
210    /// Register a single Git repository for pruning (defaults to current directory `.`).
211    #[command(long_about = help::LINK_LONG, after_long_help = help::LINK_EXAMPLES)]
212    Link {
213        /// Path to the Git repository to register.
214        #[arg(default_value = ".")]
215        path: String,
216
217        /// Suppress output and skip repos that set `disable_hooks`. Used by the Git hook.
218        #[arg(long)]
219        quiet: bool,
220    },
221
222    /// Remove a repository from the dev-prune registry (does not delete workspace files).
223    #[command(long_about = help::UNLINK_LONG, after_long_help = help::UNLINK_EXAMPLES)]
224    Unlink {
225        /// Path to the Git repository to unregister.
226        #[arg(default_value = ".")]
227        path: String,
228
229        /// Unregister every path that no longer exists, instead of one named repository.
230        #[arg(long, conflicts_with = "path")]
231        missing: bool,
232    },
233
234    /// Revert the most recent init or link action.
235    //
236    // Off the front page since 1.17.0: it covers ground `devp restore` also covers,
237    // but it shipped in 1.0.0 and the CLI surface is a contract, so it keeps
238    // working — its own `--help`, `devp man undo`, the reference — until 2.x, which
239    // is when it actually goes. `help::HIDDEN_FROM_HELP` keeps the grouped help in
240    // step with this flag.
241    #[command(hide = true)]
242    #[command(long_about = help::UNDO_LONG, after_long_help = help::UNDO_EXAMPLES)]
243    Undo,
244
245    /// Run a prune pass across all registered repositories or a target directory (`devp run .`).
246    #[command(long_about = help::RUN_LONG, after_long_help = help::RUN_EXAMPLES)]
247    Run {
248        /// Optional target workspace path. If omitted, runs across all registered repositories.
249        target_path: Option<String>,
250
251        /// Mark this as the scheduled background pass. Repositories that set
252        /// `disable_daemon` in `.devprune.json` are skipped. Set by the installed scheduler.
253        #[arg(long)]
254        daemon: bool,
255
256        /// Act only on these package managers (comma-separated),
257        /// e.g. `--only npm,pnpm`. Unknown names are an error.
258        #[arg(long, value_name = "ADAPTERS", conflicts_with = "skip")]
259        only: Option<String>,
260
261        /// Leave these package managers alone (comma-separated), e.g. `--skip cargo`.
262        #[arg(long, value_name = "ADAPTERS")]
263        skip: Option<String>,
264
265        /// Ignore bloat directories smaller than this many MiB. Overrides `min_size_mb`.
266        #[arg(long, value_name = "MIB")]
267        min_size: Option<u64>,
268
269        /// Prune everything except these repositories (comma-separated paths or names).
270        ///
271        /// The safe way to express "clean up but keep the API project": that project is
272        /// never verified, never deleted and never reinstalled, instead of being pruned
273        /// and then restored over the network.
274        #[arg(long, value_name = "REPOS")]
275        except: Option<String>,
276
277        /// Emit one JSON document instead of the human report. Implies non-interactive.
278        #[arg(long)]
279        json: bool,
280
281        /// Explain every decision instead of pruning: each repository and directory,
282        /// with the reason it would or would not be touched — including the states a
283        /// normal pass keeps quiet about (still active, opted out, under the size
284        /// floor). Read-only; nothing is verified or deleted.
285        #[arg(long, conflicts_with = "json")]
286        explain: bool,
287    },
288
289    /// View system dashboard: registered repos, background daemon, Git hooks & space metrics.
290    #[command(long_about = help::STATUS_LONG, after_long_help = help::STATUS_EXAMPLES)]
291    Status {
292        /// Show only the N repositories with the most reclaimable space.
293        ///
294        /// The dashboard lists every registered repository, which on a machine with a
295        /// hundred of them buries the handful actually worth pruning. Applies to the TUI,
296        /// the plain table and `--json` alike.
297        ///
298        /// Zero is rejected up front: "show the top 0" can only be a typo, and an empty
299        /// dashboard that looks like an empty registry is worse than a usage error.
300        #[arg(long, value_name = "N", value_parser = clap::value_parser!(u64).range(1..))]
301        top: Option<u64>,
302
303        /// Report lockfile drift instead of the dashboard: environments holding packages
304        /// their lockfile never recorded — the installs a prune would refuse to delete
305        /// because nothing could bring them back.
306        ///
307        /// A pure read: no package manager runs, nothing is written. Checked where a
308        /// file-level comparison exists — npm, uv and venv projects.
309        #[arg(long, conflicts_with = "top")]
310        drift: bool,
311
312        /// Emit the dashboard as one JSON document instead of the TUI or text table.
313        #[arg(long)]
314        json: bool,
315    },
316
317    /// List every prune pass, and open one up to see what it deleted and what asked it to.
318    #[command(long_about = help::HISTORY_LONG, after_long_help = help::HISTORY_EXAMPLES)]
319    History {
320        /// Show one pass in full. 1 is the most recent, matching the numbers in the list.
321        #[arg(long, value_name = "N", conflicts_with = "all")]
322        pass: Option<usize>,
323
324        /// How many passes to list. Defaults to the 20 most recent.
325        #[arg(long, value_name = "N", conflicts_with_all = ["all", "pass"])]
326        limit: Option<usize>,
327
328        /// List every recorded pass rather than the most recent few.
329        #[arg(long)]
330        all: bool,
331
332        /// Emit the whole log as one JSON document instead of the text report.
333        #[arg(long)]
334        json: bool,
335
336        /// Write the JSON document to a file. Bare, it goes to your documents folder.
337        #[arg(long, value_name = "PATH", num_args = 0..=1)]
338        export: Option<Option<std::path::PathBuf>>,
339    },
340
341    /// Show lifetime space reclaimed, recent prune passes, and the biggest repositories.
342    #[command(long_about = help::STATS_LONG, after_long_help = help::STATS_EXAMPLES)]
343    Stats {
344        /// Emit the figures as one JSON document instead of the text report.
345        #[arg(long)]
346        json: bool,
347    },
348
349    /// Report what dev-prune is allowed to do on this machine, and what it has been given permission to do.
350    #[command(long_about = help::TRUST_LONG, after_long_help = help::TRUST_EXAMPLES)]
351    Trust {
352        /// Emit the report as one JSON document instead of the table.
353        #[arg(long)]
354        json: bool,
355
356        /// Let Git read registered repositories it currently refuses on ownership.
357        #[arg(long, conflicts_with = "json")]
358        fix_ownership: bool,
359
360        /// Answer yes to the confirmation `--fix-ownership` asks.
361        ///
362        /// Declared here rather than inherited: this subcommand's `yes` carries the
363        /// `requires` guard the global one cannot, and having a local `yes` stops clap
364        /// propagating the global `-y` in — so without a short spelling of its own,
365        /// `devp trust --fix-ownership -y` was a usage error.
366        #[arg(long, short = 'y', requires = "fix_ownership")]
367        yes: bool,
368    },
369
370    /// Print a shell completion script for bash, zsh, fish, PowerShell or elvish.
371    #[command(long_about = help::COMPLETIONS_LONG, after_long_help = help::COMPLETIONS_EXAMPLES)]
372    Completions {
373        /// Shell to generate for.
374        shell: clap_complete::Shell,
375    },
376
377    /// Print or write man pages, generated from the same definitions `--help` prints.
378    #[command(long_about = help::MAN_LONG, after_long_help = help::MAN_EXAMPLES)]
379    Man {
380        /// The command whose page to read, e.g. `devp man run`. Omit for the
381        /// contents page.
382        command: Option<String>,
383
384        /// Write `devp.1` plus one `devp-<command>.1` per subcommand into this
385        /// directory, instead of rendering the main page to stdout.
386        #[arg(long, value_name = "DIR")]
387        dir: Option<String>,
388
389        /// Print the roff source even when stdout is a terminal, instead of the
390        /// readable manual.
391        #[arg(long)]
392        roff: bool,
393    },
394
395    /// Report the size of every package manager cache on this machine (read-only unless you ask for `clear`).
396    #[command(long_about = help::CACHES_LONG, after_long_help = help::CACHES_EXAMPLES)]
397    Caches {
398        /// Emit the report as one JSON document instead of the table.
399        ///
400        /// Global within `caches` so it can be written after the subcommand too —
401        /// `devp caches clear npm --json` is what everyone types.
402        #[arg(long, global = true)]
403        json: bool,
404
405        /// Report only the caches that sit on one drive or filesystem: `--volume V:`,
406        /// `--volume /mnt/data`, or any path on it. `--drive` is the same flag.
407        ///
408        /// Not global: it narrows the report, and there is nothing for it to narrow in
409        /// `clear`, `docker` or `containers`, so pairing it with one is a usage error
410        /// rather than a flag that looks accepted and does nothing.
411        #[arg(long, visible_alias = "drive", value_name = "VOLUME")]
412        volume: Option<String>,
413
414        #[command(subcommand)]
415        action: Option<CachesAction>,
416    },
417
418    /// Manage global settings, background daemon, Git hooks, custom icons, or per-project .devprune.json.
419    #[command(long_about = help::CONFIG_LONG, after_long_help = help::CONFIG_EXAMPLES)]
420    Config {
421        #[command(subcommand)]
422        action: Option<ConfigAction>,
423    },
424
425    /// Restore dependencies in a project using its lockfile (npm ci, pnpm install, uv sync).
426    #[command(long_about = help::RESTORE_LONG, after_long_help = help::RESTORE_EXAMPLES)]
427    Restore {
428        /// Path to the project to restore (defaults to current directory).
429        path: Option<String>,
430
431        /// Put back exactly what the most recent prune pass deleted, in every repository
432        /// it touched. The undo for a `run`.
433        #[arg(long, conflicts_with = "path")]
434        last_run: bool,
435    },
436
437    /// Print the installed version, check for a newer release, and show how to upgrade.
438    #[command(long_about = help::UPDATE_LONG, after_long_help = help::UPDATE_EXAMPLES)]
439    Update {
440        /// Skip the release check for this run. The check is the only thing in dev-prune
441        /// that opens a network connection; `devp config set update_check false` turns
442        /// it off for good.
443        #[arg(long)]
444        offline: bool,
445
446        /// Download and install the newer release without asking. Plain `devp update`
447        /// offers the same install with a `[y/N]` prompt when a newer release exists
448        /// and stdin is a terminal; `-y` answers that prompt. Needs the network, so it
449        /// cannot be combined with `--offline`.
450        #[arg(long, conflicts_with = "offline")]
451        install: bool,
452
453        /// Print the upgrade command for every channel dev-prune ships through, instead
454        /// of only the one that installed this copy. Touches nothing and needs no
455        /// network — useful when the machine in front of you is not the one that has
456        /// the stale copy.
457        #[arg(long, conflicts_with_all = ["offline", "install"])]
458        channels: bool,
459    },
460
461    /// Export SKILL.md and display ready-to-copy AI Agent onboarding & skill import prompts.
462    #[command(long_about = help::SKILL_LONG, after_long_help = help::SKILL_EXAMPLES)]
463    Skill {
464        /// Write rules for one editor's agent into the current repository instead.
465        /// Each value below names the exact file it writes. Six of them —
466        /// `agents-md`, `aider`, `copilot`, `gemini`, `junie`, `zed` — share a file with
467        /// other tools, so dev-prune owns a marked block inside it and leaves every
468        /// byte outside the markers as found. Claude Code needs no per-repo file:
469        /// plain `devp skill` installs its skill globally.
470        #[arg(long, value_enum, value_name = "EDITOR")]
471        agent: Option<commands::skill::AgentEditor>,
472
473        /// Write rules for every editor detected on this machine or in this
474        /// repository — the ones plain `devp skill` lists — in one pass.
475        #[arg(long, conflicts_with = "agent")]
476        detected: bool,
477    },
478
479    /// Install whatever dev-prune integration is missing: alias, SKILL.md, Git hooks, scheduler.
480    #[command(long_about = help::SETUP_LONG, after_long_help = help::SETUP_EXAMPLES)]
481    Setup {
482        /// Report what is installed without changing anything.
483        #[arg(long)]
484        status: bool,
485    },
486
487    /// Diagnose the installation, or one repository if given a path (`devp doctor .`).
488    #[command(long_about = help::DOCTOR_LONG, after_long_help = help::DOCTOR_EXAMPLES)]
489    Doctor {
490        /// Repository to diagnose. Omit to check the installation itself.
491        path: Option<String>,
492
493        /// Repair what the installation check finds broken: refresh a stale or missing
494        /// `devp` twin, re-export SKILL.md, re-register a scheduler or Git hooks whose
495        /// binary moved, and drop registry entries whose repository is gone.
496        ///
497        /// Repairs only what was installed and has since broken — it never installs an
498        /// integration that was never set up (that is `devp setup`), and it cannot fix a
499        /// corrupt registry file, which needs a human decision.
500        #[arg(long, conflicts_with = "path")]
501        fix: bool,
502    },
503
504    /// Move this install to another package manager: `devp install --channel uv`.
505    //
506    // Off the front page since 1.17.0: the name reads like "install the tool", and
507    // what it does is *move* an installation between package managers. The honest fix
508    // is a rename, which the 1.0.0 CLI contract defers to 2.x — until then it works
509    // exactly as before, just not in `devp --help`.
510    #[command(hide = true)]
511    #[command(long_about = help::INSTALL_LONG, after_long_help = help::INSTALL_EXAMPLES)]
512    Install {
513        /// The package manager to move this installation to. Omit to print which one
514        /// owns the running copy, and the names this flag accepts.
515        #[arg(long, value_enum, value_name = "NAME")]
516        channel: Option<commands::install::TargetChannel>,
517
518        /// Print the commands that would run, and run none of them.
519        #[arg(long)]
520        dry_run: bool,
521    },
522
523    /// Remove dev-prune: scheduler, hooks, PATH entry, agent skill, and every copy of the binary.
524    #[command(long_about = help::UNINSTALL_LONG, after_long_help = help::UNINSTALL_EXAMPLES)]
525    Uninstall {
526        /// Perform a deep uninstall (wipe configuration folder and .devprune.json files).
527        #[arg(long)]
528        deep: bool,
529    },
530}
531
532impl Commands {
533    /// Whether this command's stdout is something another program reads.
534    ///
535    /// Two cases. `--json` promises stdout carries one document and nothing else, and
536    /// `completions` prints a script that gets sourced — a stray line in either is a
537    /// parse error rather than a nicety. `link --quiet` is the Git hook path, which runs
538    /// inside somebody's commit.
539    ///
540    /// Everything else defers to [`output::print_attribution`], which prints only when
541    /// stdout is a terminal. Neither function checks that the line is intact, and nothing
542    /// downstream depends on it having been printed.
543    fn suppresses_attribution(&self) -> bool {
544        match self {
545            Commands::Completions { .. } | Commands::Man { .. } => true,
546            Commands::Run { json, .. }
547            | Commands::Status { json, .. }
548            | Commands::Stats { json }
549            | Commands::History { json, .. }
550            | Commands::Trust { json, .. }
551            | Commands::Caches { json, .. } => *json,
552            Commands::Link { quiet, .. } => *quiet,
553            _ => false,
554        }
555    }
556}
557
558#[derive(Subcommand, Debug)]
559pub enum CachesAction {
560    /// Empty one manager's cache, or every one of them, after showing what goes and asking.
561    #[command(long_about = help::CACHES_CLEAR_LONG, after_long_help = help::CACHES_CLEAR_EXAMPLES)]
562    Clear {
563        /// Which cache to empty: a manager name (npm, go, cargo, gradle, …), a
564        /// comma-separated list of them (`npm,uv,pip`), or `all`.
565        #[arg(value_name = "MANAGER")]
566        target: String,
567
568        /// With `all`: empty every cache except these (comma-separated manager
569        /// names). A one-time blacklist, the counterpart of naming a list.
570        #[arg(long, value_name = "MANAGERS")]
571        except: Option<String>,
572
573        /// Only empty caches that are over the size cap set for them in
574        /// `cache_max_gb`. Without a cap set for anything, this clears nothing.
575        #[arg(long)]
576        over_cap: bool,
577
578        /// Only empty caches that no registered repository uses. Refuses to run when
579        /// there are no registered repositories to check against.
580        #[arg(long)]
581        unused: bool,
582
583        /// With a container engine: after the narrow steps, list its unused volumes by
584        /// name and pick which of them go. Refuses `--yes`, `--json` and a piped stdin.
585        #[arg(long)]
586        include_volumes: bool,
587    },
588
589    /// What Docker is holding: images, containers, volumes and build cache (read-only).
590    #[command(long_about = help::CACHES_DOCKER_LONG, after_long_help = help::CACHES_CONTAINERS_EXAMPLES)]
591    Docker,
592
593    /// What Podman is holding: images, containers, volumes and build cache (read-only).
594    #[command(long_about = help::CACHES_DOCKER_LONG, after_long_help = help::CACHES_CONTAINERS_EXAMPLES)]
595    Podman,
596
597    /// The same report for every container engine found, or for the one you name.
598    #[command(long_about = help::CACHES_CONTAINERS_LONG, after_long_help = help::CACHES_CONTAINERS_EXAMPLES)]
599    Containers {
600        /// docker, podman, nerdctl, finch or container. Omit for every one installed.
601        #[arg(value_name = "ENGINE")]
602        engine: Option<String>,
603    },
604}
605
606#[derive(Subcommand, Debug)]
607pub enum ConfigAction {
608    /// Display a global configuration value.
609    #[command(long_about = help::CONFIG_GET_LONG, after_long_help = help::CONFIG_GET_EXAMPLES)]
610    Get {
611        /// Any key `devp config show` lists — idle_days, min_size_mb, auto_update,
612        /// disabled_adapters and the rest. `devp config show` prints every key with
613        /// its current value.
614        key: String,
615    },
616    /// Set a global configuration value.
617    #[command(long_about = help::CONFIG_SET_LONG, after_long_help = help::CONFIG_SET_EXAMPLES)]
618    Set {
619        /// Configuration key.
620        key: String,
621        /// New value.
622        value: String,
623    },
624    /// Show all global configuration values or sync per-repo configurations.
625    #[command(long_about = help::CONFIG_SHOW_LONG, after_long_help = help::CONFIG_SHOW_EXAMPLES)]
626    Show {
627        /// Force update/sync pass across all registered repos.
628        #[arg(long, short)]
629        update: bool,
630    },
631    /// Turn on everything the first run recommends, in one command.
632    #[command(long_about = help::CONFIG_RECOMMENDED_LONG, after_long_help = help::CONFIG_RECOMMENDED_EXAMPLES)]
633    Recommended {
634        /// Include the recommendations that come with something to know first.
635        #[arg(long)]
636        with_cautious: bool,
637    },
638    /// Inspect or initialize per-repository config (.devprune.json) for a workspace path.
639    #[command(long_about = help::CONFIG_PROJECT_LONG, after_long_help = help::CONFIG_PROJECT_EXAMPLES)]
640    Project {
641        /// Path to the repository (defaults to current directory).
642        #[arg(default_value = ".")]
643        path: String,
644        /// Force update/sync pass on this project config.
645        #[arg(long, short)]
646        update: bool,
647        /// Act on the committed project.devprune.json instead of the personal file.
648        #[arg(long)]
649        team: bool,
650    },
651    /// Configure OS background daemon scheduler globally or for a workspace path.
652    #[command(long_about = help::CONFIG_DAEMON_LONG, after_long_help = help::CONFIG_DAEMON_EXAMPLES)]
653    Daemon {
654        /// Optional workspace path or sub-action (enable, disable, status).
655        target: Option<String>,
656        /// Sub-action if path was provided (enable, disable, status).
657        sub_action: Option<String>,
658    },
659    /// Configure non-blocking global Git background auto-registration hooks globally or for a workspace path.
660    #[command(long_about = help::CONFIG_HOOK_LONG, after_long_help = help::CONFIG_HOOK_EXAMPLES)]
661    Hook {
662        /// Optional workspace path or sub-action (enable, disable, status).
663        target: Option<String>,
664        /// Sub-action if path was provided (enable, disable, status).
665        sub_action: Option<String>,
666        /// Install in front of the hooks directory already configured, forwarding to it,
667        /// instead of refusing to take a slot another tool is using.
668        #[arg(long)]
669        chain: bool,
670    },
671    /// Register a file-manager icon for .devprune.json, and print an editor snippet.
672    #[command(long_about = help::CONFIG_ICON_LONG, after_long_help = help::CONFIG_ICON_EXAMPLES)]
673    Icon,
674    /// Walk through every global setting, confirming or changing each one.
675    #[command(long_about = help::CONFIG_WIZARD_LONG, after_long_help = help::CONFIG_WIZARD_EXAMPLES)]
676    Wizard {
677        /// Ask one question per line instead of opening the full-screen configurator.
678        #[arg(long)]
679        no_tui: bool,
680    },
681}
682
683/// Print rich version & system environment details for -v / -V / --version.
684///
685/// This, not clap, is what `devp --version` actually runs — [`normalize_args`] catches the
686/// flag first. The author and repository are printed here because a copy of this binary
687/// found on a machine with no package manager record should still be able to say where it
688/// came from, and `--version` is the first thing anyone runs on an unknown executable.
689pub fn print_version_info() {
690    use colored::Colorize;
691    output::print_banner();
692    println!(
693        "dev-prune (devp) {}",
694        format!("v{}", constants::VERSION).green().bold()
695    );
696    println!(
697        "  Binary Aliases:  {} | {}",
698        "dev-prune".cyan(),
699        "devp".cyan()
700    );
701    // These lines are facts, not status, so most of them stay in the terminal's own
702    // colour. The author line was turquoise and the OS and architecture yellow, which
703    // marked nothing and put five hues on one short screen; yellow now only ever means a
704    // warning, and cyan is reserved for the two things worth clicking.
705    println!("  Author:          {}", constants::AUTHOR);
706    println!(
707        "  Repository:      {}",
708        constants::REPO_URL.cyan().underline()
709    );
710    println!(
711        "  Homepage:        {}",
712        constants::HOMEPAGE_URL.cyan().underline()
713    );
714    println!("  Target OS:       {}", std::env::consts::OS);
715    match native_arch_if_emulated() {
716        // Without this the line reads as a statement about the machine, and a 32-bit
717        // build on a 64-bit laptop looks like the laptop is 32-bit.
718        Some(native) => println!(
719            "  Architecture:    {} {}",
720            std::env::consts::ARCH,
721            format!(
722                "(this build — the machine is {native}; `devp update` installs the native one)"
723            )
724            .yellow()
725        ),
726        None => println!("  Architecture:    {}", std::env::consts::ARCH),
727    }
728    println!(
729        "  Compiler:        Rust {}+ (edition 2024)",
730        constants::MSRV
731    );
732    println!("  License:         Apache-2.0");
733    println!();
734    let reg_path = config::Registry::registry_path()
735        .map(output::styled_path)
736        .unwrap_or_else(|_| "unknown".to_string());
737    println!("  Config Path:     {reg_path}");
738
739    if let Ok(exe) = std::env::current_exe()
740        && let Some(exe_dir) = exe.parent()
741    {
742        let exe_dir_str = output::clean_path(exe_dir);
743        // The same tolerant comparison the PATH writer uses — a trailing backslash or a
744        // case difference must not turn the audit line red on a healthy install.
745        let path_var = std::env::var("PATH").unwrap_or_default();
746        let exe_dir_entry = exe_dir.to_string_lossy();
747        let is_in_path = path_var
748            .split(if cfg!(windows) { ';' } else { ':' })
749            .any(|p| pathenv::entries_equal(p, &exe_dir_entry));
750
751        println!("  Binary Dir:      {}", exe_dir_str.cyan());
752        if is_in_path {
753            println!(
754                "  PATH Audit:      {}",
755                "✓ Executable directory is active in system PATH.".green()
756            );
757        } else {
758            println!(
759                "  PATH Audit:      {}",
760                "⚠ Executable directory is NOT in system PATH!".yellow()
761            );
762            println!(
763                "                   Add `{}` to Environment Variables.",
764                exe_dir_str.cyan()
765            );
766        }
767    }
768}
769
770/// Case-insensitive subcommand normalizer and status alias router.
771fn normalize_args() -> Vec<String> {
772    let args: Vec<String> = std::env::args().collect();
773    if args.len() == 2 && (args[1] == "-v" || args[1] == "-V" || args[1] == "--version") {
774        print_version_info();
775        std::process::exit(exit_code::OK);
776    }
777    if args.len() <= 1
778        || args
779            .iter()
780            .any(|a| a == "-h" || a == "--help" || a == "help")
781    {
782        output::print_banner();
783    }
784    if args.len() <= 1 {
785        return args;
786    }
787
788    let mut normalized = vec![args[0].clone()];
789    for (i, arg) in args.iter().enumerate().skip(1) {
790        if i == 1 && !arg.starts_with('-') {
791            normalized.push(arg.to_lowercase());
792        } else {
793            normalized.push(arg.clone());
794        }
795    }
796
797    // Map `devp daemon|hook|icon [ARGS...]` -> `devp config daemon|hook|icon [ARGS...]`
798    //
799    // These live under `config` because that is where the rest of the persistent
800    // settings live, but nobody types `devp config hook install` when they mean
801    // "install the hook" — and the tool's own output has always said `devp hook
802    // install`. Accepting both costs one insert and removes a papercut.
803    if matches!(normalized[1].as_str(), "daemon" | "hook" | "icon") {
804        normalized.insert(1, "config".to_string());
805    }
806
807    // Map `devp status [PATH] daemon` -> `devp config daemon [PATH] status`
808    // Map `devp status [PATH] hook`   -> `devp config hook [PATH] status`
809    //
810    // Exactly one optional PATH, and never a flag: `devp status --json daemon` must
811    // reach clap as typed and fail there, not be rewritten with `--json` as a path.
812    if normalized[1] == "status"
813        && (normalized.len() == 3 || (normalized.len() == 4 && !normalized[2].starts_with('-')))
814    {
815        let last = normalized
816            .last()
817            .map(|s| s.to_lowercase())
818            .unwrap_or_default();
819        if last == "daemon" || last == "hook" {
820            let mut rewrited = vec![normalized[0].clone(), "config".to_string(), last];
821            if normalized.len() > 3 {
822                rewrited.push(normalized[2].clone());
823            }
824            rewrited.push("status".to_string());
825            return rewrited;
826        }
827    }
828
829    // Map `devp config [PATH] daemon [ACTION]` -> `devp config daemon [PATH] [ACTION]`
830    // Map `devp config [PATH] hook [ACTION]`   -> `devp config hook [PATH] [ACTION]`
831    //
832    // Only when the second argument can actually be a path — a flag there means the
833    // user is talking to `config` itself and the rewrite would misfile it.
834    if normalized.len() >= 4 && normalized[1] == "config" && !normalized[2].starts_with('-') {
835        let third = normalized[3].to_lowercase();
836        if third == "daemon" || third == "hook" {
837            let mut rewrited = vec![
838                normalized[0].clone(),
839                "config".to_string(),
840                third,
841                normalized[2].clone(),
842            ];
843            for extra in &normalized[4..] {
844                rewrited.push(extra.clone());
845            }
846            return rewrited;
847        }
848    }
849
850    normalized
851}
852
853/// Whether the automatic setup pass may run for this invocation.
854///
855/// Two callers are excluded on purpose. The Git hook runs `link --quiet` with no
856/// terminal attached and inside someone's commit; the scheduler runs `run --daemon` the
857/// same way. An integration pass nobody can see is one nobody can refuse, so both wait
858/// for the next command a human types. `uninstall` is excluded for the obvious reason,
859/// and `setup` because it is the pass, run deliberately.
860fn auto_setup_allowed(args: &[String]) -> bool {
861    let subcommand = args.get(1).map(String::as_str).unwrap_or("");
862    // `--json` means a program is parsing stdout; the setup report and the first-run
863    // wizard would land inside the document. That invocation waits too.
864    !matches!(subcommand, "uninstall" | "setup")
865        && !args
866            .iter()
867            .any(|a| a == "--quiet" || a == "--daemon" || a == "--json")
868}
869
870/// Run the CLI application.
871pub fn run_cli() {
872    restore_sigpipe();
873
874    let args = normalize_args();
875    if auto_setup_allowed(&args) {
876        setup::auto_setup_if_due();
877    }
878    let cli = Cli::parse_from(args);
879
880    // After parsing, so `--version` and `--help` never pay for it, and before anything
881    // is printed, because every heading past this line is drawn in whatever it settles
882    // on. `Registry::load` is a pure read, and a registry that will not load is not a
883    // reason to refuse to print in English — the command below reports that failure
884    // properly.
885    i18n::init(
886        config::Registry::load()
887            .ok()
888            .map(|registry| registry.settings.language)
889            .as_deref(),
890    );
891
892    // Both spellings mean the same thing; the old one just says so first.
893    let ignore_idle = cli.ignore_idle || cli.force;
894    if cli.force {
895        print_force_help();
896    }
897
898    // Decided before the match, because that is where `cli.command` is consumed.
899    let credit_the_author = !cli.command.suppresses_attribution();
900
901    // Every path the user typed passes through `expand_tilde` on the way in. PowerShell
902    // and cmd hand us `~/Code` verbatim, so without this the documented one-liner
903    // registers a directory literally named `~`.
904    let result = match cli.command {
905        Commands::Init { paths, auto } => {
906            let paths: Vec<String> = paths.iter().map(|p| config::expand_tilde(p)).collect();
907            commands::init::run(&paths, cli.dry_run, auto)
908        }
909        Commands::Link { path, quiet } => {
910            commands::link::run_link(&config::expand_tilde(&path), quiet)
911        }
912        Commands::Unlink { path, missing } => {
913            if missing {
914                commands::link::run_unlink_missing()
915            } else {
916                commands::link::run_unlink(&config::expand_tilde(&path))
917            }
918        }
919        Commands::Undo => commands::undo::run(),
920        Commands::Run {
921            target_path,
922            daemon,
923            only,
924            skip,
925            min_size,
926            except,
927            json,
928            explain,
929        } => {
930            let target_path = target_path.map(|p| config::expand_tilde(&p));
931            commands::run::run(commands::run::RunArgs {
932                target_path: target_path.as_deref(),
933                dry_run: cli.dry_run,
934                force: ignore_idle,
935                yes: cli.yes,
936                daemon,
937                only: only.as_deref(),
938                skip: skip.as_deref(),
939                min_size_mb: min_size,
940                except: except.as_deref(),
941                json,
942                explain,
943            })
944        }
945        Commands::Status { top, drift, json } => {
946            commands::status::run(top.map(|n| n as usize), drift, json)
947        }
948        Commands::History {
949            pass,
950            limit,
951            all,
952            json,
953            export,
954        } => commands::history::run(&commands::history::HistoryArgs {
955            pass,
956            limit,
957            all,
958            json,
959            export,
960        }),
961        Commands::Stats { json } => commands::stats::run(json),
962        Commands::Completions { shell } => commands::completions::run(shell),
963        Commands::Man { command, dir, roff } => {
964            commands::man::run(command.as_deref(), dir.as_deref(), roff)
965        }
966        Commands::Trust {
967            json,
968            fix_ownership,
969            yes,
970        } => {
971            if fix_ownership {
972                // The global `-y` and the subcommand's own `--yes` are the same promise;
973                // honouring only one of them made `devp -y trust --fix-ownership` stop
974                // and ask anyway.
975                commands::trust::fix_ownership(yes || cli.yes)
976            } else {
977                commands::trust::run(json)
978            }
979        }
980        Commands::Caches {
981            json,
982            volume,
983            action,
984        } => match action {
985            // Refused rather than ignored. `--volume` reads as "act on this drive
986            // only", and a `clear` that silently emptied every drive after being
987            // handed one would be the worst possible way to learn the flag does not
988            // reach here.
989            Some(_) if volume.is_some() => Err(anyhow::Error::new(UsageError(
990                "`--volume` narrows the report and has nothing to narrow in a subcommand. \
991                 Run `devp caches --volume <VOLUME>` on its own to see what is on one \
992                 drive; `devp caches clear <manager>` then empties that manager \
993                 wherever it is."
994                    .to_string(),
995            ))),
996            Some(CachesAction::Clear {
997                target,
998                except,
999                over_cap,
1000                unused,
1001                include_volumes,
1002            }) => commands::caches::run_clear(
1003                &target,
1004                except.as_deref(),
1005                over_cap,
1006                unused,
1007                include_volumes,
1008                cli.yes,
1009                cli.dry_run,
1010                json,
1011            ),
1012            Some(CachesAction::Docker) => commands::containers::run(Some("docker"), json),
1013            Some(CachesAction::Podman) => commands::containers::run(Some("podman"), json),
1014            Some(CachesAction::Containers { engine }) => {
1015                commands::containers::run(engine.as_deref(), json)
1016            }
1017            None => commands::caches::run(json, volume.as_deref()),
1018        },
1019        Commands::Config { action } => match action {
1020            Some(ConfigAction::Get { key }) => commands::config::run_get(&key),
1021            Some(ConfigAction::Set { key, value }) => commands::config::run_set(&key, &value),
1022            Some(ConfigAction::Show { update: true }) => commands::config::run_global_update(),
1023            Some(ConfigAction::Show { update: false }) | None => commands::config::run_show(),
1024            Some(ConfigAction::Recommended { with_cautious }) => {
1025                commands::config::run_recommended(with_cautious)
1026            }
1027            Some(ConfigAction::Project { path, update, team }) => {
1028                commands::config::run_path_config(&config::expand_tilde(&path), update, team)
1029            }
1030            Some(ConfigAction::Daemon { target, sub_action }) => {
1031                // A toggle word (`on`, `off`) never starts with `~`, so expanding the
1032                // target before the match cannot turn one into a path.
1033                let target = target.map(|t| config::expand_tilde(&t));
1034                let (path, action) = match (target.as_deref(), sub_action.as_deref()) {
1035                    (Some(t), Some(a)) => (Some(t), a),
1036                    (Some(t), None) if commands::config::is_toggle_word(t) => (None, t),
1037                    (Some(t), None) => (Some(t), "status"),
1038                    (None, Some(a)) => (None, a),
1039                    (None, None) => (None, "status"),
1040                };
1041                commands::config::run_daemon_toggle(path, action)
1042            }
1043            Some(ConfigAction::Hook {
1044                target,
1045                sub_action,
1046                chain,
1047            }) => {
1048                let target = target.map(|t| config::expand_tilde(&t));
1049                let (path, action) = match (target.as_deref(), sub_action.as_deref()) {
1050                    (Some(t), Some(a)) => (Some(t), a),
1051                    (Some(t), None) if commands::config::is_toggle_word(t) => (None, t),
1052                    (Some(t), None) => (Some(t), "status"),
1053                    (None, Some(a)) => (None, a),
1054                    // `--chain` on its own is an install instruction, not a status query.
1055                    (None, None) if chain => (None, "install"),
1056                    (None, None) => (None, "status"),
1057                };
1058                commands::config::run_hook_toggle(path, action, chain)
1059            }
1060            Some(ConfigAction::Icon) => commands::icon::run_install(),
1061            Some(ConfigAction::Wizard { no_tui }) => {
1062                commands::config::run_wizard(no_tui, commands::config::Opened::ByRequest)
1063                    .map(|_| ())
1064            }
1065        },
1066        Commands::Restore { path, last_run } => {
1067            if last_run {
1068                commands::restore::run_last_run()
1069            } else {
1070                commands::restore::run(&config::expand_tilde(path.as_deref().unwrap_or(".")))
1071            }
1072        }
1073        Commands::Update {
1074            offline,
1075            install,
1076            channels,
1077        } => commands::update::run(offline, install, channels, cli.yes),
1078        Commands::Skill { agent, detected } => commands::skill::run(agent, detected),
1079        Commands::Setup { status } => commands::setup::run(status),
1080        Commands::Doctor { path, fix } => {
1081            let path = path.map(|p| config::expand_tilde(&p));
1082            commands::doctor::run(path.as_deref(), fix)
1083        }
1084        Commands::Install { channel, dry_run } => commands::install::run(channel, dry_run, cli.yes),
1085        Commands::Uninstall { deep } => commands::uninstall::run(deep, cli.yes),
1086    };
1087
1088    if let Err(e) = result {
1089        if is_broken_pipe(&e) {
1090            std::process::exit(exit_code::OK);
1091        }
1092        output::print_error(&format!("{e:#}"));
1093        if e.downcast_ref::<UsageError>().is_some() {
1094            std::process::exit(exit_code::USAGE);
1095        }
1096        std::process::exit(exit_code::FAILURE);
1097    }
1098
1099    // Only on the way out of a successful run: nobody reading an error message needs a
1100    // credit under it.
1101    if credit_the_author {
1102        output::print_attribution();
1103    }
1104}
1105
1106#[cfg(test)]
1107mod tests {
1108    use super::auto_setup_allowed;
1109
1110    fn args(rest: &[&str]) -> Vec<String> {
1111        std::iter::once("devp")
1112            .chain(rest.iter().copied())
1113            .map(String::from)
1114            .collect()
1115    }
1116
1117    #[test]
1118    fn ordinary_interactive_commands_may_auto_setup() {
1119        assert!(auto_setup_allowed(&args(&["status"])));
1120        assert!(auto_setup_allowed(&args(&["run", "--dry-run"])));
1121        assert!(auto_setup_allowed(&args(&[])));
1122    }
1123
1124    #[test]
1125    fn unattended_and_machine_read_invocations_may_not() {
1126        // The Git hook, the scheduler, and any `--json` consumer: a setup pass nobody
1127        // can see is one nobody can refuse, and setup output inside a JSON document is
1128        // a parse error.
1129        assert!(!auto_setup_allowed(&args(&["link", ".", "--quiet"])));
1130        assert!(!auto_setup_allowed(&args(&["run", "--daemon"])));
1131        assert!(!auto_setup_allowed(&args(&["status", "--json"])));
1132        assert!(!auto_setup_allowed(&args(&["uninstall"])));
1133        assert!(!auto_setup_allowed(&args(&["setup"])));
1134    }
1135}