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