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