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