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        total_audited += 1;
501        let clean = output::clean_path(repo_path);
502
503        match PerRepoConfig::load_with_diagnostics(repo_path) {
504            Ok(Some(cfg)) => {
505                if let Err(e) = cfg.save_to_repo(repo_path) {
506                    output::print_error(&format!("Failed to write config for {clean}: {e}"));
507                    errors_found += 1;
508                } else {
509                    output::print_success(&format!("Audited & synced config for {clean}"));
510                }
511            }
512            Ok(None) => {
513                let cfg = PerRepoConfig::default();
514                if let Err(e) = cfg.save_to_repo(repo_path) {
515                    output::print_error(&format!("Failed to write config for {clean}: {e}"));
516                    errors_found += 1;
517                } else {
518                    output::print_success(&format!(
519                        "Audited & initialized default config for {clean}"
520                    ));
521                }
522            }
523            Err(err_msg) => {
524                errors_found += 1;
525                output::print_error(&format!("Syntax/Schema Error in {clean}:"));
526                for line in err_msg.lines() {
527                    eprintln!("    {line}");
528                }
529                output::print_info(&format!(
530                    "Hint: fix the syntax by hand, or run `devp config {clean} --update` to \
531                     replace the file with a valid default."
532                ));
533            }
534        }
535    }
536
537    if errors_found > 0 {
538        // Non-zero, so a CI step or a shell `&&` chain notices. An audit that found
539        // broken config files has not succeeded, however calmly it says so.
540        anyhow::bail!(
541            "Audit complete: {total_audited} repos checked, {errors_found} could not be read \
542             or written."
543        );
544    }
545    output::print_success(&format!(
546        "Audit complete: All {total_audited} registered repositories are healthy & synced!"
547    ));
548
549    Ok(())
550}
551
552/// Inspect or create per-repository configuration (.devprune.json).
553pub fn run_path_config(path_str: &str, force_update: bool) -> Result<()> {
554    let raw_path = Path::new(path_str);
555
556    let path = if raw_path.exists() {
557        raw_path
558            .canonicalize()
559            .unwrap_or_else(|_| raw_path.to_path_buf())
560    } else {
561        raw_path.to_path_buf()
562    };
563
564    let clean = output::clean_path(&path);
565
566    if !path.exists() {
567        bail!("Path does not exist: {clean}");
568    }
569
570    if !crate::scanner::is_git_repo(&path) {
571        // The old text said "Initializing Git repo first..." and then did no such thing.
572        bail!(
573            "`{clean}` is not a Git repository.\n  \
574             Run `git init` there first, then `devp config {clean}` again."
575        );
576    }
577
578    let mut registry = Registry::load()?;
579    if !registry.repositories.contains_key(&path) {
580        output::print_info(&format!(
581            "{clean} is not yet registered with dev-prune. Registering now..."
582        ));
583        registry.add_repo(path.clone());
584        registry.save()?;
585    }
586
587    let cfg_file = path.join(crate::constants::PER_REPO_CONFIG_FILE);
588
589    if cfg_file.exists() && !force_update {
590        output::print_header(&format!("dev-prune Per-Repo Config for {clean}"));
591        match PerRepoConfig::load_with_diagnostics(&path) {
592            Ok(cfg) => {
593                let json_str = serde_json::to_string_pretty(&cfg)?;
594                println!("{json_str}");
595                output::print_info("File location: .devprune.json");
596            }
597            Err(err_msg) => {
598                output::print_error(&format!("Invalid configuration in {clean}:"));
599                for line in err_msg.lines() {
600                    eprintln!("    {line}");
601                }
602                // Non-zero: the file this command was asked to show could not be read,
603                // and the same file is what every prune of this repo will trip over.
604                anyhow::bail!(
605                    "Run `devp config {clean} --update` to reset this file back to defaults \
606                     (your current overrides in it are discarded)."
607                );
608            }
609        }
610    } else {
611        output::print_info(&format!("Initializing .devprune.json for {clean}..."));
612        let cfg = PerRepoConfig::default();
613        cfg.save_to_repo(&path)?;
614        output::print_success(&format!("Created .devprune.json in {clean}"));
615    }
616
617    Ok(())
618}
619
620/// Load a workspace's `.devprune.json` for a toggle that is about to write it back.
621///
622/// Refuses a file that does not parse, rather than starting from the defaults. Starting
623/// from the defaults meant `devp config <repo> daemon off` wrote a fresh file straight
624/// over the broken one, so a single typo cost the user every other override in it.
625fn load_workspace_config_for_write(repo_path: &Path) -> Result<PerRepoConfig> {
626    match PerRepoConfig::load_with_diagnostics(repo_path) {
627        Ok(Some(cfg)) => Ok(cfg),
628        Ok(None) => Ok(PerRepoConfig::default()),
629        Err(e) => bail!(
630            "{e}\n  \
631             Fix that file, or run `devp config {} --update` to reset it back to defaults \
632             (your current overrides in it are discarded).",
633            output::clean_path(repo_path)
634        ),
635    }
636}
637
638/// Toggle or status check for background daemon (global or local workspace).
639pub fn run_daemon_toggle(path: Option<&str>, action: &str) -> Result<()> {
640    if let Some(p) = path {
641        let repo_path = resolve_workspace(p)?;
642        let mut cfg = load_workspace_config_for_write(&repo_path)?;
643        match parse_toggle(action)? {
644            Toggle::Enable => {
645                cfg.disable_daemon = false;
646                cfg.save_to_repo(&repo_path)?;
647                output::print_success(&format!(
648                    "Enabled background daemon for workspace: {}",
649                    output::clean_path(&repo_path)
650                ));
651            }
652            Toggle::Disable => {
653                cfg.disable_daemon = true;
654                cfg.save_to_repo(&repo_path)?;
655                output::print_success(&format!(
656                    "Disabled background daemon for workspace: {}",
657                    output::clean_path(&repo_path)
658                ));
659            }
660            Toggle::Status => {
661                let st = if cfg.disable_daemon {
662                    "Disabled for workspace"
663                } else {
664                    "Enabled for workspace"
665                };
666                output::print_info(&format!(
667                    "Daemon Status ({}): {}",
668                    output::clean_path(&repo_path),
669                    st
670                ));
671            }
672        }
673    } else {
674        match parse_toggle(action)? {
675            Toggle::Enable => crate::commands::daemon::run_install()?,
676            Toggle::Disable => crate::commands::daemon::run_uninstall()?,
677            Toggle::Status => crate::commands::daemon::run_status()?,
678        }
679    }
680    Ok(())
681}
682
683/// Toggle or status check for background Git hooks (global or local workspace).
684pub fn run_hook_toggle(path: Option<&str>, action: &str, chain: bool) -> Result<()> {
685    if let Some(p) = path {
686        if chain {
687            bail!(
688                "`--chain` changes the single global `core.hooksPath`, so it has no \
689                 per-workspace form. Drop the path: `devp hook install --chain`."
690            );
691        }
692        let repo_path = resolve_workspace(p)?;
693        let mut cfg = load_workspace_config_for_write(&repo_path)?;
694        match parse_toggle(action)? {
695            Toggle::Enable => {
696                cfg.disable_hooks = false;
697                cfg.save_to_repo(&repo_path)?;
698                output::print_success(&format!(
699                    "Enabled background Git hooks for workspace: {}",
700                    output::clean_path(&repo_path)
701                ));
702            }
703            Toggle::Disable => {
704                cfg.disable_hooks = true;
705                cfg.save_to_repo(&repo_path)?;
706                output::print_success(&format!(
707                    "Disabled background Git hooks for workspace: {}",
708                    output::clean_path(&repo_path)
709                ));
710            }
711            Toggle::Status => {
712                let st = if cfg.disable_hooks {
713                    "Disabled for workspace"
714                } else {
715                    "Enabled for workspace"
716                };
717                output::print_info(&format!(
718                    "Git Hook Status ({}): {}",
719                    output::clean_path(&repo_path),
720                    st
721                ));
722            }
723        }
724    } else {
725        match parse_toggle(action)? {
726            Toggle::Enable => crate::commands::hook::run_install(chain)?,
727            Toggle::Disable => crate::commands::hook::run_uninstall()?,
728            Toggle::Status => crate::commands::hook::run_status()?,
729        }
730    }
731    Ok(())
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737
738    #[test]
739    fn enable_synonyms_all_resolve_to_enable() {
740        for word in ["enable", "install", "on", "INSTALL", "On"] {
741            assert_eq!(parse_toggle(word).unwrap(), Toggle::Enable, "{word}");
742        }
743    }
744
745    #[test]
746    fn disable_synonyms_all_resolve_to_disable() {
747        for word in ["disable", "uninstall", "remove", "off", "Uninstall"] {
748            assert_eq!(parse_toggle(word).unwrap(), Toggle::Disable, "{word}");
749        }
750    }
751
752    #[test]
753    fn status_is_the_default_and_is_also_spellable() {
754        for word in ["", "status", "show"] {
755            assert_eq!(parse_toggle(word).unwrap(), Toggle::Status, "{word}");
756        }
757    }
758
759    #[test]
760    fn a_typo_is_an_error_rather_than_a_silent_status_report() {
761        // `devp config daemon enabel` must not print status and exit 0 — that reads as
762        // success while the daemon stays uninstalled.
763        let err = parse_toggle("enabel").unwrap_err().to_string();
764        assert!(err.contains("enabel"), "{err}");
765        assert!(err.contains("enable"), "{err}");
766    }
767
768    #[test]
769    fn a_workspace_toggle_refuses_to_write_over_a_broken_config() {
770        // The toggle rewrites the whole file. Starting from the defaults on a file it
771        // could not read would silently discard every override the user had put in it.
772        let tmp = tempfile::TempDir::new().unwrap();
773        let broken = r#"{ "project_name": "api", "override_idle_days": 90, }"#;
774        std::fs::write(
775            tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE),
776            broken,
777        )
778        .unwrap();
779
780        let err = load_workspace_config_for_write(tmp.path())
781            .unwrap_err()
782            .to_string();
783        assert!(err.contains("Syntax error"), "{err}");
784        assert!(err.contains("--update"), "{err}");
785
786        // Untouched, so the user still has their 90 days to recover.
787        let on_disk =
788            std::fs::read_to_string(tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE))
789                .unwrap();
790        assert_eq!(on_disk, broken);
791    }
792
793    #[test]
794    fn a_workspace_with_no_config_yet_starts_from_the_defaults() {
795        let tmp = tempfile::TempDir::new().unwrap();
796        assert_eq!(
797            load_workspace_config_for_write(tmp.path()).unwrap(),
798            PerRepoConfig::default()
799        );
800    }
801
802    #[test]
803    fn every_setting_round_trips_through_its_own_getter() {
804        // The table is what `get`, `set`, `show` and the wizard all read, so a getter
805        // that reports a different field than its setter writes would be invisible in
806        // every one of them at once.
807        let mut settings = Settings::default();
808        for setting in SETTINGS {
809            let before = (setting.get)(&settings);
810            let probe = match before.as_str() {
811                "true" => "false".to_string(),
812                "false" => "true".to_string(),
813                // A number every numeric setting accepts: above every minimum, below
814                // `scan_depth`'s ceiling.
815                _ => "7".to_string(),
816            };
817            (setting.set)(&mut settings, &probe)
818                .unwrap_or_else(|e| panic!("{} rejected `{probe}`: {e}", setting.key));
819            assert_eq!(
820                (setting.get)(&settings),
821                probe,
822                "{} reads back a different field than it writes",
823                setting.key
824            );
825        }
826    }
827
828    #[test]
829    fn every_setting_is_documented_and_uniquely_named() {
830        let mut seen = std::collections::HashSet::new();
831        for setting in SETTINGS {
832            assert!(seen.insert(setting.key), "duplicate key {}", setting.key);
833            assert!(!setting.help.is_empty(), "{} has no help", setting.key);
834            // The wizard prints the help under the key; a sentence keeps that readable.
835            assert!(
836                setting.help.ends_with('.'),
837                "{} help should read as a sentence",
838                setting.key
839            );
840        }
841    }
842
843    #[test]
844    fn the_settings_table_covers_every_field_of_settings() {
845        // Serialising `Settings` names every field, so a field added without a table
846        // entry — unsettable, unshown, never asked about — fails here rather than in
847        // a bug report.
848        let json = serde_json::to_value(Settings::default()).unwrap();
849        let fields: Vec<String> = json.as_object().unwrap().keys().cloned().collect();
850        for field in fields {
851            assert!(
852                SETTINGS.iter().any(|s| s.key == field),
853                "`{field}` is a setting with no entry in SETTINGS, so `devp config set \
854                 {field}` cannot reach it"
855            );
856        }
857    }
858
859    #[test]
860    fn a_rejected_value_leaves_the_previous_one_in_place() {
861        let mut settings = Settings::default();
862        assert!((find_setting("scan_depth").unwrap().set)(&mut settings, "0").is_err());
863        assert_eq!(settings.scan_depth, Settings::default().scan_depth);
864
865        assert!((find_setting("command_timeout_secs").unwrap().set)(&mut settings, "0").is_err());
866        assert!((find_setting("check_interval_days").unwrap().set)(&mut settings, "0").is_err());
867        assert!(
868            (find_setting("update_check_interval_days").unwrap().set)(&mut settings, "0").is_err()
869        );
870    }
871
872    #[test]
873    fn booleans_accept_the_words_people_actually_type() {
874        assert!(parse_bool("k", "yes").unwrap());
875        assert!(parse_bool("k", "ON").unwrap());
876        assert!(!parse_bool("k", "0").unwrap());
877        assert!(parse_bool("k", "maybe").is_err());
878    }
879
880    #[test]
881    fn an_unknown_key_lists_the_ones_that_exist() {
882        let err = match find_setting("idel_days") {
883            Ok(_) => panic!("`idel_days` is not a setting"),
884            Err(e) => e.to_string(),
885        };
886        assert!(err.contains("idle_days"), "{err}");
887    }
888
889    #[test]
890    fn a_path_is_never_mistaken_for_an_action() {
891        // The router uses this to decide whether a lone argument is a path or an action.
892        assert!(!is_toggle_word("~/Code/my-repo"));
893        assert!(!is_toggle_word("."));
894        assert!(!is_toggle_word(""));
895        assert!(is_toggle_word("install"));
896    }
897}