Skip to main content

dev_prune/commands/
config.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Handler for the `dev-prune config` command.
5//
6// Supports `get`, `set`, `show`, `update`, `daemon`, and `hook` sub-actions
7// for managing global and per-repo workspace settings.
8
9use anyhow::{Result, bail};
10use std::path::Path;
11
12use crate::config::{PerRepoConfig, Registry, Settings};
13use crate::output;
14
15/// One tunable in the global config: how to read it, how to write it, and what to say
16/// about it.
17///
18/// A table rather than a `match` arm per operation. `get`, `set`, `show` and the
19/// first-run walkthrough all iterate this, so a setting cannot be added to one of them
20/// and quietly forgotten in the other three — which is how `min_size_mb` shipped with no
21/// line in `config show`.
22struct Setting {
23    key: &'static str,
24    /// The release this key first appeared in.
25    ///
26    /// Not decoration: the first-run marker records the version it was written at, so
27    /// comparing the two is how an upgrade knows which settings the user has never been
28    /// shown — without keeping a second list of "new in this version" to forget to
29    /// update. See [`settings_added_since_review`].
30    since: &'static str,
31    /// What kind of value this is, so a picker can offer the right control.
32    kind: Kind,
33    /// One line, shown by the walkthrough and by `config show --help-text`.
34    help: &'static str,
35    get: fn(&Settings) -> String,
36    set: fn(&mut Settings, &str) -> Result<()>,
37}
38
39/// How a setting should be *asked* about, as opposed to how it is stored.
40///
41/// Every value round-trips through `get`/`set` as a string either way — this only
42/// decides whether the configurator offers a toggle, a number to type, or the adapter
43/// checklist. Validation stays in the setters, which are the one place that owns it.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45enum Kind {
46    /// `true` or `false`.
47    Toggle,
48    /// A whole number, bounded by whatever its own setter enforces.
49    Number,
50    /// A comma-separated list of adapter names.
51    Adapters,
52    /// Adapter names with a number each, as `cargo=60,npm=30`.
53    ///
54    /// Edited on the same screen as [`Kind::Adapters`] rather than in a field of its
55    /// own: which adapters run and how long each waits are one decision made twice,
56    /// and splitting them across two rows is how someone switches an adapter on and
57    /// never finds the dial that would have made it safe.
58    AdapterDays,
59}
60
61/// Every global setting, in the order a person would want to be asked about them.
62const SETTINGS: &[Setting] = &[
63    Setting {
64        key: "idle_days",
65        since: "1.0.0",
66        kind: Kind::Number,
67        help: "Days a repository must sit untouched before it is eligible for pruning.",
68        get: |s| s.idle_days.to_string(),
69        set: |s, v| {
70            s.idle_days = v
71                .parse()
72                .map_err(|_| anyhow::anyhow!("idle_days must be a whole number of days"))?;
73            Ok(())
74        },
75    },
76    Setting {
77        key: "min_size_mb",
78        since: "1.0.0",
79        kind: Kind::Number,
80        help: "Smallest bloat directory worth deleting, in MiB. 0 removes the floor.",
81        get: |s| s.min_size_mb.to_string(),
82        set: |s, v| {
83            s.min_size_mb = v.parse().map_err(|_| {
84                anyhow::anyhow!("min_size_mb must be a whole number of MiB (0 disables the floor)")
85            })?;
86            Ok(())
87        },
88    },
89    Setting {
90        key: "scan_depth",
91        since: "1.0.0",
92        kind: Kind::Number,
93        help: "How many directory levels below a repo root project discovery descends.",
94        get: |s| s.scan_depth.to_string(),
95        set: |s, v| {
96            let depth: usize = v
97                .parse()
98                .map_err(|_| anyhow::anyhow!("scan_depth must be a positive integer"))?;
99            // Rejected rather than clamped. `clamp_depth` exists so a hand-edited config
100            // file cannot break the walk, but when someone types the number at us we owe
101            // them the truth instead of silently storing something else.
102            if depth == 0 {
103                bail!("scan_depth must be at least 1 — 0 would find no projects at all.");
104            }
105            if depth > crate::constants::MAX_SCAN_DEPTH_LIMIT {
106                bail!(
107                    "scan_depth must be at most {} — deeper walks stall on generated trees.",
108                    crate::constants::MAX_SCAN_DEPTH_LIMIT
109                );
110            }
111            s.scan_depth = depth;
112            Ok(())
113        },
114    },
115    Setting {
116        key: "require_confirmation",
117        since: "1.0.0",
118        kind: Kind::Toggle,
119        help: "Ask before deleting anything. Turning this off makes every run unattended.",
120        get: |s| s.require_confirmation.to_string(),
121        set: |s, v| {
122            s.require_confirmation = parse_bool("require_confirmation", v)?;
123            Ok(())
124        },
125    },
126    Setting {
127        key: "allow_manifest_rewrite",
128        since: "1.0.0",
129        kind: Kind::Toggle,
130        help: "Let cargo and go run the sync command that rewrites tracked manifests.",
131        get: |s| s.allow_manifest_rewrite.to_string(),
132        set: |s, v| {
133            s.allow_manifest_rewrite = parse_bool("allow_manifest_rewrite", v)?;
134            Ok(())
135        },
136    },
137    Setting {
138        key: "command_timeout_secs",
139        since: "1.0.0",
140        kind: Kind::Number,
141        help: "How long a lockfile command may run before it is killed.",
142        get: |s| s.command_timeout_secs.to_string(),
143        set: |s, v| {
144            let secs: u64 = v
145                .parse()
146                .map_err(|_| anyhow::anyhow!("command_timeout_secs must be a positive integer"))?;
147            // Zero is not "no limit": the runner compares elapsed time against it before
148            // the child has had a chance to finish, so every lockfile sync would be
149            // killed on the spot and nothing would ever be pruneable.
150            if secs == 0 {
151                bail!(
152                    "command_timeout_secs must be at least 1 — 0 would kill every command \
153                     the instant it starts."
154                );
155            }
156            s.command_timeout_secs = secs;
157            Ok(())
158        },
159    },
160    Setting {
161        key: "auto_setup",
162        since: "1.0.0",
163        kind: Kind::Toggle,
164        help: "Install missing integrations by itself, once per installed version.",
165        get: |s| s.auto_setup.to_string(),
166        set: |s, v| {
167            s.auto_setup = parse_bool("auto_setup", v)?;
168            Ok(())
169        },
170    },
171    Setting {
172        key: "auto_config",
173        since: "1.3.0",
174        kind: Kind::Toggle,
175        help: "Write a default .devprune.json into repositories that link/init register.",
176        get: |s| s.auto_config.to_string(),
177        set: |s, v| {
178            s.auto_config = parse_bool("auto_config", v)?;
179            Ok(())
180        },
181    },
182    Setting {
183        key: "auto_daemon",
184        since: "1.0.0",
185        kind: Kind::Toggle,
186        help: "Register the OS scheduler so passes run without being remembered.",
187        get: |s| s.auto_daemon.to_string(),
188        set: |s, v| {
189            s.auto_daemon = parse_bool("auto_daemon", v)?;
190            Ok(())
191        },
192    },
193    Setting {
194        key: "check_interval_days",
195        since: "1.0.0",
196        kind: Kind::Number,
197        help: "Days between scheduled background passes.",
198        get: |s| s.check_interval_days.to_string(),
199        set: |s, v| {
200            let days: u64 = v
201                .parse()
202                .map_err(|_| anyhow::anyhow!("check_interval_days must be a positive integer"))?;
203            // Zero would schedule a prune pass with no gap between passes.
204            if days == 0 {
205                bail!("check_interval_days must be at least 1.");
206            }
207            s.check_interval_days = days;
208            Ok(())
209        },
210    },
211    Setting {
212        key: "auto_hooks",
213        since: "1.0.0",
214        kind: Kind::Toggle,
215        help: "Install the Git hooks that register repositories as you clone them.",
216        get: |s| s.auto_hooks.to_string(),
217        set: |s, v| {
218            s.auto_hooks = parse_bool("auto_hooks", v)?;
219            Ok(())
220        },
221    },
222    Setting {
223        key: "auto_hooks_chain",
224        since: "1.0.0",
225        kind: Kind::Toggle,
226        help: "If another tool owns core.hooksPath, install in front of it and forward.",
227        get: |s| s.auto_hooks_chain.to_string(),
228        set: |s, v| {
229            s.auto_hooks_chain = parse_bool("auto_hooks_chain", v)?;
230            Ok(())
231        },
232    },
233    Setting {
234        key: "update_check",
235        since: "1.0.0",
236        kind: Kind::Toggle,
237        help: "Ask GitHub for the latest release from time to time. Sends nothing but the request.",
238        get: |s| s.update_check.to_string(),
239        set: |s, v| {
240            s.update_check = parse_bool("update_check", v)?;
241            Ok(())
242        },
243    },
244    Setting {
245        key: "update_check_interval_days",
246        since: "1.0.0",
247        kind: Kind::Number,
248        help: "Days between automatic release checks.",
249        get: |s| s.update_check_interval_days.to_string(),
250        set: |s, v| {
251            let days: i64 = v.parse().map_err(|_| {
252                anyhow::anyhow!("update_check_interval_days must be a positive integer")
253            })?;
254            if days < 1 {
255                bail!("update_check_interval_days must be at least 1.");
256            }
257            s.update_check_interval_days = days;
258            Ok(())
259        },
260    },
261    Setting {
262        key: "update_check_timeout_secs",
263        since: "1.0.0",
264        kind: Kind::Number,
265        help: "Seconds the release check waits for GitHub. Raise it behind a slow proxy.",
266        get: |s| s.update_check_timeout_secs.to_string(),
267        set: |s, v| {
268            let secs: u64 = v.parse().map_err(|_| {
269                anyhow::anyhow!("update_check_timeout_secs must be a positive integer")
270            })?;
271            if secs == 0 {
272                bail!("update_check_timeout_secs must be at least 1.");
273            }
274            s.update_check_timeout_secs = secs;
275            Ok(())
276        },
277    },
278    Setting {
279        key: "enable_cargo",
280        since: "1.5.0",
281        kind: Kind::Toggle,
282        help: "Turn on the opt-in Cargo adapter (target/ comes back by recompiling, not downloading).",
283        get: |s| s.enable_cargo.to_string(),
284        set: |s, v| {
285            s.enable_cargo = parse_bool("enable_cargo", v)?;
286            Ok(())
287        },
288    },
289    Setting {
290        key: "enable_gradle",
291        since: "1.3.0",
292        kind: Kind::Toggle,
293        help: "Turn on the opt-in Gradle adapter (build/ and .gradle/ come back by recompiling).",
294        get: |s| s.enable_gradle.to_string(),
295        set: |s, v| {
296            s.enable_gradle = parse_bool("enable_gradle", v)?;
297            Ok(())
298        },
299    },
300    Setting {
301        key: "enable_maven",
302        since: "1.3.0",
303        kind: Kind::Toggle,
304        help: "Turn on the opt-in Maven adapter (target/ comes back by recompiling).",
305        get: |s| s.enable_maven.to_string(),
306        set: |s, v| {
307            s.enable_maven = parse_bool("enable_maven", v)?;
308            Ok(())
309        },
310    },
311    Setting {
312        key: "enable_swift",
313        since: "1.4.0",
314        kind: Kind::Toggle,
315        help: "Turn on the opt-in SwiftPM adapter (.build/ comes back by recompiling).",
316        get: |s| s.enable_swift.to_string(),
317        set: |s, v| {
318            s.enable_swift = parse_bool("enable_swift", v)?;
319            Ok(())
320        },
321    },
322    Setting {
323        key: "enable_dart",
324        since: "1.6.0",
325        kind: Kind::Toggle,
326        help: "Turn on the opt-in Dart/Flutter adapter (.dart_tool/ holds build caches).",
327        get: |s| s.enable_dart.to_string(),
328        set: |s, v| {
329            s.enable_dart = parse_bool("enable_dart", v)?;
330            Ok(())
331        },
332    },
333    Setting {
334        key: "build_idle_days",
335        since: "1.3.0",
336        kind: Kind::Number,
337        help: "Idle days before cargo/gradle/maven/swift build trees are pruned. Applied as max(this, idle_days).",
338        get: |s| s.build_idle_days.to_string(),
339        set: |s, v| {
340            let days: u64 = v
341                .parse()
342                .map_err(|_| anyhow::anyhow!("build_idle_days must be a non-negative integer"))?;
343            s.build_idle_days = days;
344            Ok(())
345        },
346    },
347    Setting {
348        key: "auto_update",
349        since: "1.3.0",
350        kind: Kind::Toggle,
351        help: "Run `devp update --install` by itself after a prune pass when a newer release exists.",
352        get: |s| s.auto_update.to_string(),
353        set: |s, v| {
354            s.auto_update = parse_bool("auto_update", v)?;
355            Ok(())
356        },
357    },
358    Setting {
359        key: "disabled_adapters",
360        since: "1.4.0",
361        kind: Kind::Adapters,
362        help: "Adapters to leave alone entirely, by name. Empty means every one of them is active.",
363        get: |s| {
364            if s.disabled_adapters.is_empty() {
365                "(none)".to_string()
366            } else {
367                s.disabled_adapters.join(",")
368            }
369        },
370        set: |s, v| {
371            s.disabled_adapters = parse_adapter_list(v)?;
372            Ok(())
373        },
374    },
375    Setting {
376        key: "adapter_idle_days",
377        since: "1.5.0",
378        kind: Kind::AdapterDays,
379        help: "Per-adapter idle windows, as `cargo=60,npm=30`. Each one can only raise its own wait.",
380        get: |s| {
381            if s.adapter_idle_days.is_empty() {
382                "(none)".to_string()
383            } else {
384                s.adapter_idle_days
385                    .iter()
386                    .map(|(name, days)| format!("{name}={days}"))
387                    .collect::<Vec<_>>()
388                    .join(",")
389            }
390        },
391        set: |s, v| {
392            s.adapter_idle_days = parse_adapter_days(v)?;
393            Ok(())
394        },
395    },
396];
397
398/// Parse the comma-separated adapter deny-list, rejecting names that do not exist.
399///
400/// An unknown name is an error listing the valid ones rather than a no-op, for the same
401/// reason `--only nmp` is: a silently ignored typo reads as "npm is protected" right up
402/// until the pass that deletes `node_modules`.
403/// Parse `cargo=60,npm=30` into the per-adapter idle map.
404///
405/// Same "clear it" spellings as [`parse_adapter_list`], and the same closed loop: what
406/// `config get adapter_idle_days` prints is accepted verbatim by `config set`.
407fn parse_adapter_days(value: &str) -> Result<std::collections::BTreeMap<String, u64>> {
408    let trimmed = value.trim();
409    if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
410        return Ok(std::collections::BTreeMap::new());
411    }
412
413    let mut days = std::collections::BTreeMap::new();
414    for raw in trimmed.split(',') {
415        let entry = raw.trim();
416        if entry.is_empty() {
417            continue;
418        }
419        let Some((name, value)) = entry.split_once('=') else {
420            bail!("`{entry}` must be written as `<adapter>=<days>`, for example `cargo=60`.");
421        };
422        let name = name.trim().to_lowercase();
423        if !crate::adapters::is_adapter_name(&name) {
424            bail!(
425                "`{name}` is not an adapter. Valid names: {}",
426                crate::adapters::all_adapter_names().join(", ")
427            );
428        }
429        let parsed: u64 = value.trim().parse().map_err(|_| {
430            anyhow::anyhow!(
431                "`{name}` needs a whole number of days, not `{}`.",
432                value.trim()
433            )
434        })?;
435        days.insert(name, parsed);
436    }
437    Ok(days)
438}
439
440fn parse_adapter_list(value: &str) -> Result<Vec<String>> {
441    let trimmed = value.trim();
442    // The spellings that mean "clear it". `(none)` closes the loop with the getter, so
443    // whatever `config get` prints can be handed straight back to `config set`.
444    if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
445        return Ok(Vec::new());
446    }
447
448    let mut names: Vec<String> = Vec::new();
449    for raw in trimmed.split(',') {
450        let name = raw.trim().to_lowercase();
451        if name.is_empty() {
452            continue;
453        }
454        if !crate::adapters::is_adapter_name(&name) {
455            bail!(
456                "`{name}` is not an adapter. Valid names: {}",
457                crate::adapters::all_adapter_names().join(", ")
458            );
459        }
460        if !names.contains(&name) {
461            names.push(name);
462        }
463    }
464    Ok(names)
465}
466
467fn parse_bool(key: &str, value: &str) -> Result<bool> {
468    match value.trim().to_lowercase().as_str() {
469        "true" | "yes" | "y" | "on" | "1" => Ok(true),
470        "false" | "no" | "n" | "off" | "0" => Ok(false),
471        _ => bail!("{key} must be true or false"),
472    }
473}
474
475/// Every stored setting that its own setter would refuse, with the reason.
476///
477/// `devp config set` guards the ranges, but nothing guards a hand-edited `registry.json`
478/// — and the values that get in that way are the quiet ones: `scan_depth: 0` finds no
479/// projects, `command_timeout_secs: 0` kills every lockfile command the instant it
480/// starts. Both leave a tool that runs, reports success and prunes nothing.
481///
482/// Round-tripping each value through the setter that owns it is deliberate. A separate
483/// list of ranges would be a second copy of the rules, free to drift from the ones
484/// actually enforced.
485pub fn invalid_settings(settings: &Settings) -> Vec<(&'static str, String)> {
486    SETTINGS
487        .iter()
488        .filter_map(|setting| {
489            let mut probe = settings.clone();
490            (setting.set)(&mut probe, &(setting.get)(settings))
491                .err()
492                .map(|e| (setting.key, e.to_string()))
493        })
494        .collect()
495}
496
497/// The number of settings [`invalid_settings`] checks, for reports that say so.
498pub fn setting_count() -> usize {
499    SETTINGS.len()
500}
501
502fn find_setting(key: &str) -> Result<&'static Setting> {
503    SETTINGS
504        .iter()
505        .find(|s| s.key == key)
506        .ok_or_else(|| anyhow::anyhow!("Unknown config key: {key}. Valid keys: {}", valid_keys()))
507}
508
509fn valid_keys() -> String {
510    SETTINGS
511        .iter()
512        .map(|s| s.key)
513        .collect::<Vec<_>>()
514        .join(", ")
515}
516
517/// What a `daemon` / `hook` sub-action word means.
518#[derive(Debug, PartialEq, Eq)]
519pub enum Toggle {
520    Enable,
521    Disable,
522    Status,
523}
524
525/// Resolve the sub-action word users actually type.
526///
527/// `install` / `uninstall` are what this tool's own output and its documentation have
528/// always called these operations, and `on` / `off` is the obvious guess; each pair
529/// means the same thing as `enable` / `disable`, so all of them are accepted.
530///
531/// Anything else is an error rather than a fall-through to `status`. Silently printing
532/// status for `devp config daemon enabel` looks like it worked and leaves the daemon
533/// uninstalled.
534pub fn parse_toggle(action: &str) -> Result<Toggle> {
535    match action.to_lowercase().as_str() {
536        "enable" | "install" | "on" => Ok(Toggle::Enable),
537        "disable" | "uninstall" | "remove" | "off" => Ok(Toggle::Disable),
538        "" | "status" | "show" => Ok(Toggle::Status),
539        other => bail!(
540            "Unknown action `{other}`. Expected `enable`, `disable` or `status` \
541             (`install` / `uninstall` / `on` / `off` also work)."
542        ),
543    }
544}
545
546/// Whether a bare argument is a sub-action rather than a workspace path.
547///
548/// `devp config hook <word>` is ambiguous by design — `<word>` is either the action or
549/// the repository to apply it to — so both the argument router and [`parse_toggle`]
550/// have to agree on which words are actions.
551pub fn is_toggle_word(word: &str) -> bool {
552    parse_toggle(word).is_ok() && !word.is_empty()
553}
554
555/// Resolve the workspace argument of `daemon` / `hook`, which is whatever was not
556/// recognised as an action.
557///
558/// A word that is neither an action nor a directory is a mistyped action. Treating it
559/// as a path would print `Daemon Status (enabel): Enabled for workspace` — a success
560/// message about a repository that does not exist.
561fn resolve_workspace(path: &str) -> Result<std::path::PathBuf> {
562    let raw = Path::new(path);
563    if !raw.is_dir() {
564        bail!(
565            "`{path}` is neither an action nor an existing directory.\n\
566             Expected `enable`, `disable` or `status`, or a path to a repository."
567        );
568    }
569    Ok(raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf()))
570}
571
572/// Display a single config value.
573pub fn run_get(key: &str) -> Result<()> {
574    let registry = Registry::load()?;
575    let setting = find_setting(key)?;
576    println!("{key} = {}", (setting.get)(&registry.settings));
577    Ok(())
578}
579
580/// Set a config value.
581pub fn run_set(key: &str, value: &str) -> Result<()> {
582    let mut registry = Registry::load()?;
583    let setting = find_setting(key)?;
584    (setting.set)(&mut registry.settings, value)?;
585    registry.save()?;
586
587    // The stored value, not the typed one: `devp config set auto_daemon yes` stores
588    // `true`, and echoing "auto_daemon = yes" would describe a file that does not exist.
589    output::print_success(&format!("{key} = {}", (setting.get)(&registry.settings)));
590    Ok(())
591}
592
593/// Widest key name, so every value in `config show` lines up.
594fn key_column_width() -> usize {
595    SETTINGS.iter().map(|s| s.key.len()).max().unwrap_or(0)
596}
597
598/// Show all config values.
599pub fn run_show() -> Result<()> {
600    let registry = Registry::load()?;
601    let width = key_column_width();
602
603    output::print_header("dev-prune Global Configuration");
604    for setting in SETTINGS {
605        println!(
606            "  {:<width$} = {}",
607            setting.key,
608            (setting.get)(&registry.settings)
609        );
610    }
611    println!("  {:<width$} = {}", "tracked_repos", registry.repo_count());
612
613    let reg_path = Registry::registry_path()
614        .map(|p| output::clean_path(&p))
615        .unwrap_or_else(|_| "unknown".to_string());
616    println!("\n  {:<width$} = {reg_path}", "registry_file");
617    println!();
618    output::print_info("Change any of these with `devp config set <key> <value>`.");
619    output::print_info("Walk through them one at a time with `devp config wizard`.");
620
621    Ok(())
622}
623
624/// Put every global setting in front of the user, and let them change any of it.
625///
626/// Run by hand as `devp config wizard`, and once automatically — the first time a human
627/// types a command on a fresh install, and again after an upgrade that added a setting
628/// they have never been shown. Both are the moment a default starts applying to their
629/// machine, and the only moment they can be told so before rather than after.
630///
631/// Two implementations, one meaning. [`run_wizard_tui`] is the full-screen one; the
632/// line-by-line [`run_wizard_prompts`] runs wherever that cannot, which is less a
633/// degraded mode than the only honest option on a pipe.
634pub fn run_wizard(no_tui: bool) -> Result<()> {
635    if !no_tui && full_screen_is_usable() {
636        return run_wizard_tui();
637    }
638    run_wizard_prompts()
639}
640
641/// Whether a full-screen view can be opened, and should be.
642///
643/// The terminal test answers "is there a screen to draw on". `DEV_PRUNE_NO_TUI` answers
644/// the one it cannot: whether the thing holding that terminal is a person. An agent
645/// driving `devp` through a pty passes every terminal check and will never press a key,
646/// so it sets the variable and gets the prompts — or, better, skips this command
647/// altogether for `devp config set`, which needs no interaction at all.
648fn full_screen_is_usable() -> bool {
649    use std::io::IsTerminal;
650    if std::env::var_os(crate::constants::ENV_NO_TUI).is_some() {
651        return false;
652    }
653    std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
654}
655
656/// The full-screen configurator: declaration, then every setting, then the summary.
657fn run_wizard_tui() -> Result<()> {
658    use crate::tui::config_view::{ConfigRow, ConfigSession, Control, Outcome};
659
660    let mut registry = Registry::load()?;
661    let new_keys = settings_added_since_review();
662
663    let rows: Vec<ConfigRow> = SETTINGS
664        .iter()
665        .map(|setting| {
666            let value = (setting.get)(&registry.settings);
667            ConfigRow {
668                key: setting.key,
669                help: setting.help,
670                control: match setting.kind {
671                    Kind::Toggle => Control::Toggle,
672                    Kind::Number => Control::Number,
673                    Kind::Adapters => Control::Adapters,
674                    Kind::AdapterDays => Control::AdapterDays,
675                },
676                original: value.clone(),
677                value,
678                is_new: new_keys.contains(&setting.key),
679            }
680        })
681        .collect();
682
683    // The view validates through the real setters against a throwaway copy, so a value it
684    // accepts is a value that will save, and the rules stay in exactly one place.
685    let base = registry.settings.clone();
686    let validate = move |key: &str, value: &str| -> std::result::Result<(), String> {
687        let setting = find_setting(key).map_err(|e| e.to_string())?;
688        let mut probe = base.clone();
689        (setting.set)(&mut probe, value).map_err(|e| format!("{e}"))
690    };
691
692    let report = crate::commands::trust::build(&registry);
693    let adapters = crate::adapters::all_adapter_names();
694    let opt_in = crate::adapters::opt_in_adapter_names();
695
696    let outcome = crate::tui::config_view::run(ConfigSession {
697        declaration: declaration_lines(&report),
698        standing: NOTHING_DELETED_YET.to_string(),
699        rows,
700        adapters: &adapters,
701        opt_in_adapters: &opt_in,
702        groups: crate::adapters::ADAPTER_GROUPS,
703        validate: &validate,
704        title: "dev-prune configuration",
705    })?;
706
707    match outcome {
708        // Deliberately not marked reviewed here — the caller decides. The first run marks
709        // it anyway, because being asked again on every command is worse than being asked
710        // once and walking away; `devp config wizard` typed by hand changes nothing.
711        Outcome::Cancelled => {
712            output::print_info("Cancelled — nothing was changed.");
713            Ok(())
714        }
715        Outcome::KeepAll => {
716            mark_reviewed();
717            output::print_success(
718                "Keeping the current values. `devp config set <key> <value>` changes any.",
719            );
720            Ok(())
721        }
722        Outcome::Save(changed) => {
723            for row in &changed {
724                (find_setting(row.key)?.set)(&mut registry.settings, &row.value)?;
725            }
726            registry.save()?;
727            mark_reviewed();
728
729            // Reprinted into the scrollback on purpose: the summary screen left with the
730            // alternate screen, and what was just written to a config file should still be
731            // readable after the view that wrote it has closed.
732            output::print_header("Saved");
733            let width = changed.iter().map(|r| r.key.len()).max().unwrap_or(0);
734            for row in &changed {
735                println!(
736                    "  {:<width$} = {}  (was {})",
737                    row.key, row.value, row.original
738                );
739            }
740            println!();
741            output::print_success(&format!(
742                "{} {} saved. `devp config show` lists every setting.",
743                changed.len(),
744                output::plural(changed.len(), "change", "changes")
745            ));
746            Ok(())
747        }
748    }
749}
750
751/// What is true at the moment the configurator opens, and stays true while it is open.
752const NOTHING_DELETED_YET: &str =
753    "Nothing has been deleted, and nothing will be until a lockfile proves it comes back.";
754
755/// The declaration screen's contents: `devp trust`, shown before rather than after.
756///
757/// Read off the same report that command prints rather than written out again here. A
758/// second copy of these promises is a second copy free to drift, and the copy a new user
759/// reads first is the worst one to have drift.
760fn declaration_lines(
761    report: &crate::commands::trust::TrustReport,
762) -> Vec<crate::tui::config_view::DeclarationLine> {
763    use crate::commands::trust::{TrustRow, Verdict};
764    use crate::tui::config_view::DeclarationLine;
765
766    let heading = |text: &str| DeclarationLine {
767        mark: '#',
768        subject: text.to_string(),
769        state: String::new(),
770    };
771    let row = |r: &TrustRow| DeclarationLine {
772        mark: match r.verdict {
773            Verdict::Guaranteed | Verdict::Safe => '+',
774            Verdict::Widened => '!',
775            Verdict::Neutral => ' ',
776        },
777        subject: r.subject.to_string(),
778        state: r.state.clone(),
779    };
780
781    let mut lines = vec![heading("Guaranteed by the code")];
782    lines.extend(report.guarantees.iter().map(&row));
783    lines.push(heading(""));
784    lines.push(heading("On this machine"));
785    lines.extend(report.machine.iter().map(&row));
786    lines
787}
788
789/// Walk the global settings one line at a time, offering each current value.
790///
791/// Refuses without a terminal instead of hanging on a read that will never return.
792fn run_wizard_prompts() -> Result<()> {
793    use std::io::{self, IsTerminal, Write};
794
795    if !io::stdin().is_terminal() {
796        bail!(
797            "`devp config wizard` needs a terminal to ask questions on.\n\
798             Use `devp config show` to read the settings and `devp config set <key> <value>` \
799             to change one."
800        );
801    }
802
803    let mut registry = Registry::load()?;
804    let width = key_column_width();
805    let new_keys = settings_added_since_review();
806
807    output::print_header("dev-prune configuration");
808    output::print_info("These are the defaults every run will use. Nothing has been changed yet.");
809    println!();
810    for setting in SETTINGS {
811        // A setting that arrived in an upgrade has been applying its default since the
812        // upgrade, so naming those is the whole reason this reopened.
813        let badge = if new_keys.contains(&setting.key) {
814            "   (new in this version)"
815        } else {
816            ""
817        };
818        println!(
819            "  {:<width$} = {}{badge}",
820            setting.key,
821            (setting.get)(&registry.settings)
822        );
823        println!("  {:<width$}   {}", "", setting.help);
824    }
825    println!();
826
827    print!("Keep all of these? [Y/n] ");
828    io::stdout().flush()?;
829    let mut answer = String::new();
830    io::stdin().read_line(&mut answer)?;
831    let keep = !matches!(answer.trim().to_lowercase().as_str(), "n" | "no");
832
833    if keep {
834        mark_reviewed();
835        output::print_success("Keeping the defaults. `devp config set <key> <value>` changes any.");
836        return Ok(());
837    }
838
839    println!();
840    output::print_info("Enter a new value, or press Enter to keep the one shown.");
841    println!();
842
843    let mut changed = 0usize;
844    for setting in SETTINGS {
845        let current = (setting.get)(&registry.settings);
846        loop {
847            print!("  {} [{current}]: ", setting.key);
848            io::stdout().flush()?;
849            let mut line = String::new();
850            // EOF mid-way — a closed pipe or Ctrl-D — keeps what has been answered so far
851            // rather than looping forever on an empty read.
852            if io::stdin().read_line(&mut line)? == 0 {
853                println!();
854                break;
855            }
856            let typed = line.trim();
857            if typed.is_empty() {
858                break;
859            }
860            match (setting.set)(&mut registry.settings, typed) {
861                Ok(()) => {
862                    changed += 1;
863                    break;
864                }
865                // Re-asked rather than aborted: losing the eight answers already given
866                // because the ninth was a typo is not a reasonable trade.
867                Err(e) => output::print_error(&format!("{e}")),
868            }
869        }
870    }
871
872    registry.save()?;
873    mark_reviewed();
874    println!();
875    if changed == 0 {
876        output::print_success("Nothing changed — the defaults are in place.");
877    } else {
878        output::print_success(&format!(
879            "Saved {changed} {}. `devp config show` lists them all.",
880            output::plural(changed, "change", "changes")
881        ));
882    }
883    Ok(())
884}
885
886/// Marker recording that the settings have been put in front of the user once.
887const REVIEW_MARKER: &str = "config-reviewed";
888
889/// Whether the walkthrough is owed: on a fresh install, or after an upgrade that added a
890/// setting this machine has never been shown.
891///
892/// An upgrade does not re-ask about settings already confirmed — being made to reconfirm
893/// `idle_days` every release is a nuisance, and a nuisance is something people learn to
894/// dismiss without reading. It reopens only when something is genuinely new, and then
895/// says which. A `devp uninstall --purge` removes the config directory and with it this
896/// marker, which is what makes a real reinstall ask about everything again.
897pub fn config_review_is_due() -> bool {
898    let Ok(dir) = Registry::config_dir() else {
899        return false;
900    };
901    if !dir.join(REVIEW_MARKER).exists() {
902        return true;
903    }
904    !settings_added_since_review().is_empty()
905}
906
907/// The release recorded the last time the settings were put in front of the user.
908fn reviewed_version() -> Option<String> {
909    let dir = Registry::config_dir().ok()?;
910    let recorded = std::fs::read_to_string(dir.join(REVIEW_MARKER)).ok()?;
911    let recorded = recorded.trim().to_string();
912    (!recorded.is_empty()).then_some(recorded)
913}
914
915/// The settings that did not exist the last time this machine was asked.
916///
917/// Derived from each setting's own `since` rather than from a hand-kept "new in this
918/// version" list, because that list is one more thing to forget when adding a setting and
919/// its failure mode is silent: a new default starts applying and nothing ever says so.
920///
921/// Empty when the marker is missing or unreadable — that is the fresh-install case, where
922/// every setting is new and [`config_review_is_due`] has already said so.
923pub fn settings_added_since_review() -> Vec<&'static str> {
924    let Some(reviewed) = reviewed_version() else {
925        return Vec::new();
926    };
927    SETTINGS
928        .iter()
929        .filter(|s| {
930            crate::commands::update::compare_versions(s.since, &reviewed)
931                == Some(std::cmp::Ordering::Greater)
932        })
933        .map(|s| s.key)
934        .collect()
935}
936
937fn mark_reviewed() {
938    if let Ok(dir) = Registry::config_dir() {
939        let _ = std::fs::create_dir_all(&dir);
940        let _ = std::fs::write(dir.join(REVIEW_MARKER), crate::constants::VERSION);
941    }
942}
943
944/// Suppress the first-run walkthrough without running it.
945///
946/// For the paths that must not stop to ask: the Git hook, the scheduler, and anything
947/// with no terminal attached.
948pub fn skip_config_review() {
949    mark_reviewed();
950}
951
952/// Global audit pass for all registered repos.
953pub fn run_global_update() -> Result<()> {
954    output::print_header("dev-prune Global Configuration Audit & Sync");
955
956    let registry = Registry::load()?;
957    let mut total_audited = 0;
958    let mut errors_found = 0;
959
960    for repo_path in registry.repositories.keys() {
961        let clean = output::clean_path(repo_path);
962
963        // A registered path that is gone — deleted, on an unplugged drive — is not a
964        // config error, and writing a fresh `.devprune.json` at it would either fail or
965        // conjure a directory where the repository used to be.
966        if !repo_path.exists() {
967            output::print_warning(&format!(
968                "Skipped {clean} — the path no longer exists. `devp unlink --missing` \
969                 clears such entries."
970            ));
971            continue;
972        }
973        total_audited += 1;
974
975        match PerRepoConfig::load_with_diagnostics(repo_path) {
976            Ok(Some(cfg)) => {
977                if let Err(e) = cfg.save_to_repo(repo_path) {
978                    output::print_error(&format!("Failed to write config for {clean}: {e}"));
979                    errors_found += 1;
980                } else {
981                    output::print_success(&format!("Audited & synced config for {clean}"));
982                }
983            }
984            Ok(None) => {
985                // No file means the global defaults apply, which is a valid state, not a
986                // gap to fill. Writing one here would drop an untracked file into every
987                // registered repository in a single command.
988                output::print_info(&format!(
989                    "{clean} has no .devprune.json — global defaults apply."
990                ));
991            }
992            Err(err_msg) => {
993                errors_found += 1;
994                output::print_error(&format!("Syntax/Schema Error in {clean}:"));
995                for line in err_msg.lines() {
996                    eprintln!("    {line}");
997                }
998                output::print_info(&format!(
999                    "Hint: fix the syntax by hand, or run `devp config {clean} --update` to \
1000                     replace the file with a valid default."
1001                ));
1002            }
1003        }
1004    }
1005
1006    if errors_found > 0 {
1007        // Non-zero, so a CI step or a shell `&&` chain notices. An audit that found
1008        // broken config files has not succeeded, however calmly it says so.
1009        anyhow::bail!(
1010            "Audit complete: {total_audited} repos checked, {errors_found} could not be read \
1011             or written."
1012        );
1013    }
1014    output::print_success(&format!(
1015        "Audit complete: All {total_audited} registered repositories are healthy & synced!"
1016    ));
1017
1018    Ok(())
1019}
1020
1021/// Inspect or create per-repository configuration (.devprune.json).
1022pub fn run_path_config(path_str: &str, force_update: bool) -> Result<()> {
1023    let raw_path = Path::new(path_str);
1024
1025    let path = if raw_path.exists() {
1026        raw_path
1027            .canonicalize()
1028            .unwrap_or_else(|_| raw_path.to_path_buf())
1029    } else {
1030        raw_path.to_path_buf()
1031    };
1032
1033    let clean = output::clean_path(&path);
1034
1035    if !path.exists() {
1036        bail!("Path does not exist: {clean}");
1037    }
1038
1039    if !crate::scanner::is_git_repo(&path) {
1040        // The old text said "Initializing Git repo first..." and then did no such thing.
1041        bail!(
1042            "`{clean}` is not a Git repository.\n  \
1043             Run `git init` there first, then `devp config {clean}` again."
1044        );
1045    }
1046
1047    let mut registry = Registry::load()?;
1048    if !registry.repositories.contains_key(&path) {
1049        output::print_info(&format!(
1050            "{clean} is not yet registered with dev-prune. Registering now..."
1051        ));
1052        registry.add_repo(path.clone());
1053        registry.save()?;
1054    }
1055
1056    let cfg_file = path.join(crate::constants::PER_REPO_CONFIG_FILE);
1057
1058    if cfg_file.exists() && !force_update {
1059        output::print_header(&format!("dev-prune Per-Repo Config for {clean}"));
1060        match PerRepoConfig::load_with_diagnostics(&path) {
1061            Ok(cfg) => {
1062                let json_str = serde_json::to_string_pretty(&cfg)?;
1063                println!("{json_str}");
1064                output::print_info("File location: .devprune.json");
1065            }
1066            Err(err_msg) => {
1067                output::print_error(&format!("Invalid configuration in {clean}:"));
1068                for line in err_msg.lines() {
1069                    eprintln!("    {line}");
1070                }
1071                // Non-zero: the file this command was asked to show could not be read,
1072                // and the same file is what every prune of this repo will trip over.
1073                anyhow::bail!(
1074                    "Run `devp config {clean} --update` to reset this file back to defaults \
1075                     (your current overrides in it are discarded)."
1076                );
1077            }
1078        }
1079    } else {
1080        output::print_info(&format!("Initializing .devprune.json for {clean}..."));
1081        let cfg = PerRepoConfig::default();
1082        cfg.save_to_repo(&path)?;
1083        output::print_success(&format!("Created .devprune.json in {clean}"));
1084    }
1085
1086    Ok(())
1087}
1088
1089/// Load a workspace's `.devprune.json` for a toggle that is about to write it back.
1090///
1091/// Refuses a file that does not parse, rather than starting from the defaults. Starting
1092/// from the defaults meant `devp config <repo> daemon off` wrote a fresh file straight
1093/// over the broken one, so a single typo cost the user every other override in it.
1094fn load_workspace_config_for_write(repo_path: &Path) -> Result<PerRepoConfig> {
1095    match PerRepoConfig::load_with_diagnostics(repo_path) {
1096        Ok(Some(cfg)) => Ok(cfg),
1097        Ok(None) => Ok(PerRepoConfig::default()),
1098        Err(e) => bail!(
1099            "{e}\n  \
1100             Fix that file, or run `devp config {} --update` to reset it back to defaults \
1101             (your current overrides in it are discarded).",
1102            output::clean_path(repo_path)
1103        ),
1104    }
1105}
1106
1107/// Toggle or status check for background daemon (global or local workspace).
1108pub fn run_daemon_toggle(path: Option<&str>, action: &str) -> Result<()> {
1109    if let Some(p) = path {
1110        let repo_path = resolve_workspace(p)?;
1111        let mut cfg = load_workspace_config_for_write(&repo_path)?;
1112        match parse_toggle(action)? {
1113            Toggle::Enable => {
1114                cfg.disable_daemon = false;
1115                cfg.save_to_repo(&repo_path)?;
1116                output::print_success(&format!(
1117                    "Enabled background daemon for workspace: {}",
1118                    output::clean_path(&repo_path)
1119                ));
1120            }
1121            Toggle::Disable => {
1122                cfg.disable_daemon = true;
1123                cfg.save_to_repo(&repo_path)?;
1124                output::print_success(&format!(
1125                    "Disabled background daemon for workspace: {}",
1126                    output::clean_path(&repo_path)
1127                ));
1128            }
1129            Toggle::Status => {
1130                let st = if cfg.disable_daemon {
1131                    "Disabled for workspace"
1132                } else {
1133                    "Enabled for workspace"
1134                };
1135                output::print_info(&format!(
1136                    "Daemon Status ({}): {}",
1137                    output::clean_path(&repo_path),
1138                    st
1139                ));
1140            }
1141        }
1142    } else {
1143        match parse_toggle(action)? {
1144            Toggle::Enable => crate::commands::daemon::run_install()?,
1145            Toggle::Disable => crate::commands::daemon::run_uninstall()?,
1146            Toggle::Status => crate::commands::daemon::run_status()?,
1147        }
1148    }
1149    Ok(())
1150}
1151
1152/// Toggle or status check for background Git hooks (global or local workspace).
1153pub fn run_hook_toggle(path: Option<&str>, action: &str, chain: bool) -> Result<()> {
1154    if let Some(p) = path {
1155        if chain {
1156            bail!(
1157                "`--chain` changes the single global `core.hooksPath`, so it has no \
1158                 per-workspace form. Drop the path: `devp hook install --chain`."
1159            );
1160        }
1161        let repo_path = resolve_workspace(p)?;
1162        let mut cfg = load_workspace_config_for_write(&repo_path)?;
1163        match parse_toggle(action)? {
1164            Toggle::Enable => {
1165                cfg.disable_hooks = false;
1166                cfg.save_to_repo(&repo_path)?;
1167                output::print_success(&format!(
1168                    "Enabled background Git hooks for workspace: {}",
1169                    output::clean_path(&repo_path)
1170                ));
1171            }
1172            Toggle::Disable => {
1173                cfg.disable_hooks = true;
1174                cfg.save_to_repo(&repo_path)?;
1175                output::print_success(&format!(
1176                    "Disabled background Git hooks for workspace: {}",
1177                    output::clean_path(&repo_path)
1178                ));
1179            }
1180            Toggle::Status => {
1181                let st = if cfg.disable_hooks {
1182                    "Disabled for workspace"
1183                } else {
1184                    "Enabled for workspace"
1185                };
1186                output::print_info(&format!(
1187                    "Git Hook Status ({}): {}",
1188                    output::clean_path(&repo_path),
1189                    st
1190                ));
1191            }
1192        }
1193    } else {
1194        match parse_toggle(action)? {
1195            Toggle::Enable => crate::commands::hook::run_install(chain)?,
1196            Toggle::Disable => crate::commands::hook::run_uninstall()?,
1197            Toggle::Status => crate::commands::hook::run_status()?,
1198        }
1199    }
1200    Ok(())
1201}
1202
1203#[cfg(test)]
1204mod tests {
1205    use super::*;
1206
1207    #[test]
1208    fn enable_synonyms_all_resolve_to_enable() {
1209        for word in ["enable", "install", "on", "INSTALL", "On"] {
1210            assert_eq!(parse_toggle(word).unwrap(), Toggle::Enable, "{word}");
1211        }
1212    }
1213
1214    #[test]
1215    fn disable_synonyms_all_resolve_to_disable() {
1216        for word in ["disable", "uninstall", "remove", "off", "Uninstall"] {
1217            assert_eq!(parse_toggle(word).unwrap(), Toggle::Disable, "{word}");
1218        }
1219    }
1220
1221    #[test]
1222    fn status_is_the_default_and_is_also_spellable() {
1223        for word in ["", "status", "show"] {
1224            assert_eq!(parse_toggle(word).unwrap(), Toggle::Status, "{word}");
1225        }
1226    }
1227
1228    #[test]
1229    fn a_typo_is_an_error_rather_than_a_silent_status_report() {
1230        // `devp config daemon enabel` must not print status and exit 0 — that reads as
1231        // success while the daemon stays uninstalled.
1232        let err = parse_toggle("enabel").unwrap_err().to_string();
1233        assert!(err.contains("enabel"), "{err}");
1234        assert!(err.contains("enable"), "{err}");
1235    }
1236
1237    #[test]
1238    fn a_workspace_toggle_refuses_to_write_over_a_broken_config() {
1239        // The toggle rewrites the whole file. Starting from the defaults on a file it
1240        // could not read would silently discard every override the user had put in it.
1241        let tmp = tempfile::TempDir::new().unwrap();
1242        let broken = r#"{ "project_name": "api", "override_idle_days": 90, }"#;
1243        std::fs::write(
1244            tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE),
1245            broken,
1246        )
1247        .unwrap();
1248
1249        let err = load_workspace_config_for_write(tmp.path())
1250            .unwrap_err()
1251            .to_string();
1252        assert!(err.contains("Syntax error"), "{err}");
1253        assert!(err.contains("--update"), "{err}");
1254
1255        // Untouched, so the user still has their 90 days to recover.
1256        let on_disk =
1257            std::fs::read_to_string(tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE))
1258                .unwrap();
1259        assert_eq!(on_disk, broken);
1260    }
1261
1262    #[test]
1263    fn a_workspace_with_no_config_yet_starts_from_the_defaults() {
1264        let tmp = tempfile::TempDir::new().unwrap();
1265        assert_eq!(
1266            load_workspace_config_for_write(tmp.path()).unwrap(),
1267            PerRepoConfig::default()
1268        );
1269    }
1270
1271    #[test]
1272    fn every_setting_round_trips_through_its_own_getter() {
1273        // The table is what `get`, `set`, `show` and the wizard all read, so a getter
1274        // that reports a different field than its setter writes would be invisible in
1275        // every one of them at once.
1276        let mut settings = Settings::default();
1277        for setting in SETTINGS {
1278            let before = (setting.get)(&settings);
1279            let probe = match setting.kind {
1280                Kind::Toggle => if before == "true" { "false" } else { "true" }.to_string(),
1281                // A number every numeric setting accepts: above every minimum, below
1282                // `scan_depth`'s ceiling.
1283                Kind::Number => "7".to_string(),
1284                // A real adapter name, so the round trip also proves the list prints
1285                // back in the spelling `config set` takes.
1286                Kind::Adapters => "cargo".to_string(),
1287                // Same, with a window attached: proves the map prints back in the
1288                // `name=days` spelling `config set` parses.
1289                Kind::AdapterDays => "cargo=45".to_string(),
1290            };
1291            (setting.set)(&mut settings, &probe)
1292                .unwrap_or_else(|e| panic!("{} rejected `{probe}`: {e}", setting.key));
1293            assert_eq!(
1294                (setting.get)(&settings),
1295                probe,
1296                "{} reads back a different field than it writes",
1297                setting.key
1298            );
1299        }
1300    }
1301
1302    #[test]
1303    fn every_setting_is_documented_and_uniquely_named() {
1304        let mut seen = std::collections::HashSet::new();
1305        for setting in SETTINGS {
1306            assert!(seen.insert(setting.key), "duplicate key {}", setting.key);
1307            assert!(!setting.help.is_empty(), "{} has no help", setting.key);
1308            // The wizard prints the help under the key; a sentence keeps that readable.
1309            assert!(
1310                setting.help.ends_with('.'),
1311                "{} help should read as a sentence",
1312                setting.key
1313            );
1314        }
1315    }
1316
1317    #[test]
1318    fn the_settings_table_covers_every_field_of_settings() {
1319        // Serialising `Settings` names every field, so a field added without a table
1320        // entry — unsettable, unshown, never asked about — fails here rather than in
1321        // a bug report.
1322        let json = serde_json::to_value(Settings::default()).unwrap();
1323        let fields: Vec<String> = json.as_object().unwrap().keys().cloned().collect();
1324        for field in fields {
1325            assert!(
1326                SETTINGS.iter().any(|s| s.key == field),
1327                "`{field}` is a setting with no entry in SETTINGS, so `devp config set \
1328                 {field}` cannot reach it"
1329            );
1330        }
1331    }
1332
1333    #[test]
1334    fn a_rejected_value_leaves_the_previous_one_in_place() {
1335        let mut settings = Settings::default();
1336        assert!((find_setting("scan_depth").unwrap().set)(&mut settings, "0").is_err());
1337        assert_eq!(settings.scan_depth, Settings::default().scan_depth);
1338
1339        assert!((find_setting("command_timeout_secs").unwrap().set)(&mut settings, "0").is_err());
1340        assert!((find_setting("check_interval_days").unwrap().set)(&mut settings, "0").is_err());
1341        assert!(
1342            (find_setting("update_check_interval_days").unwrap().set)(&mut settings, "0").is_err()
1343        );
1344    }
1345
1346    #[test]
1347    fn booleans_accept_the_words_people_actually_type() {
1348        assert!(parse_bool("k", "yes").unwrap());
1349        assert!(parse_bool("k", "ON").unwrap());
1350        assert!(!parse_bool("k", "0").unwrap());
1351        assert!(parse_bool("k", "maybe").is_err());
1352    }
1353
1354    #[test]
1355    fn an_unknown_key_lists_the_ones_that_exist() {
1356        let err = match find_setting("idel_days") {
1357            Ok(_) => panic!("`idel_days` is not a setting"),
1358            Err(e) => e.to_string(),
1359        };
1360        assert!(err.contains("idle_days"), "{err}");
1361    }
1362
1363    #[test]
1364    fn a_path_is_never_mistaken_for_an_action() {
1365        // The router uses this to decide whether a lone argument is a path or an action.
1366        assert!(!is_toggle_word("~/Code/my-repo"));
1367        assert!(!is_toggle_word("."));
1368        assert!(!is_toggle_word(""));
1369        assert!(is_toggle_word("install"));
1370    }
1371}