Skip to main content

dev_prune/commands/
config.rs

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