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    /// One line, shown by the walkthrough and by `config show --help-text`.
25    help: &'static str,
26    get: fn(&Settings) -> String,
27    set: fn(&mut Settings, &str) -> Result<()>,
28}
29
30/// Every global setting, in the order a person would want to be asked about them.
31const SETTINGS: &[Setting] = &[
32    Setting {
33        key: "idle_days",
34        help: "Days a repository must sit untouched before it is eligible for pruning.",
35        get: |s| s.idle_days.to_string(),
36        set: |s, v| {
37            s.idle_days = v
38                .parse()
39                .map_err(|_| anyhow::anyhow!("idle_days must be a whole number of days"))?;
40            Ok(())
41        },
42    },
43    Setting {
44        key: "min_size_mb",
45        help: "Smallest bloat directory worth deleting, in MiB. 0 removes the floor.",
46        get: |s| s.min_size_mb.to_string(),
47        set: |s, v| {
48            s.min_size_mb = v.parse().map_err(|_| {
49                anyhow::anyhow!("min_size_mb must be a whole number of MiB (0 disables the floor)")
50            })?;
51            Ok(())
52        },
53    },
54    Setting {
55        key: "scan_depth",
56        help: "How many directory levels below a repo root project discovery descends.",
57        get: |s| s.scan_depth.to_string(),
58        set: |s, v| {
59            let depth: usize = v
60                .parse()
61                .map_err(|_| anyhow::anyhow!("scan_depth must be a positive integer"))?;
62            // Rejected rather than clamped. `clamp_depth` exists so a hand-edited config
63            // file cannot break the walk, but when someone types the number at us we owe
64            // them the truth instead of silently storing something else.
65            if depth == 0 {
66                bail!("scan_depth must be at least 1 — 0 would find no projects at all.");
67            }
68            if depth > crate::constants::MAX_SCAN_DEPTH_LIMIT {
69                bail!(
70                    "scan_depth must be at most {} — deeper walks stall on generated trees.",
71                    crate::constants::MAX_SCAN_DEPTH_LIMIT
72                );
73            }
74            s.scan_depth = depth;
75            Ok(())
76        },
77    },
78    Setting {
79        key: "require_confirmation",
80        help: "Ask before deleting anything. Turning this off makes every run unattended.",
81        get: |s| s.require_confirmation.to_string(),
82        set: |s, v| {
83            s.require_confirmation = parse_bool("require_confirmation", v)?;
84            Ok(())
85        },
86    },
87    Setting {
88        key: "allow_manifest_rewrite",
89        help: "Let cargo and go run the sync command that rewrites tracked manifests.",
90        get: |s| s.allow_manifest_rewrite.to_string(),
91        set: |s, v| {
92            s.allow_manifest_rewrite = parse_bool("allow_manifest_rewrite", v)?;
93            Ok(())
94        },
95    },
96    Setting {
97        key: "command_timeout_secs",
98        help: "How long a lockfile command may run before it is killed.",
99        get: |s| s.command_timeout_secs.to_string(),
100        set: |s, v| {
101            let secs: u64 = v
102                .parse()
103                .map_err(|_| anyhow::anyhow!("command_timeout_secs must be a positive integer"))?;
104            // Zero is not "no limit": the runner compares elapsed time against it before
105            // the child has had a chance to finish, so every lockfile sync would be
106            // killed on the spot and nothing would ever be pruneable.
107            if secs == 0 {
108                bail!(
109                    "command_timeout_secs must be at least 1 — 0 would kill every command \
110                     the instant it starts."
111                );
112            }
113            s.command_timeout_secs = secs;
114            Ok(())
115        },
116    },
117    Setting {
118        key: "auto_setup",
119        help: "Install missing integrations by itself, once per installed version.",
120        get: |s| s.auto_setup.to_string(),
121        set: |s, v| {
122            s.auto_setup = parse_bool("auto_setup", v)?;
123            Ok(())
124        },
125    },
126    Setting {
127        key: "auto_daemon",
128        help: "Register the OS scheduler so passes run without being remembered.",
129        get: |s| s.auto_daemon.to_string(),
130        set: |s, v| {
131            s.auto_daemon = parse_bool("auto_daemon", v)?;
132            Ok(())
133        },
134    },
135    Setting {
136        key: "check_interval_days",
137        help: "Days between scheduled background passes.",
138        get: |s| s.check_interval_days.to_string(),
139        set: |s, v| {
140            let days: u64 = v
141                .parse()
142                .map_err(|_| anyhow::anyhow!("check_interval_days must be a positive integer"))?;
143            // Zero would schedule a prune pass with no gap between passes.
144            if days == 0 {
145                bail!("check_interval_days must be at least 1.");
146            }
147            s.check_interval_days = days;
148            Ok(())
149        },
150    },
151    Setting {
152        key: "auto_hooks",
153        help: "Install the Git hooks that register repositories as you clone them.",
154        get: |s| s.auto_hooks.to_string(),
155        set: |s, v| {
156            s.auto_hooks = parse_bool("auto_hooks", v)?;
157            Ok(())
158        },
159    },
160    Setting {
161        key: "auto_hooks_chain",
162        help: "If another tool owns core.hooksPath, install in front of it and forward.",
163        get: |s| s.auto_hooks_chain.to_string(),
164        set: |s, v| {
165            s.auto_hooks_chain = parse_bool("auto_hooks_chain", v)?;
166            Ok(())
167        },
168    },
169    Setting {
170        key: "update_check",
171        help: "Ask GitHub for the latest release from time to time. Sends nothing but the request.",
172        get: |s| s.update_check.to_string(),
173        set: |s, v| {
174            s.update_check = parse_bool("update_check", v)?;
175            Ok(())
176        },
177    },
178    Setting {
179        key: "update_check_interval_days",
180        help: "Days between automatic release checks.",
181        get: |s| s.update_check_interval_days.to_string(),
182        set: |s, v| {
183            let days: i64 = v.parse().map_err(|_| {
184                anyhow::anyhow!("update_check_interval_days must be a positive integer")
185            })?;
186            if days < 1 {
187                bail!("update_check_interval_days must be at least 1.");
188            }
189            s.update_check_interval_days = days;
190            Ok(())
191        },
192    },
193    Setting {
194        key: "update_check_timeout_secs",
195        help: "Seconds the release check waits for GitHub. Raise it behind a slow proxy.",
196        get: |s| s.update_check_timeout_secs.to_string(),
197        set: |s, v| {
198            let secs: u64 = v.parse().map_err(|_| {
199                anyhow::anyhow!("update_check_timeout_secs must be a positive integer")
200            })?;
201            if secs == 0 {
202                bail!("update_check_timeout_secs must be at least 1.");
203            }
204            s.update_check_timeout_secs = secs;
205            Ok(())
206        },
207    },
208];
209
210fn parse_bool(key: &str, value: &str) -> Result<bool> {
211    match value.trim().to_lowercase().as_str() {
212        "true" | "yes" | "y" | "on" | "1" => Ok(true),
213        "false" | "no" | "n" | "off" | "0" => Ok(false),
214        _ => bail!("{key} must be true or false"),
215    }
216}
217
218/// Every stored setting that its own setter would refuse, with the reason.
219///
220/// `devp config set` guards the ranges, but nothing guards a hand-edited `registry.json`
221/// — and the values that get in that way are the quiet ones: `scan_depth: 0` finds no
222/// projects, `command_timeout_secs: 0` kills every lockfile command the instant it
223/// starts. Both leave a tool that runs, reports success and prunes nothing.
224///
225/// Round-tripping each value through the setter that owns it is deliberate. A separate
226/// list of ranges would be a second copy of the rules, free to drift from the ones
227/// actually enforced.
228pub fn invalid_settings(settings: &Settings) -> Vec<(&'static str, String)> {
229    SETTINGS
230        .iter()
231        .filter_map(|setting| {
232            let mut probe = settings.clone();
233            (setting.set)(&mut probe, &(setting.get)(settings))
234                .err()
235                .map(|e| (setting.key, e.to_string()))
236        })
237        .collect()
238}
239
240/// The number of settings [`invalid_settings`] checks, for reports that say so.
241pub fn setting_count() -> usize {
242    SETTINGS.len()
243}
244
245fn find_setting(key: &str) -> Result<&'static Setting> {
246    SETTINGS
247        .iter()
248        .find(|s| s.key == key)
249        .ok_or_else(|| anyhow::anyhow!("Unknown config key: {key}. Valid keys: {}", valid_keys()))
250}
251
252fn valid_keys() -> String {
253    SETTINGS
254        .iter()
255        .map(|s| s.key)
256        .collect::<Vec<_>>()
257        .join(", ")
258}
259
260/// What a `daemon` / `hook` sub-action word means.
261#[derive(Debug, PartialEq, Eq)]
262pub enum Toggle {
263    Enable,
264    Disable,
265    Status,
266}
267
268/// Resolve the sub-action word users actually type.
269///
270/// `install` / `uninstall` are what this tool's own output and its documentation have
271/// always called these operations, and `on` / `off` is the obvious guess; each pair
272/// means the same thing as `enable` / `disable`, so all of them are accepted.
273///
274/// Anything else is an error rather than a fall-through to `status`. Silently printing
275/// status for `devp config daemon enabel` looks like it worked and leaves the daemon
276/// uninstalled.
277pub fn parse_toggle(action: &str) -> Result<Toggle> {
278    match action.to_lowercase().as_str() {
279        "enable" | "install" | "on" => Ok(Toggle::Enable),
280        "disable" | "uninstall" | "remove" | "off" => Ok(Toggle::Disable),
281        "" | "status" | "show" => Ok(Toggle::Status),
282        other => bail!(
283            "Unknown action `{other}`. Expected `enable`, `disable` or `status` \
284             (`install` / `uninstall` / `on` / `off` also work)."
285        ),
286    }
287}
288
289/// Whether a bare argument is a sub-action rather than a workspace path.
290///
291/// `devp config hook <word>` is ambiguous by design — `<word>` is either the action or
292/// the repository to apply it to — so both the argument router and [`parse_toggle`]
293/// have to agree on which words are actions.
294pub fn is_toggle_word(word: &str) -> bool {
295    parse_toggle(word).is_ok() && !word.is_empty()
296}
297
298/// Resolve the workspace argument of `daemon` / `hook`, which is whatever was not
299/// recognised as an action.
300///
301/// A word that is neither an action nor a directory is a mistyped action. Treating it
302/// as a path would print `Daemon Status (enabel): Enabled for workspace` — a success
303/// message about a repository that does not exist.
304fn resolve_workspace(path: &str) -> Result<std::path::PathBuf> {
305    let raw = Path::new(path);
306    if !raw.is_dir() {
307        bail!(
308            "`{path}` is neither an action nor an existing directory.\n\
309             Expected `enable`, `disable` or `status`, or a path to a repository."
310        );
311    }
312    Ok(raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf()))
313}
314
315/// Display a single config value.
316pub fn run_get(key: &str) -> Result<()> {
317    let registry = Registry::load()?;
318    let setting = find_setting(key)?;
319    println!("{key} = {}", (setting.get)(&registry.settings));
320    Ok(())
321}
322
323/// Set a config value.
324pub fn run_set(key: &str, value: &str) -> Result<()> {
325    let mut registry = Registry::load()?;
326    let setting = find_setting(key)?;
327    (setting.set)(&mut registry.settings, value)?;
328    registry.save()?;
329
330    // The stored value, not the typed one: `devp config set auto_daemon yes` stores
331    // `true`, and echoing "auto_daemon = yes" would describe a file that does not exist.
332    output::print_success(&format!("{key} = {}", (setting.get)(&registry.settings)));
333    Ok(())
334}
335
336/// Widest key name, so every value in `config show` lines up.
337fn key_column_width() -> usize {
338    SETTINGS.iter().map(|s| s.key.len()).max().unwrap_or(0)
339}
340
341/// Show all config values.
342pub fn run_show() -> Result<()> {
343    let registry = Registry::load()?;
344    let width = key_column_width();
345
346    output::print_header("dev-prune Global Configuration");
347    for setting in SETTINGS {
348        println!(
349            "  {:<width$} = {}",
350            setting.key,
351            (setting.get)(&registry.settings)
352        );
353    }
354    println!("  {:<width$} = {}", "tracked_repos", registry.repo_count());
355
356    let reg_path = Registry::registry_path()
357        .map(|p| output::clean_path(&p))
358        .unwrap_or_else(|_| "unknown".to_string());
359    println!("\n  {:<width$} = {reg_path}", "registry_file");
360    println!();
361    output::print_info("Change any of these with `devp config set <key> <value>`.");
362    output::print_info("Walk through them one at a time with `devp config wizard`.");
363
364    Ok(())
365}
366
367/// Walk the global settings, offering each current value for confirmation.
368///
369/// Run once by hand as `devp config wizard`, and once automatically the first time a
370/// human types a command on a fresh install — the point at which every default is about
371/// to start applying to their machine, and the only point at which they can be told so
372/// before rather than after.
373///
374/// Refuses without a terminal instead of hanging on a read that will never return.
375pub fn run_wizard() -> Result<()> {
376    use std::io::{self, IsTerminal, Write};
377
378    if !io::stdin().is_terminal() {
379        bail!(
380            "`devp config wizard` needs a terminal to ask questions on.\n\
381             Use `devp config show` to read the settings and `devp config set <key> <value>` \
382             to change one."
383        );
384    }
385
386    let mut registry = Registry::load()?;
387    let width = key_column_width();
388
389    output::print_header("dev-prune configuration");
390    output::print_info("These are the defaults every run will use. Nothing has been changed yet.");
391    println!();
392    for setting in SETTINGS {
393        println!(
394            "  {:<width$} = {}",
395            setting.key,
396            (setting.get)(&registry.settings)
397        );
398        println!("  {:<width$}   {}", "", setting.help);
399    }
400    println!();
401
402    print!("Keep all of these? [Y/n] ");
403    io::stdout().flush()?;
404    let mut answer = String::new();
405    io::stdin().read_line(&mut answer)?;
406    let keep = !matches!(answer.trim().to_lowercase().as_str(), "n" | "no");
407
408    if keep {
409        mark_reviewed();
410        output::print_success("Keeping the defaults. `devp config set <key> <value>` changes any.");
411        return Ok(());
412    }
413
414    println!();
415    output::print_info("Enter a new value, or press Enter to keep the one shown.");
416    println!();
417
418    let mut changed = 0usize;
419    for setting in SETTINGS {
420        let current = (setting.get)(&registry.settings);
421        loop {
422            print!("  {} [{current}]: ", setting.key);
423            io::stdout().flush()?;
424            let mut line = String::new();
425            // EOF mid-way — a closed pipe or Ctrl-D — keeps what has been answered so far
426            // rather than looping forever on an empty read.
427            if io::stdin().read_line(&mut line)? == 0 {
428                println!();
429                break;
430            }
431            let typed = line.trim();
432            if typed.is_empty() {
433                break;
434            }
435            match (setting.set)(&mut registry.settings, typed) {
436                Ok(()) => {
437                    changed += 1;
438                    break;
439                }
440                // Re-asked rather than aborted: losing the eight answers already given
441                // because the ninth was a typo is not a reasonable trade.
442                Err(e) => output::print_error(&format!("{e}")),
443            }
444        }
445    }
446
447    registry.save()?;
448    mark_reviewed();
449    println!();
450    if changed == 0 {
451        output::print_success("Nothing changed — the defaults are in place.");
452    } else {
453        output::print_success(&format!(
454            "Saved {changed} {}. `devp config show` lists them all.",
455            output::plural(changed, "change", "changes")
456        ));
457    }
458    Ok(())
459}
460
461/// Marker recording that the settings have been put in front of the user once.
462const REVIEW_MARKER: &str = "config-reviewed";
463
464/// Whether the first-run walkthrough is still owed.
465///
466/// Keyed on the marker file rather than on the version stamp: an upgrade should not
467/// re-ask about settings that were confirmed once. A `devp uninstall --purge` removes the
468/// config directory and with it this marker, which is what makes a genuine reinstall ask
469/// again.
470pub fn config_review_is_due() -> bool {
471    Registry::config_dir()
472        .map(|dir| !dir.join(REVIEW_MARKER).exists())
473        .unwrap_or(false)
474}
475
476fn mark_reviewed() {
477    if let Ok(dir) = Registry::config_dir() {
478        let _ = std::fs::create_dir_all(&dir);
479        let _ = std::fs::write(dir.join(REVIEW_MARKER), crate::constants::VERSION);
480    }
481}
482
483/// Suppress the first-run walkthrough without running it.
484///
485/// For the paths that must not stop to ask: the Git hook, the scheduler, and anything
486/// with no terminal attached.
487pub fn skip_config_review() {
488    mark_reviewed();
489}
490
491/// Global audit pass for all registered repos.
492pub fn run_global_update() -> Result<()> {
493    output::print_header("dev-prune Global Configuration Audit & Sync");
494
495    let registry = Registry::load()?;
496    let mut total_audited = 0;
497    let mut errors_found = 0;
498
499    for repo_path in registry.repositories.keys() {
500        let clean = output::clean_path(repo_path);
501
502        // A registered path that is gone — deleted, on an unplugged drive — is not a
503        // config error, and writing a fresh `.devprune.json` at it would either fail or
504        // conjure a directory where the repository used to be.
505        if !repo_path.exists() {
506            output::print_warning(&format!(
507                "Skipped {clean} — the path no longer exists. `devp unlink --missing` \
508                 clears such entries."
509            ));
510            continue;
511        }
512        total_audited += 1;
513
514        match PerRepoConfig::load_with_diagnostics(repo_path) {
515            Ok(Some(cfg)) => {
516                if let Err(e) = cfg.save_to_repo(repo_path) {
517                    output::print_error(&format!("Failed to write config for {clean}: {e}"));
518                    errors_found += 1;
519                } else {
520                    output::print_success(&format!("Audited & synced config for {clean}"));
521                }
522            }
523            Ok(None) => {
524                // No file means the global defaults apply, which is a valid state, not a
525                // gap to fill. Writing one here would drop an untracked file into every
526                // registered repository in a single command.
527                output::print_info(&format!(
528                    "{clean} has no .devprune.json — global defaults apply."
529                ));
530            }
531            Err(err_msg) => {
532                errors_found += 1;
533                output::print_error(&format!("Syntax/Schema Error in {clean}:"));
534                for line in err_msg.lines() {
535                    eprintln!("    {line}");
536                }
537                output::print_info(&format!(
538                    "Hint: fix the syntax by hand, or run `devp config {clean} --update` to \
539                     replace the file with a valid default."
540                ));
541            }
542        }
543    }
544
545    if errors_found > 0 {
546        // Non-zero, so a CI step or a shell `&&` chain notices. An audit that found
547        // broken config files has not succeeded, however calmly it says so.
548        anyhow::bail!(
549            "Audit complete: {total_audited} repos checked, {errors_found} could not be read \
550             or written."
551        );
552    }
553    output::print_success(&format!(
554        "Audit complete: All {total_audited} registered repositories are healthy & synced!"
555    ));
556
557    Ok(())
558}
559
560/// Inspect or create per-repository configuration (.devprune.json).
561pub fn run_path_config(path_str: &str, force_update: bool) -> Result<()> {
562    let raw_path = Path::new(path_str);
563
564    let path = if raw_path.exists() {
565        raw_path
566            .canonicalize()
567            .unwrap_or_else(|_| raw_path.to_path_buf())
568    } else {
569        raw_path.to_path_buf()
570    };
571
572    let clean = output::clean_path(&path);
573
574    if !path.exists() {
575        bail!("Path does not exist: {clean}");
576    }
577
578    if !crate::scanner::is_git_repo(&path) {
579        // The old text said "Initializing Git repo first..." and then did no such thing.
580        bail!(
581            "`{clean}` is not a Git repository.\n  \
582             Run `git init` there first, then `devp config {clean}` again."
583        );
584    }
585
586    let mut registry = Registry::load()?;
587    if !registry.repositories.contains_key(&path) {
588        output::print_info(&format!(
589            "{clean} is not yet registered with dev-prune. Registering now..."
590        ));
591        registry.add_repo(path.clone());
592        registry.save()?;
593    }
594
595    let cfg_file = path.join(crate::constants::PER_REPO_CONFIG_FILE);
596
597    if cfg_file.exists() && !force_update {
598        output::print_header(&format!("dev-prune Per-Repo Config for {clean}"));
599        match PerRepoConfig::load_with_diagnostics(&path) {
600            Ok(cfg) => {
601                let json_str = serde_json::to_string_pretty(&cfg)?;
602                println!("{json_str}");
603                output::print_info("File location: .devprune.json");
604            }
605            Err(err_msg) => {
606                output::print_error(&format!("Invalid configuration in {clean}:"));
607                for line in err_msg.lines() {
608                    eprintln!("    {line}");
609                }
610                // Non-zero: the file this command was asked to show could not be read,
611                // and the same file is what every prune of this repo will trip over.
612                anyhow::bail!(
613                    "Run `devp config {clean} --update` to reset this file back to defaults \
614                     (your current overrides in it are discarded)."
615                );
616            }
617        }
618    } else {
619        output::print_info(&format!("Initializing .devprune.json for {clean}..."));
620        let cfg = PerRepoConfig::default();
621        cfg.save_to_repo(&path)?;
622        output::print_success(&format!("Created .devprune.json in {clean}"));
623    }
624
625    Ok(())
626}
627
628/// Load a workspace's `.devprune.json` for a toggle that is about to write it back.
629///
630/// Refuses a file that does not parse, rather than starting from the defaults. Starting
631/// from the defaults meant `devp config <repo> daemon off` wrote a fresh file straight
632/// over the broken one, so a single typo cost the user every other override in it.
633fn load_workspace_config_for_write(repo_path: &Path) -> Result<PerRepoConfig> {
634    match PerRepoConfig::load_with_diagnostics(repo_path) {
635        Ok(Some(cfg)) => Ok(cfg),
636        Ok(None) => Ok(PerRepoConfig::default()),
637        Err(e) => bail!(
638            "{e}\n  \
639             Fix that file, or run `devp config {} --update` to reset it back to defaults \
640             (your current overrides in it are discarded).",
641            output::clean_path(repo_path)
642        ),
643    }
644}
645
646/// Toggle or status check for background daemon (global or local workspace).
647pub fn run_daemon_toggle(path: Option<&str>, action: &str) -> Result<()> {
648    if let Some(p) = path {
649        let repo_path = resolve_workspace(p)?;
650        let mut cfg = load_workspace_config_for_write(&repo_path)?;
651        match parse_toggle(action)? {
652            Toggle::Enable => {
653                cfg.disable_daemon = false;
654                cfg.save_to_repo(&repo_path)?;
655                output::print_success(&format!(
656                    "Enabled background daemon for workspace: {}",
657                    output::clean_path(&repo_path)
658                ));
659            }
660            Toggle::Disable => {
661                cfg.disable_daemon = true;
662                cfg.save_to_repo(&repo_path)?;
663                output::print_success(&format!(
664                    "Disabled background daemon for workspace: {}",
665                    output::clean_path(&repo_path)
666                ));
667            }
668            Toggle::Status => {
669                let st = if cfg.disable_daemon {
670                    "Disabled for workspace"
671                } else {
672                    "Enabled for workspace"
673                };
674                output::print_info(&format!(
675                    "Daemon Status ({}): {}",
676                    output::clean_path(&repo_path),
677                    st
678                ));
679            }
680        }
681    } else {
682        match parse_toggle(action)? {
683            Toggle::Enable => crate::commands::daemon::run_install()?,
684            Toggle::Disable => crate::commands::daemon::run_uninstall()?,
685            Toggle::Status => crate::commands::daemon::run_status()?,
686        }
687    }
688    Ok(())
689}
690
691/// Toggle or status check for background Git hooks (global or local workspace).
692pub fn run_hook_toggle(path: Option<&str>, action: &str, chain: bool) -> Result<()> {
693    if let Some(p) = path {
694        if chain {
695            bail!(
696                "`--chain` changes the single global `core.hooksPath`, so it has no \
697                 per-workspace form. Drop the path: `devp hook install --chain`."
698            );
699        }
700        let repo_path = resolve_workspace(p)?;
701        let mut cfg = load_workspace_config_for_write(&repo_path)?;
702        match parse_toggle(action)? {
703            Toggle::Enable => {
704                cfg.disable_hooks = false;
705                cfg.save_to_repo(&repo_path)?;
706                output::print_success(&format!(
707                    "Enabled background Git hooks for workspace: {}",
708                    output::clean_path(&repo_path)
709                ));
710            }
711            Toggle::Disable => {
712                cfg.disable_hooks = true;
713                cfg.save_to_repo(&repo_path)?;
714                output::print_success(&format!(
715                    "Disabled background Git hooks for workspace: {}",
716                    output::clean_path(&repo_path)
717                ));
718            }
719            Toggle::Status => {
720                let st = if cfg.disable_hooks {
721                    "Disabled for workspace"
722                } else {
723                    "Enabled for workspace"
724                };
725                output::print_info(&format!(
726                    "Git Hook Status ({}): {}",
727                    output::clean_path(&repo_path),
728                    st
729                ));
730            }
731        }
732    } else {
733        match parse_toggle(action)? {
734            Toggle::Enable => crate::commands::hook::run_install(chain)?,
735            Toggle::Disable => crate::commands::hook::run_uninstall()?,
736            Toggle::Status => crate::commands::hook::run_status()?,
737        }
738    }
739    Ok(())
740}
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745
746    #[test]
747    fn enable_synonyms_all_resolve_to_enable() {
748        for word in ["enable", "install", "on", "INSTALL", "On"] {
749            assert_eq!(parse_toggle(word).unwrap(), Toggle::Enable, "{word}");
750        }
751    }
752
753    #[test]
754    fn disable_synonyms_all_resolve_to_disable() {
755        for word in ["disable", "uninstall", "remove", "off", "Uninstall"] {
756            assert_eq!(parse_toggle(word).unwrap(), Toggle::Disable, "{word}");
757        }
758    }
759
760    #[test]
761    fn status_is_the_default_and_is_also_spellable() {
762        for word in ["", "status", "show"] {
763            assert_eq!(parse_toggle(word).unwrap(), Toggle::Status, "{word}");
764        }
765    }
766
767    #[test]
768    fn a_typo_is_an_error_rather_than_a_silent_status_report() {
769        // `devp config daemon enabel` must not print status and exit 0 — that reads as
770        // success while the daemon stays uninstalled.
771        let err = parse_toggle("enabel").unwrap_err().to_string();
772        assert!(err.contains("enabel"), "{err}");
773        assert!(err.contains("enable"), "{err}");
774    }
775
776    #[test]
777    fn a_workspace_toggle_refuses_to_write_over_a_broken_config() {
778        // The toggle rewrites the whole file. Starting from the defaults on a file it
779        // could not read would silently discard every override the user had put in it.
780        let tmp = tempfile::TempDir::new().unwrap();
781        let broken = r#"{ "project_name": "api", "override_idle_days": 90, }"#;
782        std::fs::write(
783            tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE),
784            broken,
785        )
786        .unwrap();
787
788        let err = load_workspace_config_for_write(tmp.path())
789            .unwrap_err()
790            .to_string();
791        assert!(err.contains("Syntax error"), "{err}");
792        assert!(err.contains("--update"), "{err}");
793
794        // Untouched, so the user still has their 90 days to recover.
795        let on_disk =
796            std::fs::read_to_string(tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE))
797                .unwrap();
798        assert_eq!(on_disk, broken);
799    }
800
801    #[test]
802    fn a_workspace_with_no_config_yet_starts_from_the_defaults() {
803        let tmp = tempfile::TempDir::new().unwrap();
804        assert_eq!(
805            load_workspace_config_for_write(tmp.path()).unwrap(),
806            PerRepoConfig::default()
807        );
808    }
809
810    #[test]
811    fn every_setting_round_trips_through_its_own_getter() {
812        // The table is what `get`, `set`, `show` and the wizard all read, so a getter
813        // that reports a different field than its setter writes would be invisible in
814        // every one of them at once.
815        let mut settings = Settings::default();
816        for setting in SETTINGS {
817            let before = (setting.get)(&settings);
818            let probe = match before.as_str() {
819                "true" => "false".to_string(),
820                "false" => "true".to_string(),
821                // A number every numeric setting accepts: above every minimum, below
822                // `scan_depth`'s ceiling.
823                _ => "7".to_string(),
824            };
825            (setting.set)(&mut settings, &probe)
826                .unwrap_or_else(|e| panic!("{} rejected `{probe}`: {e}", setting.key));
827            assert_eq!(
828                (setting.get)(&settings),
829                probe,
830                "{} reads back a different field than it writes",
831                setting.key
832            );
833        }
834    }
835
836    #[test]
837    fn every_setting_is_documented_and_uniquely_named() {
838        let mut seen = std::collections::HashSet::new();
839        for setting in SETTINGS {
840            assert!(seen.insert(setting.key), "duplicate key {}", setting.key);
841            assert!(!setting.help.is_empty(), "{} has no help", setting.key);
842            // The wizard prints the help under the key; a sentence keeps that readable.
843            assert!(
844                setting.help.ends_with('.'),
845                "{} help should read as a sentence",
846                setting.key
847            );
848        }
849    }
850
851    #[test]
852    fn the_settings_table_covers_every_field_of_settings() {
853        // Serialising `Settings` names every field, so a field added without a table
854        // entry — unsettable, unshown, never asked about — fails here rather than in
855        // a bug report.
856        let json = serde_json::to_value(Settings::default()).unwrap();
857        let fields: Vec<String> = json.as_object().unwrap().keys().cloned().collect();
858        for field in fields {
859            assert!(
860                SETTINGS.iter().any(|s| s.key == field),
861                "`{field}` is a setting with no entry in SETTINGS, so `devp config set \
862                 {field}` cannot reach it"
863            );
864        }
865    }
866
867    #[test]
868    fn a_rejected_value_leaves_the_previous_one_in_place() {
869        let mut settings = Settings::default();
870        assert!((find_setting("scan_depth").unwrap().set)(&mut settings, "0").is_err());
871        assert_eq!(settings.scan_depth, Settings::default().scan_depth);
872
873        assert!((find_setting("command_timeout_secs").unwrap().set)(&mut settings, "0").is_err());
874        assert!((find_setting("check_interval_days").unwrap().set)(&mut settings, "0").is_err());
875        assert!(
876            (find_setting("update_check_interval_days").unwrap().set)(&mut settings, "0").is_err()
877        );
878    }
879
880    #[test]
881    fn booleans_accept_the_words_people_actually_type() {
882        assert!(parse_bool("k", "yes").unwrap());
883        assert!(parse_bool("k", "ON").unwrap());
884        assert!(!parse_bool("k", "0").unwrap());
885        assert!(parse_bool("k", "maybe").is_err());
886    }
887
888    #[test]
889    fn an_unknown_key_lists_the_ones_that_exist() {
890        let err = match find_setting("idel_days") {
891            Ok(_) => panic!("`idel_days` is not a setting"),
892            Err(e) => e.to_string(),
893        };
894        assert!(err.contains("idle_days"), "{err}");
895    }
896
897    #[test]
898    fn a_path_is_never_mistaken_for_an_action() {
899        // The router uses this to decide whether a lone argument is a path or an action.
900        assert!(!is_toggle_word("~/Code/my-repo"));
901        assert!(!is_toggle_word("."));
902        assert!(!is_toggle_word(""));
903        assert!(is_toggle_word("install"));
904    }
905}