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::i18n;
14use crate::output;
15
16/// One tunable in the global config: how to read it, how to write it, and what to say
17/// about it.
18///
19/// A table rather than a `match` arm per operation. `get`, `set`, `show` and the
20/// first-run walkthrough all iterate this, so a setting cannot be added to one of them
21/// and quietly forgotten in the other three — which is how `min_size_mb` shipped with no
22/// line in `config show`.
23struct Setting {
24    key: &'static str,
25    /// Which group of the configurator this setting is asked about under.
26    ///
27    /// Display order is derived from this rather than from the order of the literal
28    /// below, so a new setting is filed by what it does instead of by where there
29    /// happened to be room for it.
30    category: Category,
31    /// The release this key first appeared in.
32    ///
33    /// Not decoration: the first-run marker records the version it was written at, so
34    /// comparing the two is how an upgrade knows which settings the user has never been
35    /// shown — without keeping a second list of "new in this version" to forget to
36    /// update. See [`settings_added_since_review`].
37    since: &'static str,
38    /// What kind of value this is, so a picker can offer the right control.
39    kind: Kind,
40    /// One line, shown by the walkthrough and by `config show --help-text`.
41    ///
42    /// Written for someone who already knows what a lockfile and a build tree are.
43    help: &'static str,
44    /// The same setting explained to someone who does not.
45    ///
46    /// Not a second `help` with shorter words: `help` says what the setting *is*, this
47    /// says what happens to you if it is on, in the second person, with no jargon and no
48    /// flag names. Both are shown together — nobody should have to be the right kind of
49    /// expert to answer a question this tool asked them.
50    plain: &'static str,
51    get: fn(&Settings) -> String,
52    set: fn(&mut Settings, &str) -> Result<()>,
53}
54
55/// Which part of the configurator a setting belongs to.
56///
57/// Thirty keys in one column is a list nobody reads to the end of. The order of
58/// [`CATEGORIES`] is the order the groups are drawn in, and it is the order the
59/// decisions actually arrive in: first the language the rest of the screen is printed
60/// in, then what is in scope, what has to be proved before a delete, the build trees
61/// that stay off until they are asked for, the shared caches nothing deletes on its
62/// own, and only then the two groups about dev-prune running itself.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64enum Category {
65    /// The language dev-prune's own headings and summaries are printed in.
66    ///
67    /// First because every heading under it is printed in whatever this says, which
68    /// makes it the one answer that changes how the rest of the screen reads.
69    Presentation,
70    /// Which repositories, and which directories inside them, are eligible at all.
71    Scope,
72    /// What has to hold before anything is deleted, and what verification may do.
73    Safety,
74    /// The opt-in adapters, whose directories come back by recompiling rather than
75    /// by downloading.
76    BuildTrees,
77    /// Machine-wide download caches: reported on, never deleted unasked.
78    Caches,
79    /// What happens when nobody typed anything.
80    Unattended,
81    /// Keeping this copy of dev-prune current.
82    Updates,
83}
84
85impl Category {
86    /// The heading drawn above the group, in `devp config show` and in the
87    /// configurator. Written as the question the group answers, not as a noun: a
88    /// heading that says "Caches" tells you nothing you could not read off the keys.
89    ///
90    /// The English wording lives in `src/i18n/locales/en.json` with the rest of the
91    /// chrome, so translating a heading never means touching Rust.
92    fn title(self) -> &'static str {
93        match self {
94            Category::Presentation => i18n::t("config.category.presentation"),
95            Category::Scope => i18n::t("config.category.scope"),
96            Category::Safety => i18n::t("config.category.safety"),
97            Category::BuildTrees => i18n::t("config.category.build_trees"),
98            Category::Caches => i18n::t("config.category.caches"),
99            Category::Unattended => i18n::t("config.category.unattended"),
100            Category::Updates => i18n::t("config.category.updates"),
101        }
102    }
103}
104
105/// The groups in the order they are drawn.
106const CATEGORIES: &[Category] = &[
107    Category::Presentation,
108    Category::Scope,
109    Category::Safety,
110    Category::BuildTrees,
111    Category::Caches,
112    Category::Unattended,
113    Category::Updates,
114];
115
116/// Every setting, grouped, in display order.
117///
118/// The single place that decides what order settings are shown in, so `config show`
119/// and the configurator cannot drift into two different orders. Within a group the
120/// order of [`SETTINGS`] is kept.
121fn settings_by_category() -> Vec<(Category, Vec<&'static Setting>)> {
122    CATEGORIES
123        .iter()
124        .map(|&category| {
125            (
126                category,
127                SETTINGS.iter().filter(|s| s.category == category).collect(),
128            )
129        })
130        .collect()
131}
132
133/// How a setting should be *asked* about, as opposed to how it is stored.
134///
135/// Every value round-trips through `get`/`set` as a string either way — this only
136/// decides whether the configurator offers a toggle, a number to type, or the adapter
137/// checklist. Validation stays in the setters, which are the one place that owns it.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139enum Kind {
140    /// `true` or `false`.
141    Toggle,
142    /// A whole number, bounded by whatever its own setter enforces.
143    Number,
144    /// A comma-separated list of adapter names.
145    Adapters,
146    /// Cache manager names with a number each, as `npm=10,uv=10`.
147    ///
148    /// The third column of the same checklist [`Kind::AdapterDays`] is the second of:
149    /// which ecosystems run, how long each waits, and how big each one's cache may get
150    /// are one table, not three screens.
151    CacheCaps,
152    /// One of a fixed set of values, cycled in place.
153    ///
154    /// Which values is supplied when the row is built rather than stored here: the only
155    /// thing that knows what the options are is the module that owns them.
156    Choice,
157    /// Adapter names with a number each, as `cargo=60,npm=30`.
158    ///
159    /// Edited on the same screen as [`Kind::Adapters`] rather than in a field of its
160    /// own: which adapters run and how long each waits are one decision made twice,
161    /// and splitting them across two rows is how someone switches an adapter on and
162    /// never finds the dial that would have made it safe.
163    AdapterDays,
164}
165
166/// One first-run suggestion: a setting worth turning on, and the reason.
167///
168/// A table of its own rather than a field on [`Setting`], because a suggestion is not a
169/// property of a setting — it is a claim about what most people should do on the day
170/// they install this, and the two lists move for different reasons.
171struct Recommendation {
172    key: &'static str,
173    /// Three or four words naming what accepting it turns on.
174    label: &'static str,
175    /// Why it is suggested — the part `help` and `plain` both leave out.
176    why: &'static str,
177    /// The value accepting it sets. A string, not a `bool`, so a suggested *number*
178    /// needs no new machinery here or in the view.
179    value: &'static str,
180    /// The second tier: recommended, with one specific thing to understand first.
181    cautious: bool,
182}
183
184/// The safe tier, by the name every command that prints it uses.
185///
186/// Named once, here, because the configurator, `devp config show` and
187/// `devp config recommended` all print these two lists — and a tier that is called
188/// something different in each of the three is three lists as far as the reader is
189/// concerned.
190const SAFE_TIER: &str = "Recommended";
191
192/// The second tier: still recommended, still not risky, but with one specific
193/// consequence to understand before accepting it.
194const CAUTIOUS_TIER: &str = "Recommended, with one thing to know first";
195
196/// What the first run suggests turning on.
197///
198/// Every entry is off by default and stays off unless the person accepts it, which is
199/// the only reason a screen suggesting them is honest. Nothing already on by default
200/// belongs here: a checkbox that is already ticked before you arrive teaches people to
201/// tick boxes.
202const RECOMMENDED: &[Recommendation] = &[
203    Recommendation {
204        key: "enable_cargo",
205        label: "Rust build folders",
206        why: "Rust `target/` directories are usually the largest thing on a developer's disk — \
207              tens of gigabytes across a handful of old projects. Nothing is lost: `cargo build` \
208              rebuilds it, and a project has to sit untouched for 45 days before this one is even \
209              considered.",
210        value: "true",
211        cautious: false,
212    },
213    Recommendation {
214        key: "enable_gradle",
215        label: "Android / Gradle builds",
216        why: "`build/` and `.gradle/` grow with every Android build and are never cleaned up by \
217              anything else. They come back on the next build, under the same 45-day wait.",
218        value: "true",
219        cautious: false,
220    },
221    Recommendation {
222        key: "enable_maven",
223        label: "Maven builds",
224        why: "Maven `target/` directories accumulate quietly per module, so a multi-module project \
225              has several. `mvn package` brings them back.",
226        value: "true",
227        cautious: false,
228    },
229    Recommendation {
230        key: "enable_swift",
231        label: "Swift builds",
232        why: "`.build/` holds compiled modules for every configuration you have ever built, and \
233              `swift build` recreates the one you actually use.",
234        value: "true",
235        cautious: false,
236    },
237    Recommendation {
238        key: "enable_dart",
239        label: "Dart / Flutter caches",
240        why: "`.dart_tool/` carries the pub metadata — back in a second — alongside `build_runner` \
241              and `flutter_build` caches that are worth real disk space.",
242        value: "true",
243        cautious: false,
244    },
245    Recommendation {
246        key: "enable_mix_build",
247        label: "Elixir build trees",
248        why: "`_build/` holds compiled beam files for every Mix environment you have built, and \
249              `mix compile` recreates the one you are working in.",
250        value: "true",
251        cautious: false,
252    },
253    Recommendation {
254        key: "enable_vcpkg",
255        label: "C / C++ vcpkg trees",
256        why: "`vcpkg_installed/` holds libraries vcpkg compiled from source for one \
257              project, and `vcpkg install` builds them again from the manifest beside \
258              them.",
259        value: "true",
260        cautious: false,
261    },
262    Recommendation {
263        key: "enable_cmake_build",
264        label: "C / C++ CMake build trees",
265        why: "A configured CMake build tree is object files and linked binaries, and \
266              `cmake` writes a `CMakeCache.txt` at the top of it that says which sources \
267              build it again — so a `build/` you made by hand is left alone.",
268        value: "true",
269        cautious: false,
270    },
271    Recommendation {
272        key: "allow_manifest_rewrite",
273        label: "Let cargo and go tidy up",
274        why: "Cautious, not risky. The commands that restore a Rust or Go project can also update \
275              `Cargo.lock` or `go.mod` — files Git tracks. Nothing is lost and nothing is deleted, \
276              but the next `git status` may show a change you did not make by hand. Turn it on if \
277              that is fine; leave it off if a clean working tree matters more than a fully \
278              automatic restore.",
279        value: "true",
280        cautious: true,
281    },
282];
283
284/// Every global setting, in the order a person would want to be asked about them.
285const SETTINGS: &[Setting] = &[
286    Setting {
287        key: "language",
288        category: Category::Presentation,
289        since: "1.10.0",
290        kind: Kind::Choice,
291        help: "Language for dev-prune's own headings and summary lines. `--json`, exit codes, flag names and config keys stay English in every language.",
292        plain: "What language dev-prune talks to you in. Only its own headings change — the words you type and anything a script reads stay in English, so nothing breaks. Everything but English is a community translation, and some have not been proofread yet.",
293        get: |s| s.language.clone(),
294        set: |s, v| {
295            let code = v.trim();
296            let Some(meta) = i18n::language(code) else {
297                bail!(
298                    "unknown language `{code}` — available: {}",
299                    i18n::catalogue_line()
300                );
301            };
302            s.language = meta.code.clone();
303            Ok(())
304        },
305    },
306    Setting {
307        key: "idle_days",
308        category: Category::Scope,
309        since: "1.0.0",
310        kind: Kind::Number,
311        help: "Days a repository must sit untouched before it is eligible for pruning.",
312        plain: "How long a project has to sit untouched before dev-prune will clean it. Something you worked on yesterday is never touched.",
313        get: |s| s.idle_days.to_string(),
314        set: |s, v| {
315            s.idle_days = v
316                .parse()
317                .map_err(|_| anyhow::anyhow!("idle_days must be a whole number of days"))?;
318            Ok(())
319        },
320    },
321    Setting {
322        key: "min_size_mb",
323        category: Category::Scope,
324        since: "1.0.0",
325        kind: Kind::Number,
326        help: "Smallest bloat directory worth deleting, in MiB. 0 removes the floor.",
327        plain: "Ignore small folders. Deleting a 2 MB folder is not worth the download to get it back.",
328        get: |s| s.min_size_mb.to_string(),
329        set: |s, v| {
330            s.min_size_mb = v.parse().map_err(|_| {
331                anyhow::anyhow!("min_size_mb must be a whole number of MiB (0 disables the floor)")
332            })?;
333            Ok(())
334        },
335    },
336    Setting {
337        key: "scan_depth",
338        category: Category::Scope,
339        since: "1.0.0",
340        kind: Kind::Number,
341        help: "How many directory levels below a repo root project discovery descends.",
342        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.",
343        get: |s| s.scan_depth.to_string(),
344        set: |s, v| {
345            let depth: usize = v
346                .parse()
347                .map_err(|_| anyhow::anyhow!("scan_depth must be a positive integer"))?;
348            // Rejected rather than clamped. `clamp_depth` exists so a hand-edited config
349            // file cannot break the walk, but when someone types the number at us we owe
350            // them the truth instead of silently storing something else.
351            if depth == 0 {
352                bail!("scan_depth must be at least 1 — 0 would find no projects at all.");
353            }
354            if depth > crate::constants::MAX_SCAN_DEPTH_LIMIT {
355                bail!(
356                    "scan_depth must be at most {} — deeper walks stall on generated trees.",
357                    crate::constants::MAX_SCAN_DEPTH_LIMIT
358                );
359            }
360            s.scan_depth = depth;
361            Ok(())
362        },
363    },
364    Setting {
365        key: "require_confirmation",
366        category: Category::Safety,
367        since: "1.0.0",
368        kind: Kind::Toggle,
369        help: "Ask before deleting anything. Turning this off makes every run unattended.",
370        plain: "Whether dev-prune asks \"delete these?\" before it deletes. Leave this on unless you want it to run silently while you are away.",
371        get: |s| s.require_confirmation.to_string(),
372        set: |s, v| {
373            s.require_confirmation = parse_bool("require_confirmation", v)?;
374            Ok(())
375        },
376    },
377    Setting {
378        key: "allow_manifest_rewrite",
379        category: Category::Safety,
380        since: "1.0.0",
381        kind: Kind::Toggle,
382        help: "Let cargo and go run the sync command that rewrites tracked manifests.",
383        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`.",
384        get: |s| s.allow_manifest_rewrite.to_string(),
385        set: |s, v| {
386            s.allow_manifest_rewrite = parse_bool("allow_manifest_rewrite", v)?;
387            Ok(())
388        },
389    },
390    Setting {
391        key: "command_timeout_secs",
392        category: Category::Safety,
393        since: "1.0.0",
394        kind: Kind::Number,
395        help: "How long one package-manager command may run before it is killed — the lockfile check and `devp restore`, never a recompile.",
396        plain: "How long to wait for a package manager to answer before giving up on it: the lockfile check before a delete, and the reinstall `devp restore` runs. Nothing is compiled under it — the opt-in build adapters run no command at all during a prune — except a restore whose install builds a native module. Raise it on a slow connection.",
397        get: |s| s.command_timeout_secs.to_string(),
398        set: |s, v| {
399            let secs: u64 = v
400                .parse()
401                .map_err(|_| anyhow::anyhow!("command_timeout_secs must be a positive integer"))?;
402            // Zero is not "no limit": the runner compares elapsed time against it before
403            // the child has had a chance to finish, so every lockfile sync would be
404            // killed on the spot and nothing would ever be pruneable.
405            if secs == 0 {
406                bail!(
407                    "command_timeout_secs must be at least 1 — 0 would kill every command \
408                     the instant it starts."
409                );
410            }
411            s.command_timeout_secs = secs;
412            Ok(())
413        },
414    },
415    Setting {
416        key: "auto_setup",
417        category: Category::Unattended,
418        since: "1.0.0",
419        kind: Kind::Toggle,
420        help: "Install missing integrations by itself, once per installed version.",
421        plain: "Whether dev-prune finishes setting itself up on its own instead of making you run `devp setup`.",
422        get: |s| s.auto_setup.to_string(),
423        set: |s, v| {
424            s.auto_setup = parse_bool("auto_setup", v)?;
425            Ok(())
426        },
427    },
428    Setting {
429        key: "auto_config",
430        category: Category::Unattended,
431        since: "1.3.0",
432        kind: Kind::Toggle,
433        help: "Write a default .devprune.json into repositories that link/init register.",
434        plain: "Drops a small settings file into each repository you register, so you can give that one project different rules later.",
435        get: |s| s.auto_config.to_string(),
436        set: |s, v| {
437            s.auto_config = parse_bool("auto_config", v)?;
438            Ok(())
439        },
440    },
441    Setting {
442        key: "auto_daemon",
443        category: Category::Unattended,
444        since: "1.0.0",
445        kind: Kind::Toggle,
446        help: "Register the OS scheduler so passes run without being remembered.",
447        plain: "Lets your operating system run dev-prune on a schedule, so you never have to remember to.",
448        get: |s| s.auto_daemon.to_string(),
449        set: |s, v| {
450            s.auto_daemon = parse_bool("auto_daemon", v)?;
451            Ok(())
452        },
453    },
454    Setting {
455        key: "check_interval_days",
456        category: Category::Unattended,
457        since: "1.0.0",
458        kind: Kind::Number,
459        help: "Days between scheduled background passes.",
460        plain: "How often that scheduled cleanup runs.",
461        get: |s| s.check_interval_days.to_string(),
462        set: |s, v| {
463            let days: u64 = v
464                .parse()
465                .map_err(|_| anyhow::anyhow!("check_interval_days must be a positive integer"))?;
466            // Zero would schedule a prune pass with no gap between passes.
467            if days == 0 {
468                bail!("check_interval_days must be at least 1.");
469            }
470            s.check_interval_days = days;
471            Ok(())
472        },
473    },
474    Setting {
475        key: "auto_discover",
476        category: Category::Unattended,
477        since: "1.14.0",
478        kind: Kind::Toggle,
479        help: "Let the scheduled pass find and register repositories you never added.",
480        plain: "Finds the rest of your projects on its own, so nothing is missed just because you never added it.",
481        get: |s| s.auto_discover.to_string(),
482        set: |s, v| {
483            s.auto_discover = parse_bool("auto_discover", v)?;
484            Ok(())
485        },
486    },
487    Setting {
488        key: "auto_hooks",
489        category: Category::Unattended,
490        since: "1.0.0",
491        kind: Kind::Toggle,
492        help: "Install the Git hooks that register repositories as you clone them.",
493        plain: "Registers new repositories automatically as you clone them, so you never have to add them by hand.",
494        get: |s| s.auto_hooks.to_string(),
495        set: |s, v| {
496            s.auto_hooks = parse_bool("auto_hooks", v)?;
497            Ok(())
498        },
499    },
500    Setting {
501        key: "auto_hooks_chain",
502        category: Category::Unattended,
503        since: "1.0.0",
504        kind: Kind::Toggle,
505        help: "If another tool owns core.hooksPath, install in front of it and forward. Off by default: that slot is machine-wide and already someone else's.",
506        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. Off by default because the slot is global to your machine and dev-prune would be taking over another tool's setup to use it. `devp doctor` names the command when it finds one of those tools holding it.",
507        get: |s| s.auto_hooks_chain.to_string(),
508        set: |s, v| {
509            s.auto_hooks_chain = parse_bool("auto_hooks_chain", v)?;
510            Ok(())
511        },
512    },
513    Setting {
514        key: "update_check",
515        category: Category::Updates,
516        since: "1.0.0",
517        kind: Kind::Toggle,
518        help: "Ask GitHub for the latest release from time to time. Sends nothing but the request.",
519        plain: "Whether dev-prune checks GitHub now and then to see if there is a newer version. It sends no information about you.",
520        get: |s| s.update_check.to_string(),
521        set: |s, v| {
522            s.update_check = parse_bool("update_check", v)?;
523            Ok(())
524        },
525    },
526    Setting {
527        key: "update_check_interval_days",
528        category: Category::Updates,
529        since: "1.0.0",
530        kind: Kind::Number,
531        help: "Days between automatic release checks.",
532        plain: "How often that version check happens.",
533        get: |s| s.update_check_interval_days.to_string(),
534        set: |s, v| {
535            let days: i64 = v.parse().map_err(|_| {
536                anyhow::anyhow!("update_check_interval_days must be a positive integer")
537            })?;
538            if days < 1 {
539                bail!("update_check_interval_days must be at least 1.");
540            }
541            s.update_check_interval_days = days;
542            Ok(())
543        },
544    },
545    Setting {
546        key: "update_check_timeout_secs",
547        category: Category::Updates,
548        since: "1.0.0",
549        kind: Kind::Number,
550        help: "Seconds the release check waits for GitHub. Raise it behind a slow proxy.",
551        plain: "How long the version check waits before giving up. Raise it if you are behind a slow proxy.",
552        get: |s| s.update_check_timeout_secs.to_string(),
553        set: |s, v| {
554            let secs: u64 = v.parse().map_err(|_| {
555                anyhow::anyhow!("update_check_timeout_secs must be a positive integer")
556            })?;
557            if secs == 0 {
558                bail!("update_check_timeout_secs must be at least 1.");
559            }
560            s.update_check_timeout_secs = secs;
561            Ok(())
562        },
563    },
564    Setting {
565        key: "enable_cargo",
566        category: Category::BuildTrees,
567        since: "1.5.0",
568        kind: Kind::Toggle,
569        help: "Turn on the opt-in Cargo adapter (target/ comes back by recompiling, not downloading).",
570        plain: "Clean Rust build folders too. These come back by recompiling, which takes minutes rather than a download — so it is off by default.",
571        get: |s| s.enable_cargo.to_string(),
572        set: |s, v| {
573            s.enable_cargo = parse_bool("enable_cargo", v)?;
574            Ok(())
575        },
576    },
577    Setting {
578        key: "enable_gradle",
579        category: Category::BuildTrees,
580        since: "1.3.0",
581        kind: Kind::Toggle,
582        help: "Turn on the opt-in Gradle adapter (build/ and .gradle/ come back by recompiling).",
583        plain: "Clean Android and Java build folders too. Same trade: they come back by recompiling, not downloading.",
584        get: |s| s.enable_gradle.to_string(),
585        set: |s, v| {
586            s.enable_gradle = parse_bool("enable_gradle", v)?;
587            Ok(())
588        },
589    },
590    Setting {
591        key: "enable_maven",
592        category: Category::BuildTrees,
593        since: "1.3.0",
594        kind: Kind::Toggle,
595        help: "Turn on the opt-in Maven adapter (target/ comes back by recompiling).",
596        plain: "Clean Maven build folders too. They come back by recompiling.",
597        get: |s| s.enable_maven.to_string(),
598        set: |s, v| {
599            s.enable_maven = parse_bool("enable_maven", v)?;
600            Ok(())
601        },
602    },
603    Setting {
604        key: "enable_swift",
605        category: Category::BuildTrees,
606        since: "1.4.0",
607        kind: Kind::Toggle,
608        help: "Turn on the opt-in SwiftPM adapter (.build/ comes back by recompiling).",
609        plain: "Clean Swift build folders too. They come back by recompiling.",
610        get: |s| s.enable_swift.to_string(),
611        set: |s, v| {
612            s.enable_swift = parse_bool("enable_swift", v)?;
613            Ok(())
614        },
615    },
616    Setting {
617        key: "enable_dart",
618        category: Category::BuildTrees,
619        since: "1.6.0",
620        kind: Kind::Toggle,
621        help: "Turn on the opt-in Dart/Flutter adapter (.dart_tool/ holds build caches).",
622        plain: "Clean Dart and Flutter caches too. Part comes back instantly, part by recompiling.",
623        get: |s| s.enable_dart.to_string(),
624        set: |s, v| {
625            s.enable_dart = parse_bool("enable_dart", v)?;
626            Ok(())
627        },
628    },
629    Setting {
630        key: "enable_mix_build",
631        category: Category::BuildTrees,
632        since: "1.7.0",
633        kind: Kind::Toggle,
634        help: "Turn on the opt-in Elixir Mix build-tree adapter (_build/ comes back by recompiling).",
635        plain: "Elixir projects only. Mix is Elixir's build tool, and it compiles your project and every dependency into `_build/` — this cleans that folder. The downloaded `deps/` folder beside it belongs to a different adapter that is already on. Off by default, because `_build/` comes back by recompiling rather than by downloading.",
636        get: |s| s.enable_mix_build.to_string(),
637        set: |s, v| {
638            s.enable_mix_build = parse_bool("enable_mix_build", v)?;
639            Ok(())
640        },
641    },
642    Setting {
643        key: "enable_vcpkg",
644        category: Category::BuildTrees,
645        since: "1.8.0",
646        kind: Kind::Toggle,
647        help: "Turn on the opt-in vcpkg adapter (vcpkg_installed/ comes back by recompiling).",
648        plain: "Clean C and C++ vcpkg_installed/ folders too. They come back by recompiling.",
649        get: |s| s.enable_vcpkg.to_string(),
650        set: |s, v| {
651            s.enable_vcpkg = parse_bool("enable_vcpkg", v)?;
652            Ok(())
653        },
654    },
655    Setting {
656        key: "enable_cmake_build",
657        category: Category::BuildTrees,
658        since: "1.8.0",
659        kind: Kind::Toggle,
660        help: "Turn on the opt-in CMake adapter (build trees proven by their CMakeCache.txt).",
661        plain: "Clean C and C++ build folders CMake configured. A `build/` you made by hand is \
662                never touched.",
663        get: |s| s.enable_cmake_build.to_string(),
664        set: |s, v| {
665            s.enable_cmake_build = parse_bool("enable_cmake_build", v)?;
666            Ok(())
667        },
668    },
669    Setting {
670        key: "build_idle_days",
671        category: Category::BuildTrees,
672        since: "1.3.0",
673        kind: Kind::Number,
674        help: "Idle days before the opt-in adapters' build trees are pruned. Applied as max(this, idle_days).",
675        plain: "A longer wait, used only for the build folders above, because getting those back costs a recompile rather than a download.",
676        get: |s| s.build_idle_days.to_string(),
677        set: |s, v| {
678            let days: u64 = v
679                .parse()
680                .map_err(|_| anyhow::anyhow!("build_idle_days must be a non-negative integer"))?;
681            s.build_idle_days = days;
682            Ok(())
683        },
684    },
685    Setting {
686        key: "auto_update",
687        category: Category::Updates,
688        since: "1.3.0",
689        kind: Kind::Toggle,
690        help: "Install a newer release by itself at the end of a prune pass. On by default.",
691        plain: "Whether dev-prune installs its own updates after a cleanup. The download is checked against its published fingerprint first.",
692        get: |s| s.auto_update.to_string(),
693        set: |s, v| {
694            s.auto_update = parse_bool("auto_update", v)?;
695            Ok(())
696        },
697    },
698    Setting {
699        key: "version_lock",
700        category: Category::Updates,
701        since: "1.8.0",
702        kind: Kind::Toggle,
703        help: "Pin this copy to the version it is. Overrides auto_update, `devp update \
704                --install`, `devp install --channel` and the install scripts.",
705        plain: "Stay on exactly this version. Nothing dev-prune does replaces the binary \
706                while this is on -- not the automatic update, not a re-run of the install \
707                one-liner.",
708        get: |s| s.version_lock.to_string(),
709        set: |s, v| {
710            s.version_lock = parse_bool("version_lock", v)?;
711            Ok(())
712        },
713    },
714    Setting {
715        key: "disabled_adapters",
716        category: Category::Scope,
717        since: "1.4.0",
718        kind: Kind::Adapters,
719        help: "Adapters to leave alone entirely, by name. Empty means every one of them is active.",
720        plain: "Ecosystems to ignore completely — as if you did not have them installed at all.",
721        get: |s| {
722            if s.disabled_adapters.is_empty() {
723                "(none)".to_string()
724            } else {
725                s.disabled_adapters.join(",")
726            }
727        },
728        set: |s, v| {
729            s.disabled_adapters = parse_adapter_list(v)?;
730            Ok(())
731        },
732    },
733    Setting {
734        key: "adapter_idle_days",
735        category: Category::Scope,
736        since: "1.5.0",
737        kind: Kind::AdapterDays,
738        help: "Per-adapter idle windows, as `cargo=60,npm=30`. Each one can only raise its own wait.",
739        plain: "A different waiting period for one ecosystem. Useful when your Rust projects should wait longer than your Node ones.",
740        get: |s| {
741            if s.adapter_idle_days.is_empty() {
742                "(none)".to_string()
743            } else {
744                s.adapter_idle_days
745                    .iter()
746                    .map(|(name, days)| format!("{name}={days}"))
747                    .collect::<Vec<_>>()
748                    .join(",")
749            }
750        },
751        set: |s, v| {
752            s.adapter_idle_days = parse_adapter_days(v)?;
753            Ok(())
754        },
755    },
756    Setting {
757        key: "cache_max_gb",
758        category: Category::Caches,
759        since: "1.8.0",
760        kind: Kind::CacheCaps,
761        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`.",
762        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.",
763        get: |s| {
764            if s.cache_max_gb.is_empty() {
765                "(none)".to_string()
766            } else {
767                s.cache_max_gb
768                    .iter()
769                    .map(|(name, gb)| format!("{name}={gb}"))
770                    .collect::<Vec<_>>()
771                    .join(",")
772            }
773        },
774        set: |s, v| {
775            s.cache_max_gb = parse_cache_caps(v)?;
776            Ok(())
777        },
778    },
779];
780
781/// Parse the comma-separated adapter deny-list, rejecting names that do not exist.
782///
783/// An unknown name is an error listing the valid ones rather than a no-op, for the same
784/// reason `--only nmp` is: a silently ignored typo reads as "npm is protected" right up
785/// until the pass that deletes `node_modules`.
786/// Parse `npm=10,uv=10` into the per-manager cache cap map.
787///
788/// Validated against the cache manager names `devp caches clear` takes, not the adapter
789/// names [`parse_adapter_days`] uses. The two lists overlap but neither contains the
790/// other — `pip`, `nuget`, `conan`, `conda`, `vcpkg` and `hex` are caches with no
791/// adapter, and `venv`, `terraform` and `dart` are adapters with no cache — so
792/// accepting an adapter name here would store a cap that nothing ever reads.
793///
794/// Zero is rejected rather than treated as "cap everything": a cache is over a cap of
795/// zero the moment it exists, and a setting whose only effect is to mark every cache
796/// permanently over-size is a typo for `-` every time.
797fn parse_cache_caps(value: &str) -> Result<std::collections::BTreeMap<String, u64>> {
798    let trimmed = value.trim();
799    if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
800        return Ok(std::collections::BTreeMap::new());
801    }
802
803    let mut caps = std::collections::BTreeMap::new();
804    for raw in trimmed.split(',') {
805        let entry = raw.trim();
806        if entry.is_empty() {
807            continue;
808        }
809        let Some((name, value)) = entry.split_once('=') else {
810            bail!("`{entry}` must be written as `<manager>=<gib>`, for example `uv=10`.");
811        };
812        let name = name.trim().to_lowercase();
813        if !crate::commands::caches::is_cache_manager(&name) {
814            bail!(
815                "`{name}` is not a manager dev-prune knows a cache for. Valid names: {}",
816                crate::commands::caches::known_managers().join(", ")
817            );
818        }
819        let parsed: u64 = value.trim().parse().map_err(|_| {
820            anyhow::anyhow!(
821                "`{name}` needs a whole number of gibibytes, not `{}`.",
822                value.trim()
823            )
824        })?;
825        if parsed == 0 {
826            bail!(
827                "`{name}=0` would call the cache too big the moment it exists. Use `-` to clear the caps instead."
828            );
829        }
830        caps.insert(name, parsed);
831    }
832    Ok(caps)
833}
834
835/// Parse `cargo=60,npm=30` into the per-adapter idle map.
836///
837/// Same "clear it" spellings as [`parse_adapter_list`], and the same closed loop: what
838/// `config get adapter_idle_days` prints is accepted verbatim by `config set`.
839fn parse_adapter_days(value: &str) -> Result<std::collections::BTreeMap<String, u64>> {
840    let trimmed = value.trim();
841    if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
842        return Ok(std::collections::BTreeMap::new());
843    }
844
845    let mut days = std::collections::BTreeMap::new();
846    for raw in trimmed.split(',') {
847        let entry = raw.trim();
848        if entry.is_empty() {
849            continue;
850        }
851        let Some((name, value)) = entry.split_once('=') else {
852            bail!("`{entry}` must be written as `<adapter>=<days>`, for example `cargo=60`.");
853        };
854        let name = name.trim().to_lowercase();
855        if !crate::adapters::is_adapter_name(&name) {
856            bail!(
857                "`{name}` is not an adapter. Valid names: {}",
858                crate::adapters::all_adapter_names().join(", ")
859            );
860        }
861        let parsed: u64 = value.trim().parse().map_err(|_| {
862            anyhow::anyhow!(
863                "`{name}` needs a whole number of days, not `{}`.",
864                value.trim()
865            )
866        })?;
867        days.insert(name, parsed);
868    }
869    Ok(days)
870}
871
872fn parse_adapter_list(value: &str) -> Result<Vec<String>> {
873    let trimmed = value.trim();
874    // The spellings that mean "clear it". `(none)` closes the loop with the getter, so
875    // whatever `config get` prints can be handed straight back to `config set`.
876    if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
877        return Ok(Vec::new());
878    }
879
880    let mut names: Vec<String> = Vec::new();
881    for raw in trimmed.split(',') {
882        let name = raw.trim().to_lowercase();
883        if name.is_empty() {
884            continue;
885        }
886        if !crate::adapters::is_adapter_name(&name) {
887            bail!(
888                "`{name}` is not an adapter. Valid names: {}",
889                crate::adapters::all_adapter_names().join(", ")
890            );
891        }
892        if !names.contains(&name) {
893            names.push(name);
894        }
895    }
896    Ok(names)
897}
898
899fn parse_bool(key: &str, value: &str) -> Result<bool> {
900    match value.trim().to_lowercase().as_str() {
901        "true" | "yes" | "y" | "on" | "1" => Ok(true),
902        "false" | "no" | "n" | "off" | "0" => Ok(false),
903        _ => bail!("{key} must be true or false"),
904    }
905}
906
907/// Every stored setting that its own setter would refuse, with the reason.
908///
909/// `devp config set` guards the ranges, but nothing guards a hand-edited `registry.json`
910/// — and the values that get in that way are the quiet ones: `scan_depth: 0` finds no
911/// projects, `command_timeout_secs: 0` kills every lockfile command the instant it
912/// starts. Both leave a tool that runs, reports success and prunes nothing.
913///
914/// Round-tripping each value through the setter that owns it is deliberate. A separate
915/// list of ranges would be a second copy of the rules, free to drift from the ones
916/// actually enforced.
917pub fn invalid_settings(settings: &Settings) -> Vec<(&'static str, String)> {
918    SETTINGS
919        .iter()
920        .filter_map(|setting| {
921            let mut probe = settings.clone();
922            (setting.set)(&mut probe, &(setting.get)(settings))
923                .err()
924                .map(|e| (setting.key, e.to_string()))
925        })
926        .collect()
927}
928
929/// The number of settings [`invalid_settings`] checks, for reports that say so.
930pub fn setting_count() -> usize {
931    SETTINGS.len()
932}
933
934/// Apply the wizard's settings edits to a registry freshly read from disk, and save
935/// that.
936///
937/// The wizard holds the registry it loaded when it opened, and it can sit open for
938/// minutes — long enough for a scheduled pass to finish and record its prune history
939/// through its own load–save. Saving the wizard's stale copy wrote that history back
940/// out of existence, so only the keys the user actually changed are carried over,
941/// onto whatever the file holds now.
942fn save_settings_edits<'a>(edits: impl Iterator<Item = (&'a str, &'a str)>) -> Result<()> {
943    let mut fresh = Registry::load()?;
944    for (key, value) in edits {
945        (find_setting(key)?.set)(&mut fresh.settings, value)?;
946    }
947    fresh.save()
948}
949
950fn find_setting(key: &str) -> Result<&'static Setting> {
951    SETTINGS
952        .iter()
953        .find(|s| s.key == key)
954        .ok_or_else(|| anyhow::anyhow!("Unknown config key: {key}. Valid keys: {}", valid_keys()))
955}
956
957fn valid_keys() -> String {
958    SETTINGS
959        .iter()
960        .map(|s| s.key)
961        .collect::<Vec<_>>()
962        .join(", ")
963}
964
965/// What a `daemon` / `hook` sub-action word means.
966#[derive(Debug, PartialEq, Eq)]
967pub enum Toggle {
968    Enable,
969    Disable,
970    Status,
971}
972
973/// Resolve the sub-action word users actually type.
974///
975/// `install` / `uninstall` are what this tool's own output and its documentation have
976/// always called these operations, and `on` / `off` is the obvious guess; each pair
977/// means the same thing as `enable` / `disable`, so all of them are accepted.
978///
979/// Anything else is an error rather than a fall-through to `status`. Silently printing
980/// status for `devp config daemon enabel` looks like it worked and leaves the daemon
981/// uninstalled.
982pub fn parse_toggle(action: &str) -> Result<Toggle> {
983    match action.to_lowercase().as_str() {
984        "enable" | "install" | "on" => Ok(Toggle::Enable),
985        "disable" | "uninstall" | "remove" | "off" => Ok(Toggle::Disable),
986        "" | "status" | "show" => Ok(Toggle::Status),
987        other => bail!(
988            "Unknown action `{other}`. Expected `enable`, `disable` or `status` \
989             (`install` / `uninstall` / `on` / `off` also work)."
990        ),
991    }
992}
993
994/// Whether a bare argument is a sub-action rather than a workspace path.
995///
996/// `devp config hook <word>` is ambiguous by design — `<word>` is either the action or
997/// the repository to apply it to — so both the argument router and [`parse_toggle`]
998/// have to agree on which words are actions.
999pub fn is_toggle_word(word: &str) -> bool {
1000    parse_toggle(word).is_ok() && !word.is_empty()
1001}
1002
1003/// Resolve the workspace argument of `daemon` / `hook`, which is whatever was not
1004/// recognised as an action.
1005///
1006/// A word that is neither an action nor a directory is a mistyped action. Treating it
1007/// as a path would print `Daemon Status (enabel): Enabled for workspace` — a success
1008/// message about a repository that does not exist.
1009fn resolve_workspace(path: &str) -> Result<std::path::PathBuf> {
1010    let raw = Path::new(path);
1011    if !raw.is_dir() {
1012        bail!(
1013            "`{path}` is neither an action nor an existing directory.\n\
1014             Expected `enable`, `disable` or `status`, or a path to a repository."
1015        );
1016    }
1017    Ok(raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf()))
1018}
1019
1020/// Display a single config value.
1021pub fn run_get(key: &str) -> Result<()> {
1022    let registry = Registry::load()?;
1023    let setting = find_setting(key)?;
1024    println!("{key} = {}", (setting.get)(&registry.settings));
1025    Ok(())
1026}
1027
1028/// Set a config value.
1029pub fn run_set(key: &str, value: &str) -> Result<()> {
1030    let mut registry = Registry::load()?;
1031    let setting = find_setting(key)?;
1032    (setting.set)(&mut registry.settings, value)?;
1033    registry.save()?;
1034
1035    // The stored value, not the typed one: `devp config set auto_daemon yes` stores
1036    // `true`, and echoing "auto_daemon = yes" would describe a file that does not exist.
1037    output::print_success(&format!("{key} = {}", (setting.get)(&registry.settings)));
1038
1039    // The one value that carries a caveat. A catalogue nobody has proofread is still
1040    // worth shipping — it is how the first speaker of that language finds the mistakes
1041    // — but they should hear it here rather than infer it from a wrong heading.
1042    if key == "language"
1043        && let Some(meta) = i18n::language(&registry.settings.language)
1044        && !meta.reviewed
1045    {
1046        output::print_info(&format!(
1047            "No native speaker has reviewed the {} translation yet. Corrections are welcome — see docs/TRANSLATIONS.md.",
1048            meta.english_name
1049        ));
1050    }
1051    Ok(())
1052}
1053
1054/// Widest key name, so every value in `config show` lines up.
1055fn key_column_width() -> usize {
1056    SETTINGS.iter().map(|s| s.key.len()).max().unwrap_or(0)
1057}
1058
1059/// Show all config values.
1060pub fn run_show() -> Result<()> {
1061    let registry = Registry::load()?;
1062    let width = key_column_width();
1063
1064    output::print_header("dev-prune Global Configuration");
1065    for (category, settings) in settings_by_category() {
1066        output::print_section(category.title());
1067        for setting in settings {
1068            println!(
1069                "    {:<width$} = {}",
1070                setting.key,
1071                (setting.get)(&registry.settings)
1072            );
1073        }
1074    }
1075
1076    // Not settings, and so not in a group with any: one is a count and the other is a
1077    // path, and neither is something `devp config set` will take.
1078    output::print_section("This machine");
1079    println!(
1080        "    {:<width$} = {}",
1081        "tracked_repos",
1082        registry.repo_count()
1083    );
1084    let reg_path = Registry::registry_path()
1085        .map(|p| output::clean_path(&p))
1086        .unwrap_or_else(|_| "unknown".to_string());
1087    println!("    {:<width$} = {reg_path}", "registry_file");
1088
1089    // Until 1.10.0 the recommendations existed only on the first-run screen, so a
1090    // machine that had already been through it had no way left to find out that a
1091    // recommendation existed at all — let alone that one of them carries a caveat.
1092    print_recommendation_summary(&registry.settings);
1093
1094    println!();
1095    output::print_info("Change any of these with `devp config set <key> <value>`.");
1096    output::print_info("Walk through them one at a time with `devp config wizard`.");
1097
1098    Ok(())
1099}
1100
1101/// Whether anybody asked for the configurator, or it opened on its own.
1102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1103pub enum Opened {
1104    /// `devp config wizard`, typed on purpose.
1105    ByRequest,
1106    /// The first run after an install, or the first after an upgrade added a setting —
1107    /// the two times this takes a terminal in the middle of a command that asked for
1108    /// something else.
1109    OnItsOwn,
1110}
1111
1112/// What to tell someone who did not ask to be here, or `None` when they did ask.
1113///
1114/// Two different situations and so two different sentences: a fresh install has never
1115/// seen any of this, while an upgrade has added a handful of keys to a list somebody
1116/// already went through. Both close on the same promise, which is the one the reader
1117/// actually wants — the command they typed is still going to run.
1118fn why_this_opened(opened: Opened) -> Option<String> {
1119    if opened == Opened::ByRequest {
1120        return None;
1121    }
1122    let new = settings_added_since_review().len();
1123    Some(if reviewed_version().is_none() || new == 0 {
1124        "You did not ask for this screen. dev-prune opens it once, on the first command \
1125         after it is installed, so that you see what its defaults do before they start \
1126         doing it. Whatever you typed runs as soon as you leave. It will not open by \
1127         itself again unless an upgrade adds a setting."
1128            .to_string()
1129    } else {
1130        format!(
1131            "You did not ask for this screen. This upgrade added {new} {}, and dev-prune \
1132             shows a new one once before its default goes on applying. Nothing else about \
1133             your configuration changed. Whatever you typed runs as soon as you leave.",
1134            output::plural(new, "setting", "settings"),
1135        )
1136    })
1137}
1138
1139/// Put every global setting in front of the user, and let them change any of it.
1140///
1141/// Run by hand as `devp config wizard`, and once automatically — the first time a human
1142/// types a command on a fresh install, and again after an upgrade that added a setting
1143/// they have never been shown. Both are the moment a default starts applying to their
1144/// machine, and the only moment they can be told so before rather than after.
1145///
1146/// Two implementations, one meaning. [`run_wizard_tui`] is the full-screen one; the
1147/// line-by-line [`run_wizard_prompts`] runs wherever that cannot, which is less a
1148/// degraded mode than the only honest option on a pipe.
1149pub fn run_wizard(no_tui: bool, opened: Opened) -> Result<()> {
1150    if !no_tui && full_screen_is_usable() {
1151        return run_wizard_tui(opened);
1152    }
1153    run_wizard_prompts(opened)
1154}
1155
1156/// Whether a full-screen view can be opened, and should be.
1157///
1158/// The terminal test answers "is there a screen to draw on". `DEV_PRUNE_NO_TUI` answers
1159/// the one it cannot: whether the thing holding that terminal is a person. An agent
1160/// driving `devp` through a pty passes every terminal check and will never press a key,
1161/// so it sets the variable and gets the prompts — or, better, skips this command
1162/// altogether for `devp config set`, which needs no interaction at all.
1163fn full_screen_is_usable() -> bool {
1164    use std::io::IsTerminal;
1165    if std::env::var_os(crate::constants::ENV_NO_TUI).is_some() {
1166        return false;
1167    }
1168    std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
1169}
1170
1171/// The full-screen configurator: declaration, then every setting, then the summary.
1172fn run_wizard_tui(opened: Opened) -> Result<()> {
1173    use crate::tui::config_view::{ConfigRow, ConfigSession, Control, Outcome};
1174
1175    let registry = Registry::load()?;
1176    let new_keys = settings_added_since_review();
1177    // What a machine that had never run this would hold, read through the same getters
1178    // rather than restated. A second spelling of every default is a second spelling free
1179    // to drift from `Settings::default()`, and this one is shown as fact.
1180    let fresh = Settings::default();
1181
1182    // Grouped, not in the order of the table — the view draws a heading wherever the
1183    // category changes, so the order rows arrive in is the order they are read in.
1184    let rows: Vec<ConfigRow> = settings_by_category()
1185        .into_iter()
1186        .flat_map(|(category, settings)| {
1187            settings.into_iter().map(move |setting| (category, setting))
1188        })
1189        .map(|(category, setting)| {
1190            let value = (setting.get)(&registry.settings);
1191            ConfigRow {
1192                key: setting.key,
1193                category: category.title(),
1194                help: setting.help,
1195                plain: setting.plain,
1196                control: match setting.kind {
1197                    Kind::Toggle => Control::Toggle,
1198                    Kind::Choice => Control::Choice(i18n::choices()),
1199                    Kind::Number => Control::Number,
1200                    Kind::Adapters => Control::Adapters,
1201                    Kind::AdapterDays => Control::AdapterDays,
1202                    Kind::CacheCaps => Control::CacheCaps,
1203                },
1204                original: value.clone(),
1205                default: (setting.get)(&fresh),
1206                recommended: recommended_value(setting.key),
1207                cautious: recommendation(setting.key).is_some_and(|r| r.cautious),
1208                value,
1209                is_new: new_keys.contains(&setting.key),
1210            }
1211        })
1212        .collect();
1213
1214    // The view validates through the real setters against a throwaway copy, so a value it
1215    // accepts is a value that will save, and the rules stay in exactly one place.
1216    let base = registry.settings.clone();
1217    let validate = move |key: &str, value: &str| -> std::result::Result<(), String> {
1218        let setting = find_setting(key).map_err(|e| e.to_string())?;
1219        let mut probe = base.clone();
1220        (setting.set)(&mut probe, value).map_err(|e| format!("{e}"))
1221    };
1222
1223    let report = crate::commands::trust::build(&registry);
1224    let adapters = crate::adapters::all_adapter_names();
1225    let opt_in = crate::adapters::opt_in_adapter_names();
1226    // Identity, never a guess: the checklist offers a cache cap only where an adapter
1227    // and a cache go by the same name. See `ConfigSession::capped_adapters`.
1228    let capped: Vec<&'static str> = adapters
1229        .iter()
1230        .copied()
1231        .filter(|name| crate::commands::caches::is_cache_manager(name))
1232        .collect();
1233
1234    let why = why_this_opened(opened);
1235    let outcome = crate::tui::config_view::run(ConfigSession {
1236        declaration: declaration_lines(&report),
1237        standing: NOTHING_DELETED_YET.to_string(),
1238        suggestions: first_run_suggestions(),
1239        rows,
1240        adapters: &adapters,
1241        opt_in_adapters: &opt_in,
1242        capped_adapters: &capped,
1243        groups: crate::adapters::ADAPTER_GROUPS,
1244        validate: &validate,
1245        title: "dev-prune configuration",
1246        uninvited: why.as_deref(),
1247    })?;
1248
1249    match outcome {
1250        // Deliberately not marked reviewed here — the caller decides. The first run marks
1251        // it anyway, because being asked again on every command is worse than being asked
1252        // once and walking away; `devp config wizard` typed by hand changes nothing.
1253        Outcome::Cancelled => {
1254            output::print_info("Cancelled — nothing was changed.");
1255            Ok(())
1256        }
1257        Outcome::KeepAll => {
1258            mark_reviewed();
1259            output::print_success(
1260                "Keeping the current values. `devp config set <key> <value>` changes any.",
1261            );
1262            Ok(())
1263        }
1264        Outcome::Save(changed) => {
1265            save_settings_edits(changed.iter().map(|row| (row.key, row.value.as_str())))?;
1266            mark_reviewed();
1267
1268            // Reprinted into the scrollback on purpose: the summary screen left with the
1269            // alternate screen, and what was just written to a config file should still be
1270            // readable after the view that wrote it has closed.
1271            output::print_header("Saved");
1272            let width = changed.iter().map(|r| r.key.len()).max().unwrap_or(0);
1273            for row in &changed {
1274                println!(
1275                    "  {:<width$} = {}  (was {})",
1276                    row.key, row.value, row.original
1277                );
1278            }
1279            println!();
1280            output::print_success(&format!(
1281                "{} {} saved. `devp config show` lists every setting.",
1282                changed.len(),
1283                output::plural(changed.len(), "change", "changes")
1284            ));
1285            Ok(())
1286        }
1287    }
1288}
1289
1290/// The suggestions screen's contents — empty on every run but the first.
1291///
1292/// "First" is the same fact the walkthrough itself runs on: no review marker on disk
1293/// means this machine has never been shown the settings. Someone who types
1294/// `devp config wizard` a month later has already made these decisions once, and
1295/// re-suggesting them is how a suggestion turns into nagging.
1296///
1297/// The descriptions are read off the settings table rather than written again here.
1298/// Two copies of "what does `enable_cargo` do" is one copy free to drift, and the copy
1299/// on this screen is the one a brand-new user reads first.
1300/// The value [`RECOMMENDED`] suggests for a key, if it suggests one.
1301///
1302/// Unlike [`first_run_suggestions`] this answers on every run, not only the first. The
1303/// suggestions screen is shown once; the settings list is where somebody goes back to a
1304/// year later, and "what did the author think this should be" is a question that does
1305/// not expire with the screen that first asked it.
1306fn recommended_value(key: &str) -> Option<&'static str> {
1307    recommendation(key).map(|r| r.value)
1308}
1309
1310/// The recommendation covering a setting, when one does.
1311fn recommendation(key: &str) -> Option<&'static Recommendation> {
1312    RECOMMENDED.iter().find(|r| r.key == key)
1313}
1314
1315/// Which recommendations a machine has not taken yet, in table order.
1316fn outstanding(settings: &Settings) -> Vec<&'static Recommendation> {
1317    RECOMMENDED
1318        .iter()
1319        .filter(|r| {
1320            find_setting(r.key)
1321                .map(|s| (s.get)(settings))
1322                .ok()
1323                .as_deref()
1324                != Some(r.value)
1325        })
1326        .collect()
1327}
1328
1329/// The outstanding recommendations, in their two tiers, under the names both tiers are
1330/// known by everywhere else.
1331///
1332/// Prints nothing when there is nothing outstanding: a section whose entire content is
1333/// "nothing to do" is a section people learn to scroll past, and it would then be in the
1334/// way on every later reading of `devp config show`.
1335fn print_recommendation_summary(settings: &Settings) {
1336    let outstanding = outstanding(settings);
1337    if outstanding.is_empty() {
1338        return;
1339    }
1340    let width = key_column_width();
1341
1342    let safe: Vec<_> = outstanding.iter().filter(|r| !r.cautious).collect();
1343    if !safe.is_empty() {
1344        output::print_section(SAFE_TIER);
1345        for r in &safe {
1346            println!("    {:<width$} = {}   {}", r.key, r.value, r.label);
1347        }
1348        println!();
1349        output::print_info(&format!(
1350            "`devp config recommended` sets {} {} in one command.",
1351            safe.len(),
1352            output::plural(safe.len(), "setting", "settings")
1353        ));
1354    }
1355
1356    let cautious: Vec<_> = outstanding.iter().filter(|r| r.cautious).collect();
1357    if !cautious.is_empty() {
1358        output::print_section(CAUTIOUS_TIER);
1359        for r in &cautious {
1360            println!("    {:<width$} = {}   {}", r.key, r.value, r.label);
1361            println!("    {:<width$}   {}", "", r.why);
1362        }
1363        println!();
1364        output::print_info(
1365            "Not included above. `devp config recommended --with-cautious` includes it; \
1366             `devp config set <key> <value>` sets one on its own.",
1367        );
1368    }
1369}
1370
1371/// Turn on everything the first run recommends, without the first run.
1372///
1373/// Reads the same table the configurator reads, so the one-command path and the
1374/// walkthrough cannot end up disagreeing about what "recommended" means.
1375///
1376/// The cautious tier is held back unless `--with-cautious` is typed. That is not the
1377/// same prohibition the configurator's `[a]` key is under: `[a]` would accept, on
1378/// somebody's behalf, the thing the screen had just told them to read about, whereas a
1379/// flag is the reading having happened. What it must not do is arrive by default.
1380///
1381/// It does not mark the settings as reviewed. This is a shortcut past the decision, not
1382/// the screen that puts the decision in front of somebody — so a machine configured
1383/// this way still gets the walkthrough it is owed.
1384pub fn run_recommended(with_cautious: bool) -> Result<()> {
1385    let mut registry = Registry::load()?;
1386    let width = key_column_width();
1387
1388    output::print_header("dev-prune recommended settings");
1389
1390    let mut applied: Vec<(&'static str, String, &'static str)> = Vec::new();
1391    let mut already: Vec<&'static Recommendation> = Vec::new();
1392    let mut held_back: Vec<&'static Recommendation> = Vec::new();
1393
1394    for rec in RECOMMENDED {
1395        let setting = find_setting(rec.key)?;
1396        let current = (setting.get)(&registry.settings);
1397        if current == rec.value {
1398            already.push(rec);
1399        } else if rec.cautious && !with_cautious {
1400            held_back.push(rec);
1401        } else {
1402            (setting.set)(&mut registry.settings, rec.value)?;
1403            applied.push((rec.key, current, rec.value));
1404        }
1405    }
1406
1407    if !applied.is_empty() {
1408        registry.save()?;
1409        output::print_section("Turned on");
1410        for (key, from, to) in &applied {
1411            println!("    {:<width$}   {from} → {to}", key);
1412        }
1413    }
1414    if !already.is_empty() {
1415        output::print_section("Already set");
1416        for rec in &already {
1417            println!("    {:<width$}   {}", rec.key, rec.label);
1418        }
1419    }
1420    if !held_back.is_empty() {
1421        output::print_section(CAUTIOUS_TIER);
1422        for rec in &held_back {
1423            println!("    {:<width$} = {}   {}", rec.key, rec.value, rec.label);
1424            println!("    {:<width$}   {}", "", rec.why);
1425        }
1426        println!();
1427        output::print_info(
1428            "Left alone. `devp config recommended --with-cautious` includes it; \
1429             `devp config set <key> <value>` sets one on its own.",
1430        );
1431    }
1432
1433    println!();
1434    if applied.is_empty() {
1435        output::print_success(
1436            "Nothing changed — everything recommended without a caveat is already set.",
1437        );
1438    } else {
1439        output::print_success(&format!(
1440            "{} {} changed. `devp config show` lists them all.",
1441            applied.len(),
1442            output::plural(applied.len(), "setting", "settings")
1443        ));
1444    }
1445    Ok(())
1446}
1447
1448fn first_run_suggestions() -> Vec<crate::tui::config_view::Suggestion> {
1449    use crate::tui::config_view::Suggestion;
1450
1451    if reviewed_version().is_some() {
1452        return Vec::new();
1453    }
1454    RECOMMENDED
1455        .iter()
1456        .filter_map(|r| {
1457            let setting = find_setting(r.key).ok()?;
1458            Some(Suggestion {
1459                key: r.key,
1460                label: r.label,
1461                help: setting.help,
1462                plain: setting.plain,
1463                why: r.why,
1464                value: r.value,
1465                cautious: r.cautious,
1466            })
1467        })
1468        .collect()
1469}
1470
1471/// What is true at the moment the configurator opens, and stays true while it is open.
1472const NOTHING_DELETED_YET: &str =
1473    "Nothing has been deleted, and nothing will be until a lockfile proves it comes back.";
1474
1475/// Who wrote this, and where a copy of it legitimately comes from.
1476///
1477/// Everything else on the declaration screen is a promise about what dev-prune will not
1478/// do, and a promise is worth what the thing making it is: the screen listed seven
1479/// guarantees without ever saying whose binary was guaranteeing them. This is that
1480/// block, and it is the one place on the screen a reader can act on before trusting the
1481/// rest — by checking the download against a name and a URL they can verify.
1482///
1483/// Read from `constants` rather than written out here, because `devp --version` reads
1484/// the same values: a stray copy of the executable and the screen that vouches for it
1485/// must not be able to disagree about who built it.
1486///
1487/// Every channel listed is one dev-prune is actually published to today. WinGet is
1488/// deliberately absent until it is, because a provenance list that names a channel
1489/// nobody publishes to teaches people to trust a name instead of a source, which is the
1490/// exact habit this block exists to prevent.
1491fn provenance_rows() -> Vec<(&'static str, String)> {
1492    // Trimmed of the scheme so the longest line still fits an 80-column terminal beside
1493    // a 26-cell label column; nothing here is a link to click.
1494    let url = |u: &str| u.trim_start_matches("https://").to_string();
1495    vec![
1496        (
1497            "What you are running",
1498            format!(
1499                "{} v{}",
1500                crate::constants::APP_NAME,
1501                crate::constants::VERSION
1502            ),
1503        ),
1504        (
1505            "Written by",
1506            format!("{}, under Apache-2.0", crate::constants::AUTHOR),
1507        ),
1508        ("Source code", url(crate::constants::REPO_URL)),
1509        (
1510            "Official downloads",
1511            format!("{} · GitHub releases", url(crate::constants::HOMEPAGE_URL)),
1512        ),
1513        (
1514            "Package registries",
1515            "crates.io · PyPI · npm, all named dev-prune".to_string(),
1516        ),
1517        (
1518            "Editor extension",
1519            "VS Code Marketplace · Open VSX".to_string(),
1520        ),
1521        (
1522            "Any other source",
1523            "is not a copy the author published".to_string(),
1524        ),
1525    ]
1526}
1527
1528/// The declaration screen's contents: `devp trust`, shown before rather than after.
1529///
1530/// Read off the same report that command prints rather than written out again here. A
1531/// second copy of these promises is a second copy free to drift, and the copy a new user
1532/// reads first is the worst one to have drift.
1533fn declaration_lines(
1534    report: &crate::commands::trust::TrustReport,
1535) -> Vec<crate::tui::config_view::DeclarationLine> {
1536    use crate::commands::trust::{TrustRow, Verdict};
1537    use crate::tui::config_view::DeclarationLine;
1538
1539    let heading = |text: &str| DeclarationLine {
1540        mark: '#',
1541        subject: text.to_string(),
1542        state: String::new(),
1543    };
1544    let row = |r: &TrustRow| DeclarationLine {
1545        mark: match r.verdict {
1546            Verdict::Guaranteed | Verdict::Safe => '+',
1547            Verdict::Widened => '!',
1548            Verdict::Neutral => ' ',
1549        },
1550        subject: r.subject.to_string(),
1551        state: r.state.clone(),
1552    };
1553
1554    let mut lines = vec![heading("What this is, and where it came from")];
1555    lines.extend(
1556        provenance_rows()
1557            .into_iter()
1558            .map(|(subject, state)| DeclarationLine {
1559                mark: ' ',
1560                subject: subject.to_string(),
1561                state,
1562            }),
1563    );
1564    lines.push(heading(""));
1565    lines.push(heading("Guaranteed by the code"));
1566    lines.extend(report.guarantees.iter().map(&row));
1567    lines.push(heading(""));
1568    lines.push(heading("On this machine"));
1569    lines.extend(report.machine.iter().map(&row));
1570    lines
1571}
1572
1573/// Walk the global settings one line at a time, offering each current value.
1574///
1575/// Refuses without a terminal instead of hanging on a read that will never return.
1576fn run_wizard_prompts(opened: Opened) -> Result<()> {
1577    use std::io::{self, IsTerminal, Write};
1578
1579    if !io::stdin().is_terminal() {
1580        bail!(
1581            "`devp config wizard` needs a terminal to ask questions on.\n\
1582             Use `devp config show` to read the settings and `devp config set <key> <value>` \
1583             to change one."
1584        );
1585    }
1586
1587    let mut registry = Registry::load()?;
1588    let width = key_column_width();
1589    let new_keys = settings_added_since_review();
1590    let fresh = Settings::default();
1591
1592    output::print_header("dev-prune configuration");
1593    // Before the list rather than after it: somebody who typed `devp caches` and got this
1594    // needs the reason at the top, where they are already looking, not under thirty keys.
1595    if let Some(why) = why_this_opened(opened) {
1596        output::print_warning(&why);
1597        println!();
1598    }
1599    output::print_section("What this is, and where it came from");
1600    for (subject, state) in provenance_rows() {
1601        println!("    {}  {state}", output::pad_display(subject, 22));
1602    }
1603    println!("    {}", crate::constants::LICENCE_NOTICE);
1604    println!();
1605
1606    output::print_info("These are the defaults every run will use. Nothing has been changed yet.");
1607    println!();
1608    for (category, settings) in settings_by_category() {
1609        output::print_section(category.title());
1610        for setting in settings {
1611            // A setting that arrived in an upgrade has been applying its default since
1612            // the upgrade, so naming those is the whole reason this reopened.
1613            let badge = if new_keys.contains(&setting.key) {
1614                "   (new in this version)"
1615            } else {
1616                ""
1617            };
1618            println!(
1619                "    {:<width$} = {}{badge}",
1620                setting.key,
1621                (setting.get)(&registry.settings)
1622            );
1623            println!("    {:<width$}   {}", "", setting.help);
1624            // Both lines here too. This path is what a pipe, a narrow terminal and
1625            // `DEV_PRUNE_NO_TUI` all get, and it is no place to be the terse one.
1626            println!("    {:<width$}   {}", "", setting.plain);
1627            // Same two facts the full-screen detail pane carries. The short path is
1628            // allowed to be shorter; it is not allowed to be the one that leaves out
1629            // what a fresh install would have done.
1630            let mut facts = format!("default {}", (setting.get)(&fresh));
1631            // Which tier, not just "recommended". The cautious one is the whole reason
1632            // the distinction exists, and a line that prints both the same way is the
1633            // line that loses it.
1634            if let Some(rec) = recommendation(setting.key) {
1635                facts.push_str(&format!(
1636                    "  ·  recommended {} ({}, not required)",
1637                    rec.value,
1638                    if rec.cautious {
1639                        "read the note below first"
1640                    } else {
1641                        "suggested"
1642                    }
1643                ));
1644            }
1645            println!("    {:<width$}   {facts}", "");
1646            if let Some(rec) = recommendation(setting.key).filter(|r| r.cautious) {
1647                println!("    {:<width$}   {}", "", rec.why);
1648            }
1649        }
1650    }
1651    println!();
1652
1653    print_recommendation_summary(&registry.settings);
1654    println!();
1655
1656    // Two presses here even though the full-screen configurator now finishes on one:
1657    // that one walks a list and ends at a summary, while this line is the only thing
1658    // standing between a held-down Enter and "reviewed". One press is what somebody
1659    // presses to get past a screen they have stopped reading.
1660    if confirmed_twice("Press Enter twice to keep all of these, or type anything to change them: ")?
1661    {
1662        mark_reviewed();
1663        output::print_success("Keeping the defaults. `devp config set <key> <value>` changes any.");
1664        return Ok(());
1665    }
1666
1667    println!();
1668    output::print_info("Enter a new value, or press Enter to keep the one shown.");
1669    println!();
1670
1671    let mut edits: Vec<(&'static str, String, String)> = Vec::new();
1672    let mut input_ended = false;
1673    for setting in SETTINGS {
1674        if input_ended {
1675            break;
1676        }
1677        let current = (setting.get)(&registry.settings);
1678        loop {
1679            print!("  {} [{current}]: ", setting.key);
1680            io::stdout().flush()?;
1681            let mut line = String::new();
1682            // EOF mid-way — a closed pipe or Ctrl-D — keeps what has been answered so far
1683            // rather than looping forever on an empty read.
1684            if io::stdin().read_line(&mut line)? == 0 {
1685                println!();
1686                input_ended = true;
1687                break;
1688            }
1689            let typed = line.trim();
1690            if typed.is_empty() {
1691                break;
1692            }
1693            match (setting.set)(&mut registry.settings, typed) {
1694                Ok(()) => {
1695                    // Read back rather than recording what was typed: a setter is
1696                    // allowed to normalise, and a summary that quotes the keystrokes
1697                    // would then describe something other than what gets written.
1698                    let now = (setting.get)(&registry.settings);
1699                    if now != current {
1700                        edits.push((setting.key, current.clone(), now));
1701                    }
1702                    break;
1703                }
1704                // Re-asked rather than aborted: losing the eight answers already given
1705                // because the ninth was a typo is not a reasonable trade.
1706                Err(e) => output::print_error(&format!("{e}")),
1707            }
1708        }
1709    }
1710
1711    println!();
1712    if edits.is_empty() {
1713        // EOF is not a review. Ctrl-D at the "keep all" gate correctly refused to
1714        // confirm, but then fell through to here — where the empty walkthrough set
1715        // the marker, so closing the input counted as having read every setting.
1716        if input_ended {
1717            output::print_info("Input ended — nothing was changed.");
1718            return Ok(());
1719        }
1720        mark_reviewed();
1721        output::print_success("Nothing changed — the defaults are in place.");
1722        return Ok(());
1723    }
1724
1725    // The last screen of the full-screen configurator, on one line per change: what is
1726    // about to be written, before it is written.
1727    output::print_section("About to be saved");
1728    for (key, from, to) in &edits {
1729        println!("    {:<width$}   {from} → {to}", key);
1730    }
1731    println!();
1732    if !confirmed_twice("Press Enter twice to save, or type anything to abandon: ")? {
1733        output::print_info("Nothing was written.");
1734        return Ok(());
1735    }
1736
1737    save_settings_edits(edits.iter().map(|(key, _, to)| (*key, to.as_str())))?;
1738    mark_reviewed();
1739    let changed = edits.len();
1740    println!();
1741    output::print_success(&format!(
1742        "Saved {changed} {}. `devp config show` lists them all.",
1743        output::plural(changed, "change", "changes")
1744    ));
1745    Ok(())
1746}
1747
1748/// Two empty lines to say yes: in line mode a single Enter is what people press to
1749/// dismiss a prompt they have stopped reading, so keeping-or-saving costs two. (The
1750/// full-screen configurator does not need this — its Enter walk always lands on the
1751/// summary before anything is written.)
1752///
1753/// Anything typed is a no, and so is EOF: a closed pipe must not be able to answer a
1754/// confirmation, and the only way to be sure of that is to treat the absence of an
1755/// answer as one.
1756fn confirmed_twice(prompt: &str) -> Result<bool> {
1757    use std::io::{self, Write};
1758
1759    for pass in 0..2 {
1760        print!(
1761            "{}",
1762            if pass == 0 {
1763                prompt
1764            } else {
1765                "Press Enter once more to confirm: "
1766            }
1767        );
1768        io::stdout().flush()?;
1769        let mut line = String::new();
1770        if io::stdin().read_line(&mut line)? == 0 {
1771            println!();
1772            return Ok(false);
1773        }
1774        if !line.trim().is_empty() {
1775            return Ok(false);
1776        }
1777    }
1778    Ok(true)
1779}
1780
1781/// Marker recording that the settings have been put in front of the user once.
1782const REVIEW_MARKER: &str = "config-reviewed";
1783
1784/// Whether the walkthrough is owed: on a fresh install, or after an upgrade that added a
1785/// setting this machine has never been shown.
1786///
1787/// An upgrade does not re-ask about settings already confirmed — being made to reconfirm
1788/// `idle_days` every release is a nuisance, and a nuisance is something people learn to
1789/// dismiss without reading. It reopens only when something is genuinely new, and then
1790/// says which. A `devp uninstall --purge` removes the config directory and with it this
1791/// marker, which is what makes a real reinstall ask about everything again.
1792pub fn config_review_is_due() -> bool {
1793    let Ok(dir) = Registry::config_dir() else {
1794        return false;
1795    };
1796    if !dir.join(REVIEW_MARKER).exists() {
1797        return true;
1798    }
1799    !settings_added_since_review().is_empty()
1800}
1801
1802/// The release recorded the last time the settings were put in front of the user.
1803fn reviewed_version() -> Option<String> {
1804    let dir = Registry::config_dir().ok()?;
1805    let recorded = std::fs::read_to_string(dir.join(REVIEW_MARKER)).ok()?;
1806    let recorded = recorded.trim().to_string();
1807    (!recorded.is_empty()).then_some(recorded)
1808}
1809
1810/// The settings that did not exist the last time this machine was asked.
1811///
1812/// Derived from each setting's own `since` rather than from a hand-kept "new in this
1813/// version" list, because that list is one more thing to forget when adding a setting and
1814/// its failure mode is silent: a new default starts applying and nothing ever says so.
1815///
1816/// Empty when the marker is missing or unreadable — that is the fresh-install case, where
1817/// every setting is new and [`config_review_is_due`] has already said so.
1818pub fn settings_added_since_review() -> Vec<&'static str> {
1819    let Some(reviewed) = reviewed_version() else {
1820        return Vec::new();
1821    };
1822    SETTINGS
1823        .iter()
1824        .filter(|s| {
1825            crate::commands::update::compare_versions(s.since, &reviewed)
1826                == Some(std::cmp::Ordering::Greater)
1827        })
1828        .map(|s| s.key)
1829        .collect()
1830}
1831
1832fn mark_reviewed() {
1833    if let Ok(dir) = Registry::config_dir() {
1834        let _ = std::fs::create_dir_all(&dir);
1835        let _ = std::fs::write(dir.join(REVIEW_MARKER), crate::constants::VERSION);
1836    }
1837}
1838
1839/// Suppress the first-run walkthrough without running it.
1840///
1841/// For the paths that must not stop to ask: the Git hook, the scheduler, and anything
1842/// with no terminal attached.
1843pub fn skip_config_review() {
1844    mark_reviewed();
1845}
1846
1847/// Global audit pass for all registered repos.
1848pub fn run_global_update() -> Result<()> {
1849    output::print_header("dev-prune Global Configuration Audit & Sync");
1850
1851    let registry = Registry::load()?;
1852    let mut total_audited = 0;
1853    let mut errors_found = 0;
1854
1855    for repo_path in registry.repositories.keys() {
1856        let clean = output::clean_path(repo_path);
1857
1858        // A registered path that is gone — deleted, on an unplugged drive — is not a
1859        // config error, and writing a fresh `.devprune.json` at it would either fail or
1860        // conjure a directory where the repository used to be.
1861        if !repo_path.exists() {
1862            output::print_warning(&format!(
1863                "Skipped {clean} — the path no longer exists. `devp unlink --missing` \
1864                 clears such entries."
1865            ));
1866            continue;
1867        }
1868        total_audited += 1;
1869
1870        match PerRepoConfig::load_personal_for_write(repo_path) {
1871            Ok(Some(cfg)) => {
1872                if let Err(e) = cfg.save_to_repo(repo_path) {
1873                    output::print_error(&format!("Failed to write config for {clean}: {e}"));
1874                    errors_found += 1;
1875                } else {
1876                    output::print_success(&format!("Audited & synced config for {clean}"));
1877                }
1878            }
1879            Ok(None) => {
1880                // No file means the global defaults apply, which is a valid state, not a
1881                // gap to fill. Writing one here would drop an untracked file into every
1882                // registered repository in a single command.
1883                output::print_info(&format!(
1884                    "{clean} has no .devprune.json — global defaults apply."
1885                ));
1886            }
1887            Err(err_msg) => {
1888                errors_found += 1;
1889                output::print_error(&format!("Syntax/Schema Error in {clean}:"));
1890                for line in err_msg.lines() {
1891                    eprintln!("    {line}");
1892                }
1893                output::print_info(&format!(
1894                    "Hint: fix the syntax by hand, or run `devp config {clean} --update` to \
1895                     replace the file with a valid default."
1896                ));
1897            }
1898        }
1899    }
1900
1901    if errors_found > 0 {
1902        // Non-zero, so a CI step or a shell `&&` chain notices. An audit that found
1903        // broken config files has not succeeded, however calmly it says so.
1904        anyhow::bail!(
1905            "Audit complete: {total_audited} repos checked, {errors_found} could not be read \
1906             or written."
1907        );
1908    }
1909    output::print_success(&format!(
1910        "Audit complete: All {total_audited} registered repositories are healthy & synced!"
1911    ));
1912
1913    Ok(())
1914}
1915
1916/// Inspect or create per-repository configuration.
1917///
1918/// `shared` addresses `project.devprune.json`, the half meant to be committed, rather than
1919/// the personal `.devprune.json` that gets excluded from git the moment it is written.
1920pub fn run_path_config(path_str: &str, force_update: bool, team: bool) -> Result<()> {
1921    let raw_path = Path::new(path_str);
1922
1923    let path = if raw_path.exists() {
1924        raw_path
1925            .canonicalize()
1926            .unwrap_or_else(|_| raw_path.to_path_buf())
1927    } else {
1928        raw_path.to_path_buf()
1929    };
1930
1931    let clean = output::clean_path(&path);
1932
1933    if !path.exists() {
1934        bail!("Path does not exist: {clean}");
1935    }
1936
1937    if !crate::scanner::is_git_repo(&path) {
1938        // The old text said "Initializing Git repo first..." and then did no such thing.
1939        bail!(
1940            "`{clean}` is not a Git repository.\n  \
1941             Run `git init` there first, then `devp config {clean}` again."
1942        );
1943    }
1944
1945    let mut registry = Registry::load()?;
1946    if !registry.repositories.contains_key(&path) {
1947        output::print_info(&format!(
1948            "{clean} is not yet registered with dev-prune. Registering now..."
1949        ));
1950        registry.add_repo(path.clone());
1951        registry.save()?;
1952    }
1953
1954    let name = if team {
1955        crate::constants::PROJECT_REPO_CONFIG_FILE
1956    } else {
1957        crate::constants::PER_REPO_CONFIG_FILE
1958    };
1959    let cfg_file = path.join(name);
1960
1961    if cfg_file.exists() && !force_update {
1962        output::print_header(&format!("dev-prune Per-Repo Config for {clean}"));
1963        match crate::config::RepoConfigLayers::load(&path) {
1964            Ok(layers) => {
1965                let addressed = if team {
1966                    layers.project_config()
1967                } else {
1968                    layers.personal_config()
1969                };
1970                println!("{}", serde_json::to_string_pretty(&addressed)?);
1971                output::print_info(&format!("File location: {name}"));
1972                print_layer_provenance(&layers);
1973            }
1974            Err(err_msg) => {
1975                output::print_error(&format!("Invalid configuration in {clean}:"));
1976                for line in err_msg.lines() {
1977                    eprintln!("    {line}");
1978                }
1979                // Non-zero: the file this command was asked to show could not be read,
1980                // and the same file is what every prune of this repo will trip over.
1981                anyhow::bail!(
1982                    "Run `devp config {clean} --update` to reset this file back to defaults \
1983                     (your current overrides in it are discarded)."
1984                );
1985            }
1986        }
1987    } else {
1988        output::print_info(&format!("Initializing {name} for {clean}..."));
1989        if team {
1990            crate::config::write_project_starter(&path)?;
1991        } else {
1992            PerRepoConfig::default().save_to_repo(&path)?;
1993        }
1994        output::print_success(&format!("Created {name} in {clean}"));
1995        if team {
1996            output::print_info(
1997                "It starts empty on purpose: every key it names overrules \
1998                 `.devprune.json`, so it should only name the ones your team decides.",
1999            );
2000            output::print_info(
2001                "`prunable.directories` is the exception — the two files' lists add \
2002                 up, so naming one here never discards somebody's own.",
2003            );
2004            output::print_info(
2005                "Commit it. Unlike `.devprune.json`, this file is not added to \
2006                 `.git/info/exclude` — being shared is the whole reason it exists.",
2007            );
2008        }
2009    }
2010
2011    Ok(())
2012}
2013
2014/// Say which of the two files each effective setting came from.
2015///
2016/// Only worth printing when both exist. With one file the answer is the file you are
2017/// already looking at, and a table restating that is noise; with two, "which one won" is
2018/// the only question the two files cannot answer between them. Printed rather than
2019/// mirrored into `.devprune.json`, because a copied value is a second copy free to drift
2020/// from the first and then be believed.
2021fn print_layer_provenance(layers: &crate::config::RepoConfigLayers) {
2022    if layers.project_config().is_none() || layers.personal_config().is_none() {
2023        return;
2024    }
2025    output::print_section("Effective values");
2026    for (key, value, source) in layers.rows() {
2027        println!(
2028            "  {}  {}  {}",
2029            output::pad_display(key, 20),
2030            output::pad_display(&value, 14),
2031            source.label()
2032        );
2033    }
2034}
2035
2036/// Load a workspace's `.devprune.json` for a toggle that is about to write it back.
2037///
2038/// Refuses a file that does not parse, rather than starting from the defaults. Starting
2039/// from the defaults meant `devp config <repo> daemon off` wrote a fresh file straight
2040/// over the broken one, so a single typo cost the user every other override in it.
2041fn load_workspace_config_for_write(repo_path: &Path) -> Result<PerRepoConfig> {
2042    match PerRepoConfig::load_personal_for_write(repo_path) {
2043        Ok(Some(cfg)) => Ok(cfg),
2044        Ok(None) => Ok(PerRepoConfig::default()),
2045        Err(e) => bail!(
2046            "{e}\n  \
2047             Fix that file, or run `devp config {} --update` to reset it back to defaults \
2048             (your current overrides in it are discarded).",
2049            output::clean_path(repo_path)
2050        ),
2051    }
2052}
2053
2054/// Toggle or status check for background daemon (global or local workspace).
2055pub fn run_daemon_toggle(path: Option<&str>, action: &str) -> Result<()> {
2056    if let Some(p) = path {
2057        let repo_path = resolve_workspace(p)?;
2058        let mut cfg = load_workspace_config_for_write(&repo_path)?;
2059        match parse_toggle(action)? {
2060            Toggle::Enable => {
2061                cfg.disable_daemon = false;
2062                cfg.save_to_repo(&repo_path)?;
2063                output::print_success(&format!(
2064                    "Enabled background daemon for workspace: {}",
2065                    output::clean_path(&repo_path)
2066                ));
2067            }
2068            Toggle::Disable => {
2069                cfg.disable_daemon = true;
2070                cfg.save_to_repo(&repo_path)?;
2071                output::print_success(&format!(
2072                    "Disabled background daemon for workspace: {}",
2073                    output::clean_path(&repo_path)
2074                ));
2075            }
2076            Toggle::Status => {
2077                let st = if cfg.disable_daemon {
2078                    "Disabled for workspace"
2079                } else {
2080                    "Enabled for workspace"
2081                };
2082                output::print_info(&format!(
2083                    "Daemon Status ({}): {}",
2084                    output::clean_path(&repo_path),
2085                    st
2086                ));
2087            }
2088        }
2089    } else {
2090        match parse_toggle(action)? {
2091            Toggle::Enable => crate::commands::daemon::run_install()?,
2092            Toggle::Disable => crate::commands::daemon::run_uninstall()?,
2093            Toggle::Status => crate::commands::daemon::run_status()?,
2094        }
2095    }
2096    Ok(())
2097}
2098
2099/// Toggle or status check for background Git hooks (global or local workspace).
2100pub fn run_hook_toggle(path: Option<&str>, action: &str, chain: bool) -> Result<()> {
2101    if let Some(p) = path {
2102        if chain {
2103            bail!(
2104                "`--chain` changes the single global `core.hooksPath`, so it has no \
2105                 per-workspace form. Drop the path: `devp hook install --chain`."
2106            );
2107        }
2108        let repo_path = resolve_workspace(p)?;
2109        let mut cfg = load_workspace_config_for_write(&repo_path)?;
2110        match parse_toggle(action)? {
2111            Toggle::Enable => {
2112                cfg.disable_hooks = false;
2113                cfg.save_to_repo(&repo_path)?;
2114                output::print_success(&format!(
2115                    "Enabled background Git hooks for workspace: {}",
2116                    output::clean_path(&repo_path)
2117                ));
2118            }
2119            Toggle::Disable => {
2120                cfg.disable_hooks = true;
2121                cfg.save_to_repo(&repo_path)?;
2122                output::print_success(&format!(
2123                    "Disabled background Git hooks for workspace: {}",
2124                    output::clean_path(&repo_path)
2125                ));
2126            }
2127            Toggle::Status => {
2128                let st = if cfg.disable_hooks {
2129                    "Disabled for workspace"
2130                } else {
2131                    "Enabled for workspace"
2132                };
2133                output::print_info(&format!(
2134                    "Git Hook Status ({}): {}",
2135                    output::clean_path(&repo_path),
2136                    st
2137                ));
2138            }
2139        }
2140    } else {
2141        match parse_toggle(action)? {
2142            Toggle::Enable => crate::commands::hook::run_install(chain)?,
2143            Toggle::Disable => crate::commands::hook::run_uninstall()?,
2144            Toggle::Status => crate::commands::hook::run_status()?,
2145        }
2146    }
2147    Ok(())
2148}
2149
2150#[cfg(test)]
2151mod tests {
2152    use super::*;
2153
2154    #[test]
2155    fn enable_synonyms_all_resolve_to_enable() {
2156        for word in ["enable", "install", "on", "INSTALL", "On"] {
2157            assert_eq!(parse_toggle(word).unwrap(), Toggle::Enable, "{word}");
2158        }
2159    }
2160
2161    #[test]
2162    fn disable_synonyms_all_resolve_to_disable() {
2163        for word in ["disable", "uninstall", "remove", "off", "Uninstall"] {
2164            assert_eq!(parse_toggle(word).unwrap(), Toggle::Disable, "{word}");
2165        }
2166    }
2167
2168    #[test]
2169    fn status_is_the_default_and_is_also_spellable() {
2170        for word in ["", "status", "show"] {
2171            assert_eq!(parse_toggle(word).unwrap(), Toggle::Status, "{word}");
2172        }
2173    }
2174
2175    #[test]
2176    fn a_typo_is_an_error_rather_than_a_silent_status_report() {
2177        // `devp config daemon enabel` must not print status and exit 0 — that reads as
2178        // success while the daemon stays uninstalled.
2179        let err = parse_toggle("enabel").unwrap_err().to_string();
2180        assert!(err.contains("enabel"), "{err}");
2181        assert!(err.contains("enable"), "{err}");
2182    }
2183
2184    #[test]
2185    fn a_workspace_toggle_refuses_to_write_over_a_broken_config() {
2186        // The toggle rewrites the whole file. Starting from the defaults on a file it
2187        // could not read would silently discard every override the user had put in it.
2188        let tmp = tempfile::TempDir::new().unwrap();
2189        let broken = r#"{ "project_name": "api", "override_idle_days": 90, }"#;
2190        std::fs::write(
2191            tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE),
2192            broken,
2193        )
2194        .unwrap();
2195
2196        let err = load_workspace_config_for_write(tmp.path())
2197            .unwrap_err()
2198            .to_string();
2199        assert!(err.contains("Syntax error"), "{err}");
2200        assert!(err.contains("--update"), "{err}");
2201
2202        // Untouched, so the user still has their 90 days to recover.
2203        let on_disk =
2204            std::fs::read_to_string(tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE))
2205                .unwrap();
2206        assert_eq!(on_disk, broken);
2207    }
2208
2209    #[test]
2210    fn a_workspace_with_no_config_yet_starts_from_the_defaults() {
2211        let tmp = tempfile::TempDir::new().unwrap();
2212        assert_eq!(
2213            load_workspace_config_for_write(tmp.path()).unwrap(),
2214            PerRepoConfig::default()
2215        );
2216    }
2217
2218    #[test]
2219    fn a_cache_cap_is_written_the_way_it_is_read_back() {
2220        let caps = parse_cache_caps("uv=10,npm=4").unwrap();
2221        assert_eq!(caps.get("uv"), Some(&10));
2222        assert_eq!(caps.get("npm"), Some(&4));
2223        // Sorted and normalised, so `config get` prints one spelling no matter which
2224        // order or casing the user typed.
2225        let settings = Settings {
2226            cache_max_gb: parse_cache_caps("UV = 10 , npm=4").unwrap(),
2227            ..Settings::default()
2228        };
2229        let printed = SETTINGS
2230            .iter()
2231            .find(|s| s.key == "cache_max_gb")
2232            .map(|s| (s.get)(&settings))
2233            .unwrap();
2234        assert_eq!(printed, "npm=4,uv=10");
2235        assert_eq!(parse_cache_caps(&printed).unwrap(), settings.cache_max_gb);
2236    }
2237
2238    #[test]
2239    fn clearing_the_caps_is_spelled_the_way_the_getter_prints_an_empty_map() {
2240        for blank in ["", "-", "none", "(none)", "NONE"] {
2241            assert!(
2242                parse_cache_caps(blank).unwrap().is_empty(),
2243                "`{blank}` should clear every cap"
2244            );
2245        }
2246    }
2247
2248    #[test]
2249    fn a_cap_on_something_that_is_not_a_cache_is_refused_with_the_list() {
2250        // `venv`, `terraform` and `dart` are adapters with no cache of their own, and
2251        // accepting a cap for one would store a setting nothing ever reads.
2252        let err = parse_cache_caps("venv=10").unwrap_err().to_string();
2253        assert!(err.contains("venv"), "{err}");
2254        assert!(err.contains("npm"), "the error lists what is valid: {err}");
2255    }
2256
2257    #[test]
2258    fn a_cap_has_to_be_a_whole_number_of_gibibytes() {
2259        for bad in ["uv=10.5", "uv=ten", "uv=-1", "uv="] {
2260            assert!(parse_cache_caps(bad).is_err(), "`{bad}` was accepted");
2261        }
2262        // A bare name is not a cap, and guessing a default for it would be a number the
2263        // user never chose.
2264        assert!(parse_cache_caps("uv").is_err());
2265    }
2266
2267    #[test]
2268    fn a_cap_of_zero_is_refused_rather_than_stored() {
2269        // Zero marks the cache over-size the moment it exists, which is almost always a
2270        // typo for clearing the cap.
2271        let err = parse_cache_caps("uv=0").unwrap_err().to_string();
2272        assert!(
2273            err.contains("`-`"),
2274            "the error names the way to clear it: {err}"
2275        );
2276    }
2277
2278    #[test]
2279    fn every_setting_round_trips_through_its_own_getter() {
2280        // The table is what `get`, `set`, `show` and the wizard all read, so a getter
2281        // that reports a different field than its setter writes would be invisible in
2282        // every one of them at once.
2283        let mut settings = Settings::default();
2284        for setting in SETTINGS {
2285            let before = (setting.get)(&settings);
2286            let probe = match setting.kind {
2287                Kind::Toggle => if before == "true" { "false" } else { "true" }.to_string(),
2288                // A number every numeric setting accepts: above every minimum, below
2289                // `scan_depth`'s ceiling.
2290                Kind::Number => "7".to_string(),
2291                // A real adapter name, so the round trip also proves the list prints
2292                // back in the spelling `config set` takes.
2293                Kind::Adapters => "cargo".to_string(),
2294                // Same, with a window attached: proves the map prints back in the
2295                // `name=days` spelling `config set` parses.
2296                Kind::AdapterDays => "cargo=45".to_string(),
2297                // A name that is a cache manager, which `cargo` also happens to be —
2298                // spelled out separately because the two lists are validated apart.
2299                Kind::CacheCaps => "cargo=10".to_string(),
2300                // A language every catalogue ships and the default is not, so the probe
2301                // is a real change rather than a write that happens to match.
2302                Kind::Choice => "hi".to_string(),
2303            };
2304            (setting.set)(&mut settings, &probe)
2305                .unwrap_or_else(|e| panic!("{} rejected `{probe}`: {e}", setting.key));
2306            assert_eq!(
2307                (setting.get)(&settings),
2308                probe,
2309                "{} reads back a different field than it writes",
2310                setting.key
2311            );
2312        }
2313    }
2314
2315    #[test]
2316    fn every_setting_is_documented_and_uniquely_named() {
2317        let mut seen = std::collections::HashSet::new();
2318        for setting in SETTINGS {
2319            assert!(seen.insert(setting.key), "duplicate key {}", setting.key);
2320            assert!(!setting.help.is_empty(), "{} has no help", setting.key);
2321            assert!(
2322                !setting.plain.is_empty(),
2323                "{} has no plain text",
2324                setting.key
2325            );
2326            // The wizard prints both under the key; sentences keep that readable.
2327            assert!(
2328                setting.help.ends_with('.'),
2329                "{} help should read as a sentence",
2330                setting.key
2331            );
2332            assert!(
2333                setting.plain.ends_with('.'),
2334                "{} plain text should read as a sentence",
2335                setting.key
2336            );
2337            // Two ways of saying it, not the same way twice: a `plain` line that repeats
2338            // `help` costs a screen row and teaches nobody anything.
2339            assert_ne!(
2340                setting.plain, setting.help,
2341                "{} says the same thing twice",
2342                setting.key
2343            );
2344        }
2345    }
2346
2347    #[test]
2348    fn the_settings_table_covers_every_field_of_settings() {
2349        // Serialising `Settings` names every field, so a field added without a table
2350        // entry — unsettable, unshown, never asked about — fails here rather than in
2351        // a bug report.
2352        let json = serde_json::to_value(Settings::default()).unwrap();
2353        let fields: Vec<String> = json.as_object().unwrap().keys().cloned().collect();
2354        for field in fields {
2355            assert!(
2356                SETTINGS.iter().any(|s| s.key == field),
2357                "`{field}` is a setting with no entry in SETTINGS, so `devp config set \
2358                 {field}` cannot reach it"
2359            );
2360        }
2361    }
2362
2363    #[test]
2364    fn a_rejected_value_leaves_the_previous_one_in_place() {
2365        let mut settings = Settings::default();
2366        assert!((find_setting("scan_depth").unwrap().set)(&mut settings, "0").is_err());
2367        assert_eq!(settings.scan_depth, Settings::default().scan_depth);
2368
2369        assert!((find_setting("command_timeout_secs").unwrap().set)(&mut settings, "0").is_err());
2370        assert!((find_setting("check_interval_days").unwrap().set)(&mut settings, "0").is_err());
2371        assert!(
2372            (find_setting("update_check_interval_days").unwrap().set)(&mut settings, "0").is_err()
2373        );
2374    }
2375
2376    #[test]
2377    fn booleans_accept_the_words_people_actually_type() {
2378        assert!(parse_bool("k", "yes").unwrap());
2379        assert!(parse_bool("k", "ON").unwrap());
2380        assert!(!parse_bool("k", "0").unwrap());
2381        assert!(parse_bool("k", "maybe").is_err());
2382    }
2383
2384    #[test]
2385    fn an_unknown_key_lists_the_ones_that_exist() {
2386        let err = match find_setting("idel_days") {
2387            Ok(_) => panic!("`idel_days` is not a setting"),
2388            Err(e) => e.to_string(),
2389        };
2390        assert!(err.contains("idle_days"), "{err}");
2391    }
2392
2393    #[test]
2394    fn a_path_is_never_mistaken_for_an_action() {
2395        // The router uses this to decide whether a lone argument is a path or an action.
2396        assert!(!is_toggle_word("~/Code/my-repo"));
2397        assert!(!is_toggle_word("."));
2398        assert!(!is_toggle_word(""));
2399        assert!(is_toggle_word("install"));
2400    }
2401}