Skip to main content

dev_prune/commands/
config.rs

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