Skip to main content

dev_prune/commands/
config.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Handler for the `dev-prune config` command.
5//
6// Supports `get`, `set`, `show`, `update`, `daemon`, and `hook` sub-actions
7// for managing global and per-repo workspace settings.
8
9use anyhow::{Result, bail};
10use std::path::Path;
11
12use crate::config::{PerRepoConfig, Registry, Settings};
13use crate::output;
14
15/// One tunable in the global config: how to read it, how to write it, and what to say
16/// about it.
17///
18/// A table rather than a `match` arm per operation. `get`, `set`, `show` and the
19/// first-run walkthrough all iterate this, so a setting cannot be added to one of them
20/// and quietly forgotten in the other three — which is how `min_size_mb` shipped with no
21/// line in `config show`.
22struct Setting {
23    key: &'static str,
24    /// The release this key first appeared in.
25    ///
26    /// Not decoration: the first-run marker records the version it was written at, so
27    /// comparing the two is how an upgrade knows which settings the user has never been
28    /// shown — without keeping a second list of "new in this version" to forget to
29    /// update. See [`settings_added_since_review`].
30    since: &'static str,
31    /// What kind of value this is, so a picker can offer the right control.
32    kind: Kind,
33    /// One line, shown by the walkthrough and by `config show --help-text`.
34    ///
35    /// Written for someone who already knows what a lockfile and a build tree are.
36    help: &'static str,
37    /// The same setting explained to someone who does not.
38    ///
39    /// Not a second `help` with shorter words: `help` says what the setting *is*, this
40    /// says what happens to you if it is on, in the second person, with no jargon and no
41    /// flag names. Both are shown together — nobody should have to be the right kind of
42    /// expert to answer a question this tool asked them.
43    plain: &'static str,
44    get: fn(&Settings) -> String,
45    set: fn(&mut Settings, &str) -> Result<()>,
46}
47
48/// How a setting should be *asked* about, as opposed to how it is stored.
49///
50/// Every value round-trips through `get`/`set` as a string either way — this only
51/// decides whether the configurator offers a toggle, a number to type, or the adapter
52/// checklist. Validation stays in the setters, which are the one place that owns it.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54enum Kind {
55    /// `true` or `false`.
56    Toggle,
57    /// A whole number, bounded by whatever its own setter enforces.
58    Number,
59    /// A comma-separated list of adapter names.
60    Adapters,
61    /// Cache manager names with a number each, as `npm=10,uv=10`.
62    ///
63    /// The third column of the same checklist [`Kind::AdapterDays`] is the second of:
64    /// which ecosystems run, how long each waits, and how big each one's cache may get
65    /// are one table, not three screens.
66    CacheCaps,
67    /// Adapter names with a number each, as `cargo=60,npm=30`.
68    ///
69    /// Edited on the same screen as [`Kind::Adapters`] rather than in a field of its
70    /// own: which adapters run and how long each waits are one decision made twice,
71    /// and splitting them across two rows is how someone switches an adapter on and
72    /// never finds the dial that would have made it safe.
73    AdapterDays,
74}
75
76/// One first-run suggestion: a setting worth turning on, and the reason.
77///
78/// A table of its own rather than a field on [`Setting`], because a suggestion is not a
79/// property of a setting — it is a claim about what most people should do on the day
80/// they install this, and the two lists move for different reasons.
81struct Recommendation {
82    key: &'static str,
83    /// Three or four words naming what accepting it turns on.
84    label: &'static str,
85    /// Why it is suggested — the part `help` and `plain` both leave out.
86    why: &'static str,
87    /// The value accepting it sets. A string, not a `bool`, so a suggested *number*
88    /// needs no new machinery here or in the view.
89    value: &'static str,
90    /// The second tier: recommended, with one specific thing to understand first.
91    cautious: bool,
92}
93
94/// What the first run suggests turning on.
95///
96/// Every entry is off by default and stays off unless the person accepts it, which is
97/// the only reason a screen suggesting them is honest. Nothing already on by default
98/// belongs here: a checkbox that is already ticked before you arrive teaches people to
99/// tick boxes.
100const RECOMMENDED: &[Recommendation] = &[
101    Recommendation {
102        key: "enable_cargo",
103        label: "Rust build folders",
104        why: "Rust `target/` directories are usually the largest thing on a developer's disk — \
105              tens of gigabytes across a handful of old projects. Nothing is lost: `cargo build` \
106              rebuilds it, and a project has to sit untouched for 45 days before this one is even \
107              considered.",
108        value: "true",
109        cautious: false,
110    },
111    Recommendation {
112        key: "enable_gradle",
113        label: "Android / Gradle builds",
114        why: "`build/` and `.gradle/` grow with every Android build and are never cleaned up by \
115              anything else. They come back on the next build, under the same 45-day wait.",
116        value: "true",
117        cautious: false,
118    },
119    Recommendation {
120        key: "enable_maven",
121        label: "Maven builds",
122        why: "Maven `target/` directories accumulate quietly per module, so a multi-module project \
123              has several. `mvn package` brings them back.",
124        value: "true",
125        cautious: false,
126    },
127    Recommendation {
128        key: "enable_swift",
129        label: "Swift builds",
130        why: "`.build/` holds compiled modules for every configuration you have ever built, and \
131              `swift build` recreates the one you actually use.",
132        value: "true",
133        cautious: false,
134    },
135    Recommendation {
136        key: "enable_dart",
137        label: "Dart / Flutter caches",
138        why: "`.dart_tool/` carries the pub metadata — back in a second — alongside `build_runner` \
139              and `flutter_build` caches that are worth real disk space.",
140        value: "true",
141        cautious: false,
142    },
143    Recommendation {
144        key: "enable_mix_build",
145        label: "Elixir build trees",
146        why: "`_build/` holds compiled beam files for every Mix environment you have built, and \
147              `mix compile` recreates the one you are working in.",
148        value: "true",
149        cautious: false,
150    },
151    Recommendation {
152        key: "enable_vcpkg",
153        label: "C / C++ vcpkg trees",
154        why: "`vcpkg_installed/` holds libraries vcpkg compiled from source for one \
155              project, and `vcpkg install` builds them again from the manifest beside \
156              them.",
157        value: "true",
158        cautious: false,
159    },
160    Recommendation {
161        key: "enable_cmake_build",
162        label: "C / C++ CMake build trees",
163        why: "A configured CMake build tree is object files and linked binaries, and \
164              `cmake` writes a `CMakeCache.txt` at the top of it that says which sources \
165              build it again — so a `build/` you made by hand is left alone.",
166        value: "true",
167        cautious: false,
168    },
169    Recommendation {
170        key: "allow_manifest_rewrite",
171        label: "Let cargo and go tidy up",
172        why: "Cautious, not risky. The commands that restore a Rust or Go project can also update \
173              `Cargo.lock` or `go.mod` — files Git tracks. Nothing is lost and nothing is deleted, \
174              but the next `git status` may show a change you did not make by hand. Turn it on if \
175              that is fine; leave it off if a clean working tree matters more than a fully \
176              automatic restore.",
177        value: "true",
178        cautious: true,
179    },
180];
181
182/// Every global setting, in the order a person would want to be asked about them.
183const SETTINGS: &[Setting] = &[
184    Setting {
185        key: "idle_days",
186        since: "1.0.0",
187        kind: Kind::Number,
188        help: "Days a repository must sit untouched before it is eligible for pruning.",
189        plain: "How long a project has to sit untouched before dev-prune will clean it. Something you worked on yesterday is never touched.",
190        get: |s| s.idle_days.to_string(),
191        set: |s, v| {
192            s.idle_days = v
193                .parse()
194                .map_err(|_| anyhow::anyhow!("idle_days must be a whole number of days"))?;
195            Ok(())
196        },
197    },
198    Setting {
199        key: "min_size_mb",
200        since: "1.0.0",
201        kind: Kind::Number,
202        help: "Smallest bloat directory worth deleting, in MiB. 0 removes the floor.",
203        plain: "Ignore small folders. Deleting a 2 MB folder is not worth the download to get it back.",
204        get: |s| s.min_size_mb.to_string(),
205        set: |s, v| {
206            s.min_size_mb = v.parse().map_err(|_| {
207                anyhow::anyhow!("min_size_mb must be a whole number of MiB (0 disables the floor)")
208            })?;
209            Ok(())
210        },
211    },
212    Setting {
213        key: "scan_depth",
214        since: "1.0.0",
215        kind: Kind::Number,
216        help: "How many directory levels below a repo root project discovery descends.",
217        plain: "How deep inside a repository to look for projects. Raise it if your projects live several folders down; lower it if scanning feels slow.",
218        get: |s| s.scan_depth.to_string(),
219        set: |s, v| {
220            let depth: usize = v
221                .parse()
222                .map_err(|_| anyhow::anyhow!("scan_depth must be a positive integer"))?;
223            // Rejected rather than clamped. `clamp_depth` exists so a hand-edited config
224            // file cannot break the walk, but when someone types the number at us we owe
225            // them the truth instead of silently storing something else.
226            if depth == 0 {
227                bail!("scan_depth must be at least 1 — 0 would find no projects at all.");
228            }
229            if depth > crate::constants::MAX_SCAN_DEPTH_LIMIT {
230                bail!(
231                    "scan_depth must be at most {} — deeper walks stall on generated trees.",
232                    crate::constants::MAX_SCAN_DEPTH_LIMIT
233                );
234            }
235            s.scan_depth = depth;
236            Ok(())
237        },
238    },
239    Setting {
240        key: "require_confirmation",
241        since: "1.0.0",
242        kind: Kind::Toggle,
243        help: "Ask before deleting anything. Turning this off makes every run unattended.",
244        plain: "Whether dev-prune asks \"delete these?\" before it deletes. Leave this on unless you want it to run silently while you are away.",
245        get: |s| s.require_confirmation.to_string(),
246        set: |s, v| {
247            s.require_confirmation = parse_bool("require_confirmation", v)?;
248            Ok(())
249        },
250    },
251    Setting {
252        key: "allow_manifest_rewrite",
253        since: "1.0.0",
254        kind: Kind::Toggle,
255        help: "Let cargo and go run the sync command that rewrites tracked manifests.",
256        plain: "Lets dev-prune run the command that puts a Rust or Go project back together — which can edit files that are checked into Git. Nothing is lost, but the change shows up in `git status`.",
257        get: |s| s.allow_manifest_rewrite.to_string(),
258        set: |s, v| {
259            s.allow_manifest_rewrite = parse_bool("allow_manifest_rewrite", v)?;
260            Ok(())
261        },
262    },
263    Setting {
264        key: "command_timeout_secs",
265        since: "1.0.0",
266        kind: Kind::Number,
267        help: "How long a lockfile command may run before it is killed.",
268        plain: "How long to wait for a rebuild command before giving up on it. Raise it on a slow connection.",
269        get: |s| s.command_timeout_secs.to_string(),
270        set: |s, v| {
271            let secs: u64 = v
272                .parse()
273                .map_err(|_| anyhow::anyhow!("command_timeout_secs must be a positive integer"))?;
274            // Zero is not "no limit": the runner compares elapsed time against it before
275            // the child has had a chance to finish, so every lockfile sync would be
276            // killed on the spot and nothing would ever be pruneable.
277            if secs == 0 {
278                bail!(
279                    "command_timeout_secs must be at least 1 — 0 would kill every command \
280                     the instant it starts."
281                );
282            }
283            s.command_timeout_secs = secs;
284            Ok(())
285        },
286    },
287    Setting {
288        key: "auto_setup",
289        since: "1.0.0",
290        kind: Kind::Toggle,
291        help: "Install missing integrations by itself, once per installed version.",
292        plain: "Whether dev-prune finishes setting itself up on its own instead of making you run `devp setup`.",
293        get: |s| s.auto_setup.to_string(),
294        set: |s, v| {
295            s.auto_setup = parse_bool("auto_setup", v)?;
296            Ok(())
297        },
298    },
299    Setting {
300        key: "auto_config",
301        since: "1.3.0",
302        kind: Kind::Toggle,
303        help: "Write a default .devprune.json into repositories that link/init register.",
304        plain: "Drops a small settings file into each repository you register, so you can give that one project different rules later.",
305        get: |s| s.auto_config.to_string(),
306        set: |s, v| {
307            s.auto_config = parse_bool("auto_config", v)?;
308            Ok(())
309        },
310    },
311    Setting {
312        key: "auto_daemon",
313        since: "1.0.0",
314        kind: Kind::Toggle,
315        help: "Register the OS scheduler so passes run without being remembered.",
316        plain: "Lets your operating system run dev-prune on a schedule, so you never have to remember to.",
317        get: |s| s.auto_daemon.to_string(),
318        set: |s, v| {
319            s.auto_daemon = parse_bool("auto_daemon", v)?;
320            Ok(())
321        },
322    },
323    Setting {
324        key: "check_interval_days",
325        since: "1.0.0",
326        kind: Kind::Number,
327        help: "Days between scheduled background passes.",
328        plain: "How often that scheduled cleanup runs.",
329        get: |s| s.check_interval_days.to_string(),
330        set: |s, v| {
331            let days: u64 = v
332                .parse()
333                .map_err(|_| anyhow::anyhow!("check_interval_days must be a positive integer"))?;
334            // Zero would schedule a prune pass with no gap between passes.
335            if days == 0 {
336                bail!("check_interval_days must be at least 1.");
337            }
338            s.check_interval_days = days;
339            Ok(())
340        },
341    },
342    Setting {
343        key: "auto_hooks",
344        since: "1.0.0",
345        kind: Kind::Toggle,
346        help: "Install the Git hooks that register repositories as you clone them.",
347        plain: "Registers new repositories automatically as you clone them, so you never have to add them by hand.",
348        get: |s| s.auto_hooks.to_string(),
349        set: |s, v| {
350            s.auto_hooks = parse_bool("auto_hooks", v)?;
351            Ok(())
352        },
353    },
354    Setting {
355        key: "auto_hooks_chain",
356        since: "1.0.0",
357        kind: Kind::Toggle,
358        help: "If another tool owns core.hooksPath, install in front of it and forward.",
359        plain: "Git only has one slot for this kind of automation. If something else — husky, pre-commit, lefthook — is already using it, share the slot instead of taking it over.",
360        get: |s| s.auto_hooks_chain.to_string(),
361        set: |s, v| {
362            s.auto_hooks_chain = parse_bool("auto_hooks_chain", v)?;
363            Ok(())
364        },
365    },
366    Setting {
367        key: "update_check",
368        since: "1.0.0",
369        kind: Kind::Toggle,
370        help: "Ask GitHub for the latest release from time to time. Sends nothing but the request.",
371        plain: "Whether dev-prune checks GitHub now and then to see if there is a newer version. It sends no information about you.",
372        get: |s| s.update_check.to_string(),
373        set: |s, v| {
374            s.update_check = parse_bool("update_check", v)?;
375            Ok(())
376        },
377    },
378    Setting {
379        key: "update_check_interval_days",
380        since: "1.0.0",
381        kind: Kind::Number,
382        help: "Days between automatic release checks.",
383        plain: "How often that version check happens.",
384        get: |s| s.update_check_interval_days.to_string(),
385        set: |s, v| {
386            let days: i64 = v.parse().map_err(|_| {
387                anyhow::anyhow!("update_check_interval_days must be a positive integer")
388            })?;
389            if days < 1 {
390                bail!("update_check_interval_days must be at least 1.");
391            }
392            s.update_check_interval_days = days;
393            Ok(())
394        },
395    },
396    Setting {
397        key: "update_check_timeout_secs",
398        since: "1.0.0",
399        kind: Kind::Number,
400        help: "Seconds the release check waits for GitHub. Raise it behind a slow proxy.",
401        plain: "How long the version check waits before giving up. Raise it if you are behind a slow proxy.",
402        get: |s| s.update_check_timeout_secs.to_string(),
403        set: |s, v| {
404            let secs: u64 = v.parse().map_err(|_| {
405                anyhow::anyhow!("update_check_timeout_secs must be a positive integer")
406            })?;
407            if secs == 0 {
408                bail!("update_check_timeout_secs must be at least 1.");
409            }
410            s.update_check_timeout_secs = secs;
411            Ok(())
412        },
413    },
414    Setting {
415        key: "enable_cargo",
416        since: "1.5.0",
417        kind: Kind::Toggle,
418        help: "Turn on the opt-in Cargo adapter (target/ comes back by recompiling, not downloading).",
419        plain: "Clean Rust build folders too. These come back by recompiling, which takes minutes rather than a download — so this is off unless you say otherwise.",
420        get: |s| s.enable_cargo.to_string(),
421        set: |s, v| {
422            s.enable_cargo = parse_bool("enable_cargo", v)?;
423            Ok(())
424        },
425    },
426    Setting {
427        key: "enable_gradle",
428        since: "1.3.0",
429        kind: Kind::Toggle,
430        help: "Turn on the opt-in Gradle adapter (build/ and .gradle/ come back by recompiling).",
431        plain: "Clean Android and Java build folders too. Same trade: they come back by recompiling, not downloading.",
432        get: |s| s.enable_gradle.to_string(),
433        set: |s, v| {
434            s.enable_gradle = parse_bool("enable_gradle", v)?;
435            Ok(())
436        },
437    },
438    Setting {
439        key: "enable_maven",
440        since: "1.3.0",
441        kind: Kind::Toggle,
442        help: "Turn on the opt-in Maven adapter (target/ comes back by recompiling).",
443        plain: "Clean Maven build folders too. They come back by recompiling.",
444        get: |s| s.enable_maven.to_string(),
445        set: |s, v| {
446            s.enable_maven = parse_bool("enable_maven", v)?;
447            Ok(())
448        },
449    },
450    Setting {
451        key: "enable_swift",
452        since: "1.4.0",
453        kind: Kind::Toggle,
454        help: "Turn on the opt-in SwiftPM adapter (.build/ comes back by recompiling).",
455        plain: "Clean Swift build folders too. They come back by recompiling.",
456        get: |s| s.enable_swift.to_string(),
457        set: |s, v| {
458            s.enable_swift = parse_bool("enable_swift", v)?;
459            Ok(())
460        },
461    },
462    Setting {
463        key: "enable_dart",
464        since: "1.6.0",
465        kind: Kind::Toggle,
466        help: "Turn on the opt-in Dart/Flutter adapter (.dart_tool/ holds build caches).",
467        plain: "Clean Dart and Flutter caches too. Part comes back instantly, part by recompiling.",
468        get: |s| s.enable_dart.to_string(),
469        set: |s, v| {
470            s.enable_dart = parse_bool("enable_dart", v)?;
471            Ok(())
472        },
473    },
474    Setting {
475        key: "enable_mix_build",
476        since: "1.7.0",
477        kind: Kind::Toggle,
478        help: "Turn on the opt-in Mix build-tree adapter (_build/ comes back by recompiling).",
479        plain: "Clean Elixir _build/ folders too. They come back by recompiling.",
480        get: |s| s.enable_mix_build.to_string(),
481        set: |s, v| {
482            s.enable_mix_build = parse_bool("enable_mix_build", v)?;
483            Ok(())
484        },
485    },
486    Setting {
487        key: "enable_vcpkg",
488        since: "1.8.0",
489        kind: Kind::Toggle,
490        help: "Turn on the opt-in vcpkg adapter (vcpkg_installed/ comes back by recompiling).",
491        plain: "Clean C and C++ vcpkg_installed/ folders too. They come back by recompiling.",
492        get: |s| s.enable_vcpkg.to_string(),
493        set: |s, v| {
494            s.enable_vcpkg = parse_bool("enable_vcpkg", v)?;
495            Ok(())
496        },
497    },
498    Setting {
499        key: "enable_cmake_build",
500        since: "1.8.0",
501        kind: Kind::Toggle,
502        help: "Turn on the opt-in CMake adapter (build trees proven by their CMakeCache.txt).",
503        plain: "Clean C and C++ build folders CMake configured. A `build/` you made by hand is \
504                never touched.",
505        get: |s| s.enable_cmake_build.to_string(),
506        set: |s, v| {
507            s.enable_cmake_build = parse_bool("enable_cmake_build", v)?;
508            Ok(())
509        },
510    },
511    Setting {
512        key: "build_idle_days",
513        since: "1.3.0",
514        kind: Kind::Number,
515        help: "Idle days before the opt-in adapters' build trees are pruned. Applied as max(this, idle_days).",
516        plain: "A longer wait, used only for the build folders above, because getting those back costs a recompile rather than a download.",
517        get: |s| s.build_idle_days.to_string(),
518        set: |s, v| {
519            let days: u64 = v
520                .parse()
521                .map_err(|_| anyhow::anyhow!("build_idle_days must be a non-negative integer"))?;
522            s.build_idle_days = days;
523            Ok(())
524        },
525    },
526    Setting {
527        key: "auto_update",
528        since: "1.3.0",
529        kind: Kind::Toggle,
530        help: "Install a newer release by itself at the end of a prune pass. On by default.",
531        plain: "Whether dev-prune installs its own updates after a cleanup. The download is checked against its published fingerprint first.",
532        get: |s| s.auto_update.to_string(),
533        set: |s, v| {
534            s.auto_update = parse_bool("auto_update", v)?;
535            Ok(())
536        },
537    },
538    Setting {
539        key: "version_lock",
540        since: "1.8.0",
541        kind: Kind::Toggle,
542        help: "Pin this copy to the version it is. Overrides auto_update, `devp update \
543                --install`, `devp install --channel` and the install scripts.",
544        plain: "Stay on exactly this version. Nothing dev-prune does replaces the binary \
545                while this is on -- not the automatic update, not a re-run of the install \
546                one-liner.",
547        get: |s| s.version_lock.to_string(),
548        set: |s, v| {
549            s.version_lock = parse_bool("version_lock", v)?;
550            Ok(())
551        },
552    },
553    Setting {
554        key: "disabled_adapters",
555        since: "1.4.0",
556        kind: Kind::Adapters,
557        help: "Adapters to leave alone entirely, by name. Empty means every one of them is active.",
558        plain: "Ecosystems to ignore completely — as if you did not have them installed at all.",
559        get: |s| {
560            if s.disabled_adapters.is_empty() {
561                "(none)".to_string()
562            } else {
563                s.disabled_adapters.join(",")
564            }
565        },
566        set: |s, v| {
567            s.disabled_adapters = parse_adapter_list(v)?;
568            Ok(())
569        },
570    },
571    Setting {
572        key: "adapter_idle_days",
573        since: "1.5.0",
574        kind: Kind::AdapterDays,
575        help: "Per-adapter idle windows, as `cargo=60,npm=30`. Each one can only raise its own wait.",
576        plain: "A different waiting period for one ecosystem. Useful when your Rust projects should wait longer than your Node ones.",
577        get: |s| {
578            if s.adapter_idle_days.is_empty() {
579                "(none)".to_string()
580            } else {
581                s.adapter_idle_days
582                    .iter()
583                    .map(|(name, days)| format!("{name}={days}"))
584                    .collect::<Vec<_>>()
585                    .join(",")
586            }
587        },
588        set: |s, v| {
589            s.adapter_idle_days = parse_adapter_days(v)?;
590            Ok(())
591        },
592    },
593    Setting {
594        key: "cache_max_gb",
595        since: "1.8.0",
596        kind: Kind::CacheCaps,
597        help: "Per-manager cache size caps in GiB, as `npm=10,uv=10`. Reported by `devp caches`; cleared only by `devp caches clear --over-cap`.",
598        plain: "How big one ecosystem's download cache is allowed to get before dev-prune says so. It still never deletes a cache on its own.",
599        get: |s| {
600            if s.cache_max_gb.is_empty() {
601                "(none)".to_string()
602            } else {
603                s.cache_max_gb
604                    .iter()
605                    .map(|(name, gb)| format!("{name}={gb}"))
606                    .collect::<Vec<_>>()
607                    .join(",")
608            }
609        },
610        set: |s, v| {
611            s.cache_max_gb = parse_cache_caps(v)?;
612            Ok(())
613        },
614    },
615];
616
617/// Parse the comma-separated adapter deny-list, rejecting names that do not exist.
618///
619/// An unknown name is an error listing the valid ones rather than a no-op, for the same
620/// reason `--only nmp` is: a silently ignored typo reads as "npm is protected" right up
621/// until the pass that deletes `node_modules`.
622/// Parse `npm=10,uv=10` into the per-manager cache cap map.
623///
624/// Validated against the cache manager names `devp caches clear` takes, not the adapter
625/// names [`parse_adapter_days`] uses. The two lists overlap but neither contains the
626/// other — `pip`, `nuget`, `conan`, `conda`, `vcpkg` and `hex` are caches with no
627/// adapter, and `venv`, `terraform` and `dart` are adapters with no cache — so
628/// accepting an adapter name here would store a cap that nothing ever reads.
629///
630/// Zero is rejected rather than treated as "cap everything": a cache is over a cap of
631/// zero the moment it exists, and a setting whose only effect is to mark every cache
632/// permanently over-size is a typo for `-` every time.
633fn parse_cache_caps(value: &str) -> Result<std::collections::BTreeMap<String, u64>> {
634    let trimmed = value.trim();
635    if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
636        return Ok(std::collections::BTreeMap::new());
637    }
638
639    let mut caps = std::collections::BTreeMap::new();
640    for raw in trimmed.split(',') {
641        let entry = raw.trim();
642        if entry.is_empty() {
643            continue;
644        }
645        let Some((name, value)) = entry.split_once('=') else {
646            bail!("`{entry}` must be written as `<manager>=<gib>`, for example `uv=10`.");
647        };
648        let name = name.trim().to_lowercase();
649        if !crate::commands::caches::is_cache_manager(&name) {
650            bail!(
651                "`{name}` is not a manager dev-prune knows a cache for. Valid names: {}",
652                crate::commands::caches::known_managers().join(", ")
653            );
654        }
655        let parsed: u64 = value.trim().parse().map_err(|_| {
656            anyhow::anyhow!(
657                "`{name}` needs a whole number of gibibytes, not `{}`.",
658                value.trim()
659            )
660        })?;
661        if parsed == 0 {
662            bail!(
663                "`{name}=0` would call the cache too big the moment it exists. Use `-` to clear the caps instead."
664            );
665        }
666        caps.insert(name, parsed);
667    }
668    Ok(caps)
669}
670
671/// Parse `cargo=60,npm=30` into the per-adapter idle map.
672///
673/// Same "clear it" spellings as [`parse_adapter_list`], and the same closed loop: what
674/// `config get adapter_idle_days` prints is accepted verbatim by `config set`.
675fn parse_adapter_days(value: &str) -> Result<std::collections::BTreeMap<String, u64>> {
676    let trimmed = value.trim();
677    if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
678        return Ok(std::collections::BTreeMap::new());
679    }
680
681    let mut days = std::collections::BTreeMap::new();
682    for raw in trimmed.split(',') {
683        let entry = raw.trim();
684        if entry.is_empty() {
685            continue;
686        }
687        let Some((name, value)) = entry.split_once('=') else {
688            bail!("`{entry}` must be written as `<adapter>=<days>`, for example `cargo=60`.");
689        };
690        let name = name.trim().to_lowercase();
691        if !crate::adapters::is_adapter_name(&name) {
692            bail!(
693                "`{name}` is not an adapter. Valid names: {}",
694                crate::adapters::all_adapter_names().join(", ")
695            );
696        }
697        let parsed: u64 = value.trim().parse().map_err(|_| {
698            anyhow::anyhow!(
699                "`{name}` needs a whole number of days, not `{}`.",
700                value.trim()
701            )
702        })?;
703        days.insert(name, parsed);
704    }
705    Ok(days)
706}
707
708fn parse_adapter_list(value: &str) -> Result<Vec<String>> {
709    let trimmed = value.trim();
710    // The spellings that mean "clear it". `(none)` closes the loop with the getter, so
711    // whatever `config get` prints can be handed straight back to `config set`.
712    if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
713        return Ok(Vec::new());
714    }
715
716    let mut names: Vec<String> = Vec::new();
717    for raw in trimmed.split(',') {
718        let name = raw.trim().to_lowercase();
719        if name.is_empty() {
720            continue;
721        }
722        if !crate::adapters::is_adapter_name(&name) {
723            bail!(
724                "`{name}` is not an adapter. Valid names: {}",
725                crate::adapters::all_adapter_names().join(", ")
726            );
727        }
728        if !names.contains(&name) {
729            names.push(name);
730        }
731    }
732    Ok(names)
733}
734
735fn parse_bool(key: &str, value: &str) -> Result<bool> {
736    match value.trim().to_lowercase().as_str() {
737        "true" | "yes" | "y" | "on" | "1" => Ok(true),
738        "false" | "no" | "n" | "off" | "0" => Ok(false),
739        _ => bail!("{key} must be true or false"),
740    }
741}
742
743/// Every stored setting that its own setter would refuse, with the reason.
744///
745/// `devp config set` guards the ranges, but nothing guards a hand-edited `registry.json`
746/// — and the values that get in that way are the quiet ones: `scan_depth: 0` finds no
747/// projects, `command_timeout_secs: 0` kills every lockfile command the instant it
748/// starts. Both leave a tool that runs, reports success and prunes nothing.
749///
750/// Round-tripping each value through the setter that owns it is deliberate. A separate
751/// list of ranges would be a second copy of the rules, free to drift from the ones
752/// actually enforced.
753pub fn invalid_settings(settings: &Settings) -> Vec<(&'static str, String)> {
754    SETTINGS
755        .iter()
756        .filter_map(|setting| {
757            let mut probe = settings.clone();
758            (setting.set)(&mut probe, &(setting.get)(settings))
759                .err()
760                .map(|e| (setting.key, e.to_string()))
761        })
762        .collect()
763}
764
765/// The number of settings [`invalid_settings`] checks, for reports that say so.
766pub fn setting_count() -> usize {
767    SETTINGS.len()
768}
769
770fn find_setting(key: &str) -> Result<&'static Setting> {
771    SETTINGS
772        .iter()
773        .find(|s| s.key == key)
774        .ok_or_else(|| anyhow::anyhow!("Unknown config key: {key}. Valid keys: {}", valid_keys()))
775}
776
777fn valid_keys() -> String {
778    SETTINGS
779        .iter()
780        .map(|s| s.key)
781        .collect::<Vec<_>>()
782        .join(", ")
783}
784
785/// What a `daemon` / `hook` sub-action word means.
786#[derive(Debug, PartialEq, Eq)]
787pub enum Toggle {
788    Enable,
789    Disable,
790    Status,
791}
792
793/// Resolve the sub-action word users actually type.
794///
795/// `install` / `uninstall` are what this tool's own output and its documentation have
796/// always called these operations, and `on` / `off` is the obvious guess; each pair
797/// means the same thing as `enable` / `disable`, so all of them are accepted.
798///
799/// Anything else is an error rather than a fall-through to `status`. Silently printing
800/// status for `devp config daemon enabel` looks like it worked and leaves the daemon
801/// uninstalled.
802pub fn parse_toggle(action: &str) -> Result<Toggle> {
803    match action.to_lowercase().as_str() {
804        "enable" | "install" | "on" => Ok(Toggle::Enable),
805        "disable" | "uninstall" | "remove" | "off" => Ok(Toggle::Disable),
806        "" | "status" | "show" => Ok(Toggle::Status),
807        other => bail!(
808            "Unknown action `{other}`. Expected `enable`, `disable` or `status` \
809             (`install` / `uninstall` / `on` / `off` also work)."
810        ),
811    }
812}
813
814/// Whether a bare argument is a sub-action rather than a workspace path.
815///
816/// `devp config hook <word>` is ambiguous by design — `<word>` is either the action or
817/// the repository to apply it to — so both the argument router and [`parse_toggle`]
818/// have to agree on which words are actions.
819pub fn is_toggle_word(word: &str) -> bool {
820    parse_toggle(word).is_ok() && !word.is_empty()
821}
822
823/// Resolve the workspace argument of `daemon` / `hook`, which is whatever was not
824/// recognised as an action.
825///
826/// A word that is neither an action nor a directory is a mistyped action. Treating it
827/// as a path would print `Daemon Status (enabel): Enabled for workspace` — a success
828/// message about a repository that does not exist.
829fn resolve_workspace(path: &str) -> Result<std::path::PathBuf> {
830    let raw = Path::new(path);
831    if !raw.is_dir() {
832        bail!(
833            "`{path}` is neither an action nor an existing directory.\n\
834             Expected `enable`, `disable` or `status`, or a path to a repository."
835        );
836    }
837    Ok(raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf()))
838}
839
840/// Display a single config value.
841pub fn run_get(key: &str) -> Result<()> {
842    let registry = Registry::load()?;
843    let setting = find_setting(key)?;
844    println!("{key} = {}", (setting.get)(&registry.settings));
845    Ok(())
846}
847
848/// Set a config value.
849pub fn run_set(key: &str, value: &str) -> Result<()> {
850    let mut registry = Registry::load()?;
851    let setting = find_setting(key)?;
852    (setting.set)(&mut registry.settings, value)?;
853    registry.save()?;
854
855    // The stored value, not the typed one: `devp config set auto_daemon yes` stores
856    // `true`, and echoing "auto_daemon = yes" would describe a file that does not exist.
857    output::print_success(&format!("{key} = {}", (setting.get)(&registry.settings)));
858    Ok(())
859}
860
861/// Widest key name, so every value in `config show` lines up.
862fn key_column_width() -> usize {
863    SETTINGS.iter().map(|s| s.key.len()).max().unwrap_or(0)
864}
865
866/// Show all config values.
867pub fn run_show() -> Result<()> {
868    let registry = Registry::load()?;
869    let width = key_column_width();
870
871    output::print_header("dev-prune Global Configuration");
872    for setting in SETTINGS {
873        println!(
874            "  {:<width$} = {}",
875            setting.key,
876            (setting.get)(&registry.settings)
877        );
878    }
879    println!("  {:<width$} = {}", "tracked_repos", registry.repo_count());
880
881    let reg_path = Registry::registry_path()
882        .map(|p| output::clean_path(&p))
883        .unwrap_or_else(|_| "unknown".to_string());
884    println!("\n  {:<width$} = {reg_path}", "registry_file");
885    println!();
886    output::print_info("Change any of these with `devp config set <key> <value>`.");
887    output::print_info("Walk through them one at a time with `devp config wizard`.");
888
889    Ok(())
890}
891
892/// Put every global setting in front of the user, and let them change any of it.
893///
894/// Run by hand as `devp config wizard`, and once automatically — the first time a human
895/// types a command on a fresh install, and again after an upgrade that added a setting
896/// they have never been shown. Both are the moment a default starts applying to their
897/// machine, and the only moment they can be told so before rather than after.
898///
899/// Two implementations, one meaning. [`run_wizard_tui`] is the full-screen one; the
900/// line-by-line [`run_wizard_prompts`] runs wherever that cannot, which is less a
901/// degraded mode than the only honest option on a pipe.
902pub fn run_wizard(no_tui: bool) -> Result<()> {
903    if !no_tui && full_screen_is_usable() {
904        return run_wizard_tui();
905    }
906    run_wizard_prompts()
907}
908
909/// Whether a full-screen view can be opened, and should be.
910///
911/// The terminal test answers "is there a screen to draw on". `DEV_PRUNE_NO_TUI` answers
912/// the one it cannot: whether the thing holding that terminal is a person. An agent
913/// driving `devp` through a pty passes every terminal check and will never press a key,
914/// so it sets the variable and gets the prompts — or, better, skips this command
915/// altogether for `devp config set`, which needs no interaction at all.
916fn full_screen_is_usable() -> bool {
917    use std::io::IsTerminal;
918    if std::env::var_os(crate::constants::ENV_NO_TUI).is_some() {
919        return false;
920    }
921    std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
922}
923
924/// The full-screen configurator: declaration, then every setting, then the summary.
925fn run_wizard_tui() -> Result<()> {
926    use crate::tui::config_view::{ConfigRow, ConfigSession, Control, Outcome};
927
928    let mut registry = Registry::load()?;
929    let new_keys = settings_added_since_review();
930
931    let rows: Vec<ConfigRow> = SETTINGS
932        .iter()
933        .map(|setting| {
934            let value = (setting.get)(&registry.settings);
935            ConfigRow {
936                key: setting.key,
937                help: setting.help,
938                plain: setting.plain,
939                control: match setting.kind {
940                    Kind::Toggle => Control::Toggle,
941                    Kind::Number => Control::Number,
942                    Kind::Adapters => Control::Adapters,
943                    Kind::AdapterDays => Control::AdapterDays,
944                    Kind::CacheCaps => Control::CacheCaps,
945                },
946                original: value.clone(),
947                value,
948                is_new: new_keys.contains(&setting.key),
949            }
950        })
951        .collect();
952
953    // The view validates through the real setters against a throwaway copy, so a value it
954    // accepts is a value that will save, and the rules stay in exactly one place.
955    let base = registry.settings.clone();
956    let validate = move |key: &str, value: &str| -> std::result::Result<(), String> {
957        let setting = find_setting(key).map_err(|e| e.to_string())?;
958        let mut probe = base.clone();
959        (setting.set)(&mut probe, value).map_err(|e| format!("{e}"))
960    };
961
962    let report = crate::commands::trust::build(&registry);
963    let adapters = crate::adapters::all_adapter_names();
964    let opt_in = crate::adapters::opt_in_adapter_names();
965    // Identity, never a guess: the checklist offers a cache cap only where an adapter
966    // and a cache go by the same name. See `ConfigSession::capped_adapters`.
967    let capped: Vec<&'static str> = adapters
968        .iter()
969        .copied()
970        .filter(|name| crate::commands::caches::is_cache_manager(name))
971        .collect();
972
973    let outcome = crate::tui::config_view::run(ConfigSession {
974        declaration: declaration_lines(&report),
975        standing: NOTHING_DELETED_YET.to_string(),
976        suggestions: first_run_suggestions(),
977        rows,
978        adapters: &adapters,
979        opt_in_adapters: &opt_in,
980        capped_adapters: &capped,
981        groups: crate::adapters::ADAPTER_GROUPS,
982        validate: &validate,
983        title: "dev-prune configuration",
984    })?;
985
986    match outcome {
987        // Deliberately not marked reviewed here — the caller decides. The first run marks
988        // it anyway, because being asked again on every command is worse than being asked
989        // once and walking away; `devp config wizard` typed by hand changes nothing.
990        Outcome::Cancelled => {
991            output::print_info("Cancelled — nothing was changed.");
992            Ok(())
993        }
994        Outcome::KeepAll => {
995            mark_reviewed();
996            output::print_success(
997                "Keeping the current values. `devp config set <key> <value>` changes any.",
998            );
999            Ok(())
1000        }
1001        Outcome::Save(changed) => {
1002            for row in &changed {
1003                (find_setting(row.key)?.set)(&mut registry.settings, &row.value)?;
1004            }
1005            registry.save()?;
1006            mark_reviewed();
1007
1008            // Reprinted into the scrollback on purpose: the summary screen left with the
1009            // alternate screen, and what was just written to a config file should still be
1010            // readable after the view that wrote it has closed.
1011            output::print_header("Saved");
1012            let width = changed.iter().map(|r| r.key.len()).max().unwrap_or(0);
1013            for row in &changed {
1014                println!(
1015                    "  {:<width$} = {}  (was {})",
1016                    row.key, row.value, row.original
1017                );
1018            }
1019            println!();
1020            output::print_success(&format!(
1021                "{} {} saved. `devp config show` lists every setting.",
1022                changed.len(),
1023                output::plural(changed.len(), "change", "changes")
1024            ));
1025            Ok(())
1026        }
1027    }
1028}
1029
1030/// The suggestions screen's contents — empty on every run but the first.
1031///
1032/// "First" is the same fact the walkthrough itself runs on: no review marker on disk
1033/// means this machine has never been shown the settings. Someone who types
1034/// `devp config wizard` a month later has already made these decisions once, and
1035/// re-suggesting them is how a suggestion turns into nagging.
1036///
1037/// The descriptions are read off the settings table rather than written again here.
1038/// Two copies of "what does `enable_cargo` do" is one copy free to drift, and the copy
1039/// on this screen is the one a brand-new user reads first.
1040fn first_run_suggestions() -> Vec<crate::tui::config_view::Suggestion> {
1041    use crate::tui::config_view::Suggestion;
1042
1043    if reviewed_version().is_some() {
1044        return Vec::new();
1045    }
1046    RECOMMENDED
1047        .iter()
1048        .filter_map(|r| {
1049            let setting = find_setting(r.key).ok()?;
1050            Some(Suggestion {
1051                key: r.key,
1052                label: r.label,
1053                help: setting.help,
1054                plain: setting.plain,
1055                why: r.why,
1056                value: r.value,
1057                cautious: r.cautious,
1058            })
1059        })
1060        .collect()
1061}
1062
1063/// What is true at the moment the configurator opens, and stays true while it is open.
1064const NOTHING_DELETED_YET: &str =
1065    "Nothing has been deleted, and nothing will be until a lockfile proves it comes back.";
1066
1067/// The declaration screen's contents: `devp trust`, shown before rather than after.
1068///
1069/// Read off the same report that command prints rather than written out again here. A
1070/// second copy of these promises is a second copy free to drift, and the copy a new user
1071/// reads first is the worst one to have drift.
1072fn declaration_lines(
1073    report: &crate::commands::trust::TrustReport,
1074) -> Vec<crate::tui::config_view::DeclarationLine> {
1075    use crate::commands::trust::{TrustRow, Verdict};
1076    use crate::tui::config_view::DeclarationLine;
1077
1078    let heading = |text: &str| DeclarationLine {
1079        mark: '#',
1080        subject: text.to_string(),
1081        state: String::new(),
1082    };
1083    let row = |r: &TrustRow| DeclarationLine {
1084        mark: match r.verdict {
1085            Verdict::Guaranteed | Verdict::Safe => '+',
1086            Verdict::Widened => '!',
1087            Verdict::Neutral => ' ',
1088        },
1089        subject: r.subject.to_string(),
1090        state: r.state.clone(),
1091    };
1092
1093    let mut lines = vec![heading("Guaranteed by the code")];
1094    lines.extend(report.guarantees.iter().map(&row));
1095    lines.push(heading(""));
1096    lines.push(heading("On this machine"));
1097    lines.extend(report.machine.iter().map(&row));
1098    lines
1099}
1100
1101/// Walk the global settings one line at a time, offering each current value.
1102///
1103/// Refuses without a terminal instead of hanging on a read that will never return.
1104fn run_wizard_prompts() -> Result<()> {
1105    use std::io::{self, IsTerminal, Write};
1106
1107    if !io::stdin().is_terminal() {
1108        bail!(
1109            "`devp config wizard` needs a terminal to ask questions on.\n\
1110             Use `devp config show` to read the settings and `devp config set <key> <value>` \
1111             to change one."
1112        );
1113    }
1114
1115    let mut registry = Registry::load()?;
1116    let width = key_column_width();
1117    let new_keys = settings_added_since_review();
1118
1119    output::print_header("dev-prune configuration");
1120    output::print_info("These are the defaults every run will use. Nothing has been changed yet.");
1121    println!();
1122    for setting in SETTINGS {
1123        // A setting that arrived in an upgrade has been applying its default since the
1124        // upgrade, so naming those is the whole reason this reopened.
1125        let badge = if new_keys.contains(&setting.key) {
1126            "   (new in this version)"
1127        } else {
1128            ""
1129        };
1130        println!(
1131            "  {:<width$} = {}{badge}",
1132            setting.key,
1133            (setting.get)(&registry.settings)
1134        );
1135        println!("  {:<width$}   {}", "", setting.help);
1136        // Both lines here too. This path is what a pipe, a narrow terminal and
1137        // `DEV_PRUNE_NO_TUI` all get, and it is no place to be the terse one.
1138        println!("  {:<width$}   {}", "", setting.plain);
1139    }
1140    println!();
1141
1142    print!("Keep all of these? [Y/n] ");
1143    io::stdout().flush()?;
1144    let mut answer = String::new();
1145    io::stdin().read_line(&mut answer)?;
1146    let keep = !matches!(answer.trim().to_lowercase().as_str(), "n" | "no");
1147
1148    if keep {
1149        mark_reviewed();
1150        output::print_success("Keeping the defaults. `devp config set <key> <value>` changes any.");
1151        return Ok(());
1152    }
1153
1154    println!();
1155    output::print_info("Enter a new value, or press Enter to keep the one shown.");
1156    println!();
1157
1158    let mut changed = 0usize;
1159    for setting in SETTINGS {
1160        let current = (setting.get)(&registry.settings);
1161        loop {
1162            print!("  {} [{current}]: ", setting.key);
1163            io::stdout().flush()?;
1164            let mut line = String::new();
1165            // EOF mid-way — a closed pipe or Ctrl-D — keeps what has been answered so far
1166            // rather than looping forever on an empty read.
1167            if io::stdin().read_line(&mut line)? == 0 {
1168                println!();
1169                break;
1170            }
1171            let typed = line.trim();
1172            if typed.is_empty() {
1173                break;
1174            }
1175            match (setting.set)(&mut registry.settings, typed) {
1176                Ok(()) => {
1177                    changed += 1;
1178                    break;
1179                }
1180                // Re-asked rather than aborted: losing the eight answers already given
1181                // because the ninth was a typo is not a reasonable trade.
1182                Err(e) => output::print_error(&format!("{e}")),
1183            }
1184        }
1185    }
1186
1187    registry.save()?;
1188    mark_reviewed();
1189    println!();
1190    if changed == 0 {
1191        output::print_success("Nothing changed — the defaults are in place.");
1192    } else {
1193        output::print_success(&format!(
1194            "Saved {changed} {}. `devp config show` lists them all.",
1195            output::plural(changed, "change", "changes")
1196        ));
1197    }
1198    Ok(())
1199}
1200
1201/// Marker recording that the settings have been put in front of the user once.
1202const REVIEW_MARKER: &str = "config-reviewed";
1203
1204/// Whether the walkthrough is owed: on a fresh install, or after an upgrade that added a
1205/// setting this machine has never been shown.
1206///
1207/// An upgrade does not re-ask about settings already confirmed — being made to reconfirm
1208/// `idle_days` every release is a nuisance, and a nuisance is something people learn to
1209/// dismiss without reading. It reopens only when something is genuinely new, and then
1210/// says which. A `devp uninstall --purge` removes the config directory and with it this
1211/// marker, which is what makes a real reinstall ask about everything again.
1212pub fn config_review_is_due() -> bool {
1213    let Ok(dir) = Registry::config_dir() else {
1214        return false;
1215    };
1216    if !dir.join(REVIEW_MARKER).exists() {
1217        return true;
1218    }
1219    !settings_added_since_review().is_empty()
1220}
1221
1222/// The release recorded the last time the settings were put in front of the user.
1223fn reviewed_version() -> Option<String> {
1224    let dir = Registry::config_dir().ok()?;
1225    let recorded = std::fs::read_to_string(dir.join(REVIEW_MARKER)).ok()?;
1226    let recorded = recorded.trim().to_string();
1227    (!recorded.is_empty()).then_some(recorded)
1228}
1229
1230/// The settings that did not exist the last time this machine was asked.
1231///
1232/// Derived from each setting's own `since` rather than from a hand-kept "new in this
1233/// version" list, because that list is one more thing to forget when adding a setting and
1234/// its failure mode is silent: a new default starts applying and nothing ever says so.
1235///
1236/// Empty when the marker is missing or unreadable — that is the fresh-install case, where
1237/// every setting is new and [`config_review_is_due`] has already said so.
1238pub fn settings_added_since_review() -> Vec<&'static str> {
1239    let Some(reviewed) = reviewed_version() else {
1240        return Vec::new();
1241    };
1242    SETTINGS
1243        .iter()
1244        .filter(|s| {
1245            crate::commands::update::compare_versions(s.since, &reviewed)
1246                == Some(std::cmp::Ordering::Greater)
1247        })
1248        .map(|s| s.key)
1249        .collect()
1250}
1251
1252fn mark_reviewed() {
1253    if let Ok(dir) = Registry::config_dir() {
1254        let _ = std::fs::create_dir_all(&dir);
1255        let _ = std::fs::write(dir.join(REVIEW_MARKER), crate::constants::VERSION);
1256    }
1257}
1258
1259/// Suppress the first-run walkthrough without running it.
1260///
1261/// For the paths that must not stop to ask: the Git hook, the scheduler, and anything
1262/// with no terminal attached.
1263pub fn skip_config_review() {
1264    mark_reviewed();
1265}
1266
1267/// Global audit pass for all registered repos.
1268pub fn run_global_update() -> Result<()> {
1269    output::print_header("dev-prune Global Configuration Audit & Sync");
1270
1271    let registry = Registry::load()?;
1272    let mut total_audited = 0;
1273    let mut errors_found = 0;
1274
1275    for repo_path in registry.repositories.keys() {
1276        let clean = output::clean_path(repo_path);
1277
1278        // A registered path that is gone — deleted, on an unplugged drive — is not a
1279        // config error, and writing a fresh `.devprune.json` at it would either fail or
1280        // conjure a directory where the repository used to be.
1281        if !repo_path.exists() {
1282            output::print_warning(&format!(
1283                "Skipped {clean} — the path no longer exists. `devp unlink --missing` \
1284                 clears such entries."
1285            ));
1286            continue;
1287        }
1288        total_audited += 1;
1289
1290        match PerRepoConfig::load_with_diagnostics(repo_path) {
1291            Ok(Some(cfg)) => {
1292                if let Err(e) = cfg.save_to_repo(repo_path) {
1293                    output::print_error(&format!("Failed to write config for {clean}: {e}"));
1294                    errors_found += 1;
1295                } else {
1296                    output::print_success(&format!("Audited & synced config for {clean}"));
1297                }
1298            }
1299            Ok(None) => {
1300                // No file means the global defaults apply, which is a valid state, not a
1301                // gap to fill. Writing one here would drop an untracked file into every
1302                // registered repository in a single command.
1303                output::print_info(&format!(
1304                    "{clean} has no .devprune.json — global defaults apply."
1305                ));
1306            }
1307            Err(err_msg) => {
1308                errors_found += 1;
1309                output::print_error(&format!("Syntax/Schema Error in {clean}:"));
1310                for line in err_msg.lines() {
1311                    eprintln!("    {line}");
1312                }
1313                output::print_info(&format!(
1314                    "Hint: fix the syntax by hand, or run `devp config {clean} --update` to \
1315                     replace the file with a valid default."
1316                ));
1317            }
1318        }
1319    }
1320
1321    if errors_found > 0 {
1322        // Non-zero, so a CI step or a shell `&&` chain notices. An audit that found
1323        // broken config files has not succeeded, however calmly it says so.
1324        anyhow::bail!(
1325            "Audit complete: {total_audited} repos checked, {errors_found} could not be read \
1326             or written."
1327        );
1328    }
1329    output::print_success(&format!(
1330        "Audit complete: All {total_audited} registered repositories are healthy & synced!"
1331    ));
1332
1333    Ok(())
1334}
1335
1336/// Inspect or create per-repository configuration (.devprune.json).
1337pub fn run_path_config(path_str: &str, force_update: bool) -> Result<()> {
1338    let raw_path = Path::new(path_str);
1339
1340    let path = if raw_path.exists() {
1341        raw_path
1342            .canonicalize()
1343            .unwrap_or_else(|_| raw_path.to_path_buf())
1344    } else {
1345        raw_path.to_path_buf()
1346    };
1347
1348    let clean = output::clean_path(&path);
1349
1350    if !path.exists() {
1351        bail!("Path does not exist: {clean}");
1352    }
1353
1354    if !crate::scanner::is_git_repo(&path) {
1355        // The old text said "Initializing Git repo first..." and then did no such thing.
1356        bail!(
1357            "`{clean}` is not a Git repository.\n  \
1358             Run `git init` there first, then `devp config {clean}` again."
1359        );
1360    }
1361
1362    let mut registry = Registry::load()?;
1363    if !registry.repositories.contains_key(&path) {
1364        output::print_info(&format!(
1365            "{clean} is not yet registered with dev-prune. Registering now..."
1366        ));
1367        registry.add_repo(path.clone());
1368        registry.save()?;
1369    }
1370
1371    let cfg_file = path.join(crate::constants::PER_REPO_CONFIG_FILE);
1372
1373    if cfg_file.exists() && !force_update {
1374        output::print_header(&format!("dev-prune Per-Repo Config for {clean}"));
1375        match PerRepoConfig::load_with_diagnostics(&path) {
1376            Ok(cfg) => {
1377                let json_str = serde_json::to_string_pretty(&cfg)?;
1378                println!("{json_str}");
1379                output::print_info("File location: .devprune.json");
1380            }
1381            Err(err_msg) => {
1382                output::print_error(&format!("Invalid configuration in {clean}:"));
1383                for line in err_msg.lines() {
1384                    eprintln!("    {line}");
1385                }
1386                // Non-zero: the file this command was asked to show could not be read,
1387                // and the same file is what every prune of this repo will trip over.
1388                anyhow::bail!(
1389                    "Run `devp config {clean} --update` to reset this file back to defaults \
1390                     (your current overrides in it are discarded)."
1391                );
1392            }
1393        }
1394    } else {
1395        output::print_info(&format!("Initializing .devprune.json for {clean}..."));
1396        let cfg = PerRepoConfig::default();
1397        cfg.save_to_repo(&path)?;
1398        output::print_success(&format!("Created .devprune.json in {clean}"));
1399    }
1400
1401    Ok(())
1402}
1403
1404/// Load a workspace's `.devprune.json` for a toggle that is about to write it back.
1405///
1406/// Refuses a file that does not parse, rather than starting from the defaults. Starting
1407/// from the defaults meant `devp config <repo> daemon off` wrote a fresh file straight
1408/// over the broken one, so a single typo cost the user every other override in it.
1409fn load_workspace_config_for_write(repo_path: &Path) -> Result<PerRepoConfig> {
1410    match PerRepoConfig::load_with_diagnostics(repo_path) {
1411        Ok(Some(cfg)) => Ok(cfg),
1412        Ok(None) => Ok(PerRepoConfig::default()),
1413        Err(e) => bail!(
1414            "{e}\n  \
1415             Fix that file, or run `devp config {} --update` to reset it back to defaults \
1416             (your current overrides in it are discarded).",
1417            output::clean_path(repo_path)
1418        ),
1419    }
1420}
1421
1422/// Toggle or status check for background daemon (global or local workspace).
1423pub fn run_daemon_toggle(path: Option<&str>, action: &str) -> Result<()> {
1424    if let Some(p) = path {
1425        let repo_path = resolve_workspace(p)?;
1426        let mut cfg = load_workspace_config_for_write(&repo_path)?;
1427        match parse_toggle(action)? {
1428            Toggle::Enable => {
1429                cfg.disable_daemon = false;
1430                cfg.save_to_repo(&repo_path)?;
1431                output::print_success(&format!(
1432                    "Enabled background daemon for workspace: {}",
1433                    output::clean_path(&repo_path)
1434                ));
1435            }
1436            Toggle::Disable => {
1437                cfg.disable_daemon = true;
1438                cfg.save_to_repo(&repo_path)?;
1439                output::print_success(&format!(
1440                    "Disabled background daemon for workspace: {}",
1441                    output::clean_path(&repo_path)
1442                ));
1443            }
1444            Toggle::Status => {
1445                let st = if cfg.disable_daemon {
1446                    "Disabled for workspace"
1447                } else {
1448                    "Enabled for workspace"
1449                };
1450                output::print_info(&format!(
1451                    "Daemon Status ({}): {}",
1452                    output::clean_path(&repo_path),
1453                    st
1454                ));
1455            }
1456        }
1457    } else {
1458        match parse_toggle(action)? {
1459            Toggle::Enable => crate::commands::daemon::run_install()?,
1460            Toggle::Disable => crate::commands::daemon::run_uninstall()?,
1461            Toggle::Status => crate::commands::daemon::run_status()?,
1462        }
1463    }
1464    Ok(())
1465}
1466
1467/// Toggle or status check for background Git hooks (global or local workspace).
1468pub fn run_hook_toggle(path: Option<&str>, action: &str, chain: bool) -> Result<()> {
1469    if let Some(p) = path {
1470        if chain {
1471            bail!(
1472                "`--chain` changes the single global `core.hooksPath`, so it has no \
1473                 per-workspace form. Drop the path: `devp hook install --chain`."
1474            );
1475        }
1476        let repo_path = resolve_workspace(p)?;
1477        let mut cfg = load_workspace_config_for_write(&repo_path)?;
1478        match parse_toggle(action)? {
1479            Toggle::Enable => {
1480                cfg.disable_hooks = false;
1481                cfg.save_to_repo(&repo_path)?;
1482                output::print_success(&format!(
1483                    "Enabled background Git hooks for workspace: {}",
1484                    output::clean_path(&repo_path)
1485                ));
1486            }
1487            Toggle::Disable => {
1488                cfg.disable_hooks = true;
1489                cfg.save_to_repo(&repo_path)?;
1490                output::print_success(&format!(
1491                    "Disabled background Git hooks for workspace: {}",
1492                    output::clean_path(&repo_path)
1493                ));
1494            }
1495            Toggle::Status => {
1496                let st = if cfg.disable_hooks {
1497                    "Disabled for workspace"
1498                } else {
1499                    "Enabled for workspace"
1500                };
1501                output::print_info(&format!(
1502                    "Git Hook Status ({}): {}",
1503                    output::clean_path(&repo_path),
1504                    st
1505                ));
1506            }
1507        }
1508    } else {
1509        match parse_toggle(action)? {
1510            Toggle::Enable => crate::commands::hook::run_install(chain)?,
1511            Toggle::Disable => crate::commands::hook::run_uninstall()?,
1512            Toggle::Status => crate::commands::hook::run_status()?,
1513        }
1514    }
1515    Ok(())
1516}
1517
1518#[cfg(test)]
1519mod tests {
1520    use super::*;
1521
1522    #[test]
1523    fn enable_synonyms_all_resolve_to_enable() {
1524        for word in ["enable", "install", "on", "INSTALL", "On"] {
1525            assert_eq!(parse_toggle(word).unwrap(), Toggle::Enable, "{word}");
1526        }
1527    }
1528
1529    #[test]
1530    fn disable_synonyms_all_resolve_to_disable() {
1531        for word in ["disable", "uninstall", "remove", "off", "Uninstall"] {
1532            assert_eq!(parse_toggle(word).unwrap(), Toggle::Disable, "{word}");
1533        }
1534    }
1535
1536    #[test]
1537    fn status_is_the_default_and_is_also_spellable() {
1538        for word in ["", "status", "show"] {
1539            assert_eq!(parse_toggle(word).unwrap(), Toggle::Status, "{word}");
1540        }
1541    }
1542
1543    #[test]
1544    fn a_typo_is_an_error_rather_than_a_silent_status_report() {
1545        // `devp config daemon enabel` must not print status and exit 0 — that reads as
1546        // success while the daemon stays uninstalled.
1547        let err = parse_toggle("enabel").unwrap_err().to_string();
1548        assert!(err.contains("enabel"), "{err}");
1549        assert!(err.contains("enable"), "{err}");
1550    }
1551
1552    #[test]
1553    fn a_workspace_toggle_refuses_to_write_over_a_broken_config() {
1554        // The toggle rewrites the whole file. Starting from the defaults on a file it
1555        // could not read would silently discard every override the user had put in it.
1556        let tmp = tempfile::TempDir::new().unwrap();
1557        let broken = r#"{ "project_name": "api", "override_idle_days": 90, }"#;
1558        std::fs::write(
1559            tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE),
1560            broken,
1561        )
1562        .unwrap();
1563
1564        let err = load_workspace_config_for_write(tmp.path())
1565            .unwrap_err()
1566            .to_string();
1567        assert!(err.contains("Syntax error"), "{err}");
1568        assert!(err.contains("--update"), "{err}");
1569
1570        // Untouched, so the user still has their 90 days to recover.
1571        let on_disk =
1572            std::fs::read_to_string(tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE))
1573                .unwrap();
1574        assert_eq!(on_disk, broken);
1575    }
1576
1577    #[test]
1578    fn a_workspace_with_no_config_yet_starts_from_the_defaults() {
1579        let tmp = tempfile::TempDir::new().unwrap();
1580        assert_eq!(
1581            load_workspace_config_for_write(tmp.path()).unwrap(),
1582            PerRepoConfig::default()
1583        );
1584    }
1585
1586    #[test]
1587    fn a_cache_cap_is_written_the_way_it_is_read_back() {
1588        let caps = parse_cache_caps("uv=10,npm=4").unwrap();
1589        assert_eq!(caps.get("uv"), Some(&10));
1590        assert_eq!(caps.get("npm"), Some(&4));
1591        // Sorted and normalised, so `config get` prints one spelling no matter which
1592        // order or casing the user typed.
1593        let settings = Settings {
1594            cache_max_gb: parse_cache_caps("UV = 10 , npm=4").unwrap(),
1595            ..Settings::default()
1596        };
1597        let printed = SETTINGS
1598            .iter()
1599            .find(|s| s.key == "cache_max_gb")
1600            .map(|s| (s.get)(&settings))
1601            .unwrap();
1602        assert_eq!(printed, "npm=4,uv=10");
1603        assert_eq!(parse_cache_caps(&printed).unwrap(), settings.cache_max_gb);
1604    }
1605
1606    #[test]
1607    fn clearing_the_caps_is_spelled_the_way_the_getter_prints_an_empty_map() {
1608        for blank in ["", "-", "none", "(none)", "NONE"] {
1609            assert!(
1610                parse_cache_caps(blank).unwrap().is_empty(),
1611                "`{blank}` should clear every cap"
1612            );
1613        }
1614    }
1615
1616    #[test]
1617    fn a_cap_on_something_that_is_not_a_cache_is_refused_with_the_list() {
1618        // `venv`, `terraform` and `dart` are adapters with no cache of their own, and
1619        // accepting a cap for one would store a setting nothing ever reads.
1620        let err = parse_cache_caps("venv=10").unwrap_err().to_string();
1621        assert!(err.contains("venv"), "{err}");
1622        assert!(err.contains("npm"), "the error lists what is valid: {err}");
1623    }
1624
1625    #[test]
1626    fn a_cap_has_to_be_a_whole_number_of_gibibytes() {
1627        for bad in ["uv=10.5", "uv=ten", "uv=-1", "uv="] {
1628            assert!(parse_cache_caps(bad).is_err(), "`{bad}` was accepted");
1629        }
1630        // A bare name is not a cap, and guessing a default for it would be a number the
1631        // user never chose.
1632        assert!(parse_cache_caps("uv").is_err());
1633    }
1634
1635    #[test]
1636    fn a_cap_of_zero_is_refused_rather_than_stored() {
1637        // Zero marks the cache over-size the moment it exists, which is almost always a
1638        // typo for clearing the cap.
1639        let err = parse_cache_caps("uv=0").unwrap_err().to_string();
1640        assert!(
1641            err.contains("`-`"),
1642            "the error names the way to clear it: {err}"
1643        );
1644    }
1645
1646    #[test]
1647    fn every_setting_round_trips_through_its_own_getter() {
1648        // The table is what `get`, `set`, `show` and the wizard all read, so a getter
1649        // that reports a different field than its setter writes would be invisible in
1650        // every one of them at once.
1651        let mut settings = Settings::default();
1652        for setting in SETTINGS {
1653            let before = (setting.get)(&settings);
1654            let probe = match setting.kind {
1655                Kind::Toggle => if before == "true" { "false" } else { "true" }.to_string(),
1656                // A number every numeric setting accepts: above every minimum, below
1657                // `scan_depth`'s ceiling.
1658                Kind::Number => "7".to_string(),
1659                // A real adapter name, so the round trip also proves the list prints
1660                // back in the spelling `config set` takes.
1661                Kind::Adapters => "cargo".to_string(),
1662                // Same, with a window attached: proves the map prints back in the
1663                // `name=days` spelling `config set` parses.
1664                Kind::AdapterDays => "cargo=45".to_string(),
1665                // A name that is a cache manager, which `cargo` also happens to be —
1666                // spelled out separately because the two lists are validated apart.
1667                Kind::CacheCaps => "cargo=10".to_string(),
1668            };
1669            (setting.set)(&mut settings, &probe)
1670                .unwrap_or_else(|e| panic!("{} rejected `{probe}`: {e}", setting.key));
1671            assert_eq!(
1672                (setting.get)(&settings),
1673                probe,
1674                "{} reads back a different field than it writes",
1675                setting.key
1676            );
1677        }
1678    }
1679
1680    #[test]
1681    fn every_setting_is_documented_and_uniquely_named() {
1682        let mut seen = std::collections::HashSet::new();
1683        for setting in SETTINGS {
1684            assert!(seen.insert(setting.key), "duplicate key {}", setting.key);
1685            assert!(!setting.help.is_empty(), "{} has no help", setting.key);
1686            assert!(
1687                !setting.plain.is_empty(),
1688                "{} has no plain text",
1689                setting.key
1690            );
1691            // The wizard prints both under the key; sentences keep that readable.
1692            assert!(
1693                setting.help.ends_with('.'),
1694                "{} help should read as a sentence",
1695                setting.key
1696            );
1697            assert!(
1698                setting.plain.ends_with('.'),
1699                "{} plain text should read as a sentence",
1700                setting.key
1701            );
1702            // Two ways of saying it, not the same way twice: a `plain` line that repeats
1703            // `help` costs a screen row and teaches nobody anything.
1704            assert_ne!(
1705                setting.plain, setting.help,
1706                "{} says the same thing twice",
1707                setting.key
1708            );
1709        }
1710    }
1711
1712    #[test]
1713    fn the_settings_table_covers_every_field_of_settings() {
1714        // Serialising `Settings` names every field, so a field added without a table
1715        // entry — unsettable, unshown, never asked about — fails here rather than in
1716        // a bug report.
1717        let json = serde_json::to_value(Settings::default()).unwrap();
1718        let fields: Vec<String> = json.as_object().unwrap().keys().cloned().collect();
1719        for field in fields {
1720            assert!(
1721                SETTINGS.iter().any(|s| s.key == field),
1722                "`{field}` is a setting with no entry in SETTINGS, so `devp config set \
1723                 {field}` cannot reach it"
1724            );
1725        }
1726    }
1727
1728    #[test]
1729    fn a_rejected_value_leaves_the_previous_one_in_place() {
1730        let mut settings = Settings::default();
1731        assert!((find_setting("scan_depth").unwrap().set)(&mut settings, "0").is_err());
1732        assert_eq!(settings.scan_depth, Settings::default().scan_depth);
1733
1734        assert!((find_setting("command_timeout_secs").unwrap().set)(&mut settings, "0").is_err());
1735        assert!((find_setting("check_interval_days").unwrap().set)(&mut settings, "0").is_err());
1736        assert!(
1737            (find_setting("update_check_interval_days").unwrap().set)(&mut settings, "0").is_err()
1738        );
1739    }
1740
1741    #[test]
1742    fn booleans_accept_the_words_people_actually_type() {
1743        assert!(parse_bool("k", "yes").unwrap());
1744        assert!(parse_bool("k", "ON").unwrap());
1745        assert!(!parse_bool("k", "0").unwrap());
1746        assert!(parse_bool("k", "maybe").is_err());
1747    }
1748
1749    #[test]
1750    fn an_unknown_key_lists_the_ones_that_exist() {
1751        let err = match find_setting("idel_days") {
1752            Ok(_) => panic!("`idel_days` is not a setting"),
1753            Err(e) => e.to_string(),
1754        };
1755        assert!(err.contains("idle_days"), "{err}");
1756    }
1757
1758    #[test]
1759    fn a_path_is_never_mistaken_for_an_action() {
1760        // The router uses this to decide whether a lone argument is a path or an action.
1761        assert!(!is_toggle_word("~/Code/my-repo"));
1762        assert!(!is_toggle_word("."));
1763        assert!(!is_toggle_word(""));
1764        assert!(is_toggle_word("install"));
1765    }
1766}