1use anyhow::{Result, bail};
10use std::path::Path;
11
12use crate::config::{PerRepoConfig, Registry, Settings};
13use crate::output;
14
15struct Setting {
23 key: &'static str,
24 since: &'static str,
31 kind: Kind,
33 help: &'static str,
37 plain: &'static str,
44 get: fn(&Settings) -> String,
45 set: fn(&mut Settings, &str) -> Result<()>,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54enum Kind {
55 Toggle,
57 Number,
59 Adapters,
61 AdapterDays,
68}
69
70struct Recommendation {
76 key: &'static str,
77 label: &'static str,
79 why: &'static str,
81 value: &'static str,
84 cautious: bool,
86}
87
88const RECOMMENDED: &[Recommendation] = &[
95 Recommendation {
96 key: "enable_cargo",
97 label: "Rust build folders",
98 why: "Rust `target/` directories are usually the largest thing on a developer's disk — tens of gigabytes across a handful of old projects. Nothing is lost: `cargo build` rebuilds it, and a project has to sit untouched for 45 days before this one is even considered.",
99 value: "true",
100 cautious: false,
101 },
102 Recommendation {
103 key: "enable_gradle",
104 label: "Android / Gradle builds",
105 why: "`build/` and `.gradle/` grow with every Android build and are never cleaned up by anything else. They come back on the next build, under the same 45-day wait.",
106 value: "true",
107 cautious: false,
108 },
109 Recommendation {
110 key: "enable_maven",
111 label: "Maven builds",
112 why: "Maven `target/` directories accumulate quietly per module, so a multi-module project has several. `mvn package` brings them back.",
113 value: "true",
114 cautious: false,
115 },
116 Recommendation {
117 key: "enable_swift",
118 label: "Swift builds",
119 why: "`.build/` holds compiled modules for every configuration you have ever built, and `swift build` recreates the one you actually use.",
120 value: "true",
121 cautious: false,
122 },
123 Recommendation {
124 key: "enable_dart",
125 label: "Dart / Flutter caches",
126 why: "`.dart_tool/` carries the pub metadata — back in a second — alongside `build_runner` and `flutter_build` caches that are worth real disk space.",
127 value: "true",
128 cautious: false,
129 },
130 Recommendation {
131 key: "enable_mix_build",
132 label: "Elixir build trees",
133 why: "`_build/` holds compiled beam files for every Mix environment you have built, and `mix compile` recreates the one you are working in.",
134 value: "true",
135 cautious: false,
136 },
137 Recommendation {
138 key: "allow_manifest_rewrite",
139 label: "Let cargo and go tidy up",
140 why: "Cautious, not risky. The commands that restore a Rust or Go project can also update `Cargo.lock` or `go.mod` — files Git tracks. Nothing is lost and nothing is deleted, but the next `git status` may show a change you did not make by hand. Turn it on if that is fine; leave it off if a clean working tree matters more than a fully automatic restore.",
141 value: "true",
142 cautious: true,
143 },
144];
145
146const SETTINGS: &[Setting] = &[
148 Setting {
149 key: "idle_days",
150 since: "1.0.0",
151 kind: Kind::Number,
152 help: "Days a repository must sit untouched before it is eligible for pruning.",
153 plain: "How long a project has to sit untouched before dev-prune will clean it. Something you worked on yesterday is never touched.",
154 get: |s| s.idle_days.to_string(),
155 set: |s, v| {
156 s.idle_days = v
157 .parse()
158 .map_err(|_| anyhow::anyhow!("idle_days must be a whole number of days"))?;
159 Ok(())
160 },
161 },
162 Setting {
163 key: "min_size_mb",
164 since: "1.0.0",
165 kind: Kind::Number,
166 help: "Smallest bloat directory worth deleting, in MiB. 0 removes the floor.",
167 plain: "Ignore small folders. Deleting a 2 MB folder is not worth the download to get it back.",
168 get: |s| s.min_size_mb.to_string(),
169 set: |s, v| {
170 s.min_size_mb = v.parse().map_err(|_| {
171 anyhow::anyhow!("min_size_mb must be a whole number of MiB (0 disables the floor)")
172 })?;
173 Ok(())
174 },
175 },
176 Setting {
177 key: "scan_depth",
178 since: "1.0.0",
179 kind: Kind::Number,
180 help: "How many directory levels below a repo root project discovery descends.",
181 plain: "How deep inside a repository to look for projects. Raise it if your projects live several folders down; lower it if scanning feels slow.",
182 get: |s| s.scan_depth.to_string(),
183 set: |s, v| {
184 let depth: usize = v
185 .parse()
186 .map_err(|_| anyhow::anyhow!("scan_depth must be a positive integer"))?;
187 if depth == 0 {
191 bail!("scan_depth must be at least 1 — 0 would find no projects at all.");
192 }
193 if depth > crate::constants::MAX_SCAN_DEPTH_LIMIT {
194 bail!(
195 "scan_depth must be at most {} — deeper walks stall on generated trees.",
196 crate::constants::MAX_SCAN_DEPTH_LIMIT
197 );
198 }
199 s.scan_depth = depth;
200 Ok(())
201 },
202 },
203 Setting {
204 key: "require_confirmation",
205 since: "1.0.0",
206 kind: Kind::Toggle,
207 help: "Ask before deleting anything. Turning this off makes every run unattended.",
208 plain: "Whether dev-prune asks \"delete these?\" before it deletes. Leave this on unless you want it to run silently while you are away.",
209 get: |s| s.require_confirmation.to_string(),
210 set: |s, v| {
211 s.require_confirmation = parse_bool("require_confirmation", v)?;
212 Ok(())
213 },
214 },
215 Setting {
216 key: "allow_manifest_rewrite",
217 since: "1.0.0",
218 kind: Kind::Toggle,
219 help: "Let cargo and go run the sync command that rewrites tracked manifests.",
220 plain: "Lets dev-prune run the command that puts a Rust or Go project back together — which can edit files that are checked into Git. Nothing is lost, but the change shows up in `git status`.",
221 get: |s| s.allow_manifest_rewrite.to_string(),
222 set: |s, v| {
223 s.allow_manifest_rewrite = parse_bool("allow_manifest_rewrite", v)?;
224 Ok(())
225 },
226 },
227 Setting {
228 key: "command_timeout_secs",
229 since: "1.0.0",
230 kind: Kind::Number,
231 help: "How long a lockfile command may run before it is killed.",
232 plain: "How long to wait for a rebuild command before giving up on it. Raise it on a slow connection.",
233 get: |s| s.command_timeout_secs.to_string(),
234 set: |s, v| {
235 let secs: u64 = v
236 .parse()
237 .map_err(|_| anyhow::anyhow!("command_timeout_secs must be a positive integer"))?;
238 if secs == 0 {
242 bail!(
243 "command_timeout_secs must be at least 1 — 0 would kill every command \
244 the instant it starts."
245 );
246 }
247 s.command_timeout_secs = secs;
248 Ok(())
249 },
250 },
251 Setting {
252 key: "auto_setup",
253 since: "1.0.0",
254 kind: Kind::Toggle,
255 help: "Install missing integrations by itself, once per installed version.",
256 plain: "Whether dev-prune finishes setting itself up on its own instead of making you run `devp setup`.",
257 get: |s| s.auto_setup.to_string(),
258 set: |s, v| {
259 s.auto_setup = parse_bool("auto_setup", v)?;
260 Ok(())
261 },
262 },
263 Setting {
264 key: "auto_config",
265 since: "1.3.0",
266 kind: Kind::Toggle,
267 help: "Write a default .devprune.json into repositories that link/init register.",
268 plain: "Drops a small settings file into each repository you register, so you can give that one project different rules later.",
269 get: |s| s.auto_config.to_string(),
270 set: |s, v| {
271 s.auto_config = parse_bool("auto_config", v)?;
272 Ok(())
273 },
274 },
275 Setting {
276 key: "auto_daemon",
277 since: "1.0.0",
278 kind: Kind::Toggle,
279 help: "Register the OS scheduler so passes run without being remembered.",
280 plain: "Lets your operating system run dev-prune on a schedule, so you never have to remember to.",
281 get: |s| s.auto_daemon.to_string(),
282 set: |s, v| {
283 s.auto_daemon = parse_bool("auto_daemon", v)?;
284 Ok(())
285 },
286 },
287 Setting {
288 key: "check_interval_days",
289 since: "1.0.0",
290 kind: Kind::Number,
291 help: "Days between scheduled background passes.",
292 plain: "How often that scheduled cleanup runs.",
293 get: |s| s.check_interval_days.to_string(),
294 set: |s, v| {
295 let days: u64 = v
296 .parse()
297 .map_err(|_| anyhow::anyhow!("check_interval_days must be a positive integer"))?;
298 if days == 0 {
300 bail!("check_interval_days must be at least 1.");
301 }
302 s.check_interval_days = days;
303 Ok(())
304 },
305 },
306 Setting {
307 key: "auto_hooks",
308 since: "1.0.0",
309 kind: Kind::Toggle,
310 help: "Install the Git hooks that register repositories as you clone them.",
311 plain: "Registers new repositories automatically as you clone them, so you never have to add them by hand.",
312 get: |s| s.auto_hooks.to_string(),
313 set: |s, v| {
314 s.auto_hooks = parse_bool("auto_hooks", v)?;
315 Ok(())
316 },
317 },
318 Setting {
319 key: "auto_hooks_chain",
320 since: "1.0.0",
321 kind: Kind::Toggle,
322 help: "If another tool owns core.hooksPath, install in front of it and forward.",
323 plain: "Git only has one slot for this kind of automation. If something else — husky, pre-commit, lefthook — is already using it, share the slot instead of taking it over.",
324 get: |s| s.auto_hooks_chain.to_string(),
325 set: |s, v| {
326 s.auto_hooks_chain = parse_bool("auto_hooks_chain", v)?;
327 Ok(())
328 },
329 },
330 Setting {
331 key: "update_check",
332 since: "1.0.0",
333 kind: Kind::Toggle,
334 help: "Ask GitHub for the latest release from time to time. Sends nothing but the request.",
335 plain: "Whether dev-prune checks GitHub now and then to see if there is a newer version. It sends no information about you.",
336 get: |s| s.update_check.to_string(),
337 set: |s, v| {
338 s.update_check = parse_bool("update_check", v)?;
339 Ok(())
340 },
341 },
342 Setting {
343 key: "update_check_interval_days",
344 since: "1.0.0",
345 kind: Kind::Number,
346 help: "Days between automatic release checks.",
347 plain: "How often that version check happens.",
348 get: |s| s.update_check_interval_days.to_string(),
349 set: |s, v| {
350 let days: i64 = v.parse().map_err(|_| {
351 anyhow::anyhow!("update_check_interval_days must be a positive integer")
352 })?;
353 if days < 1 {
354 bail!("update_check_interval_days must be at least 1.");
355 }
356 s.update_check_interval_days = days;
357 Ok(())
358 },
359 },
360 Setting {
361 key: "update_check_timeout_secs",
362 since: "1.0.0",
363 kind: Kind::Number,
364 help: "Seconds the release check waits for GitHub. Raise it behind a slow proxy.",
365 plain: "How long the version check waits before giving up. Raise it if you are behind a slow proxy.",
366 get: |s| s.update_check_timeout_secs.to_string(),
367 set: |s, v| {
368 let secs: u64 = v.parse().map_err(|_| {
369 anyhow::anyhow!("update_check_timeout_secs must be a positive integer")
370 })?;
371 if secs == 0 {
372 bail!("update_check_timeout_secs must be at least 1.");
373 }
374 s.update_check_timeout_secs = secs;
375 Ok(())
376 },
377 },
378 Setting {
379 key: "enable_cargo",
380 since: "1.5.0",
381 kind: Kind::Toggle,
382 help: "Turn on the opt-in Cargo adapter (target/ comes back by recompiling, not downloading).",
383 plain: "Clean Rust build folders too. These come back by recompiling, which takes minutes rather than a download — so this is off unless you say otherwise.",
384 get: |s| s.enable_cargo.to_string(),
385 set: |s, v| {
386 s.enable_cargo = parse_bool("enable_cargo", v)?;
387 Ok(())
388 },
389 },
390 Setting {
391 key: "enable_gradle",
392 since: "1.3.0",
393 kind: Kind::Toggle,
394 help: "Turn on the opt-in Gradle adapter (build/ and .gradle/ come back by recompiling).",
395 plain: "Clean Android and Java build folders too. Same trade: they come back by recompiling, not downloading.",
396 get: |s| s.enable_gradle.to_string(),
397 set: |s, v| {
398 s.enable_gradle = parse_bool("enable_gradle", v)?;
399 Ok(())
400 },
401 },
402 Setting {
403 key: "enable_maven",
404 since: "1.3.0",
405 kind: Kind::Toggle,
406 help: "Turn on the opt-in Maven adapter (target/ comes back by recompiling).",
407 plain: "Clean Maven build folders too. They come back by recompiling.",
408 get: |s| s.enable_maven.to_string(),
409 set: |s, v| {
410 s.enable_maven = parse_bool("enable_maven", v)?;
411 Ok(())
412 },
413 },
414 Setting {
415 key: "enable_swift",
416 since: "1.4.0",
417 kind: Kind::Toggle,
418 help: "Turn on the opt-in SwiftPM adapter (.build/ comes back by recompiling).",
419 plain: "Clean Swift build folders too. They come back by recompiling.",
420 get: |s| s.enable_swift.to_string(),
421 set: |s, v| {
422 s.enable_swift = parse_bool("enable_swift", v)?;
423 Ok(())
424 },
425 },
426 Setting {
427 key: "enable_dart",
428 since: "1.6.0",
429 kind: Kind::Toggle,
430 help: "Turn on the opt-in Dart/Flutter adapter (.dart_tool/ holds build caches).",
431 plain: "Clean Dart and Flutter caches too. Part comes back instantly, part by recompiling.",
432 get: |s| s.enable_dart.to_string(),
433 set: |s, v| {
434 s.enable_dart = parse_bool("enable_dart", v)?;
435 Ok(())
436 },
437 },
438 Setting {
439 key: "enable_mix_build",
440 since: "1.7.0",
441 kind: Kind::Toggle,
442 help: "Turn on the opt-in Mix build-tree adapter (_build/ comes back by recompiling).",
443 plain: "Clean Elixir _build/ folders too. They come back by recompiling.",
444 get: |s| s.enable_mix_build.to_string(),
445 set: |s, v| {
446 s.enable_mix_build = parse_bool("enable_mix_build", v)?;
447 Ok(())
448 },
449 },
450 Setting {
451 key: "build_idle_days",
452 since: "1.3.0",
453 kind: Kind::Number,
454 help: "Idle days before cargo/gradle/maven/swift build trees are pruned. Applied as max(this, idle_days).",
455 plain: "A longer wait, used only for the build folders above, because getting those back costs a recompile rather than a download.",
456 get: |s| s.build_idle_days.to_string(),
457 set: |s, v| {
458 let days: u64 = v
459 .parse()
460 .map_err(|_| anyhow::anyhow!("build_idle_days must be a non-negative integer"))?;
461 s.build_idle_days = days;
462 Ok(())
463 },
464 },
465 Setting {
466 key: "auto_update",
467 since: "1.3.0",
468 kind: Kind::Toggle,
469 help: "Install a newer release by itself at the end of a prune pass. On by default.",
470 plain: "Whether dev-prune installs its own updates after a cleanup. The download is checked against its published fingerprint first.",
471 get: |s| s.auto_update.to_string(),
472 set: |s, v| {
473 s.auto_update = parse_bool("auto_update", v)?;
474 Ok(())
475 },
476 },
477 Setting {
478 key: "disabled_adapters",
479 since: "1.4.0",
480 kind: Kind::Adapters,
481 help: "Adapters to leave alone entirely, by name. Empty means every one of them is active.",
482 plain: "Ecosystems to ignore completely — as if you did not have them installed at all.",
483 get: |s| {
484 if s.disabled_adapters.is_empty() {
485 "(none)".to_string()
486 } else {
487 s.disabled_adapters.join(",")
488 }
489 },
490 set: |s, v| {
491 s.disabled_adapters = parse_adapter_list(v)?;
492 Ok(())
493 },
494 },
495 Setting {
496 key: "adapter_idle_days",
497 since: "1.5.0",
498 kind: Kind::AdapterDays,
499 help: "Per-adapter idle windows, as `cargo=60,npm=30`. Each one can only raise its own wait.",
500 plain: "A different waiting period for one ecosystem. Useful when your Rust projects should wait longer than your Node ones.",
501 get: |s| {
502 if s.adapter_idle_days.is_empty() {
503 "(none)".to_string()
504 } else {
505 s.adapter_idle_days
506 .iter()
507 .map(|(name, days)| format!("{name}={days}"))
508 .collect::<Vec<_>>()
509 .join(",")
510 }
511 },
512 set: |s, v| {
513 s.adapter_idle_days = parse_adapter_days(v)?;
514 Ok(())
515 },
516 },
517];
518
519fn parse_adapter_days(value: &str) -> Result<std::collections::BTreeMap<String, u64>> {
529 let trimmed = value.trim();
530 if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
531 return Ok(std::collections::BTreeMap::new());
532 }
533
534 let mut days = std::collections::BTreeMap::new();
535 for raw in trimmed.split(',') {
536 let entry = raw.trim();
537 if entry.is_empty() {
538 continue;
539 }
540 let Some((name, value)) = entry.split_once('=') else {
541 bail!("`{entry}` must be written as `<adapter>=<days>`, for example `cargo=60`.");
542 };
543 let name = name.trim().to_lowercase();
544 if !crate::adapters::is_adapter_name(&name) {
545 bail!(
546 "`{name}` is not an adapter. Valid names: {}",
547 crate::adapters::all_adapter_names().join(", ")
548 );
549 }
550 let parsed: u64 = value.trim().parse().map_err(|_| {
551 anyhow::anyhow!(
552 "`{name}` needs a whole number of days, not `{}`.",
553 value.trim()
554 )
555 })?;
556 days.insert(name, parsed);
557 }
558 Ok(days)
559}
560
561fn parse_adapter_list(value: &str) -> Result<Vec<String>> {
562 let trimmed = value.trim();
563 if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
566 return Ok(Vec::new());
567 }
568
569 let mut names: Vec<String> = Vec::new();
570 for raw in trimmed.split(',') {
571 let name = raw.trim().to_lowercase();
572 if name.is_empty() {
573 continue;
574 }
575 if !crate::adapters::is_adapter_name(&name) {
576 bail!(
577 "`{name}` is not an adapter. Valid names: {}",
578 crate::adapters::all_adapter_names().join(", ")
579 );
580 }
581 if !names.contains(&name) {
582 names.push(name);
583 }
584 }
585 Ok(names)
586}
587
588fn parse_bool(key: &str, value: &str) -> Result<bool> {
589 match value.trim().to_lowercase().as_str() {
590 "true" | "yes" | "y" | "on" | "1" => Ok(true),
591 "false" | "no" | "n" | "off" | "0" => Ok(false),
592 _ => bail!("{key} must be true or false"),
593 }
594}
595
596pub fn invalid_settings(settings: &Settings) -> Vec<(&'static str, String)> {
607 SETTINGS
608 .iter()
609 .filter_map(|setting| {
610 let mut probe = settings.clone();
611 (setting.set)(&mut probe, &(setting.get)(settings))
612 .err()
613 .map(|e| (setting.key, e.to_string()))
614 })
615 .collect()
616}
617
618pub fn setting_count() -> usize {
620 SETTINGS.len()
621}
622
623fn find_setting(key: &str) -> Result<&'static Setting> {
624 SETTINGS
625 .iter()
626 .find(|s| s.key == key)
627 .ok_or_else(|| anyhow::anyhow!("Unknown config key: {key}. Valid keys: {}", valid_keys()))
628}
629
630fn valid_keys() -> String {
631 SETTINGS
632 .iter()
633 .map(|s| s.key)
634 .collect::<Vec<_>>()
635 .join(", ")
636}
637
638#[derive(Debug, PartialEq, Eq)]
640pub enum Toggle {
641 Enable,
642 Disable,
643 Status,
644}
645
646pub fn parse_toggle(action: &str) -> Result<Toggle> {
656 match action.to_lowercase().as_str() {
657 "enable" | "install" | "on" => Ok(Toggle::Enable),
658 "disable" | "uninstall" | "remove" | "off" => Ok(Toggle::Disable),
659 "" | "status" | "show" => Ok(Toggle::Status),
660 other => bail!(
661 "Unknown action `{other}`. Expected `enable`, `disable` or `status` \
662 (`install` / `uninstall` / `on` / `off` also work)."
663 ),
664 }
665}
666
667pub fn is_toggle_word(word: &str) -> bool {
673 parse_toggle(word).is_ok() && !word.is_empty()
674}
675
676fn resolve_workspace(path: &str) -> Result<std::path::PathBuf> {
683 let raw = Path::new(path);
684 if !raw.is_dir() {
685 bail!(
686 "`{path}` is neither an action nor an existing directory.\n\
687 Expected `enable`, `disable` or `status`, or a path to a repository."
688 );
689 }
690 Ok(raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf()))
691}
692
693pub fn run_get(key: &str) -> Result<()> {
695 let registry = Registry::load()?;
696 let setting = find_setting(key)?;
697 println!("{key} = {}", (setting.get)(®istry.settings));
698 Ok(())
699}
700
701pub fn run_set(key: &str, value: &str) -> Result<()> {
703 let mut registry = Registry::load()?;
704 let setting = find_setting(key)?;
705 (setting.set)(&mut registry.settings, value)?;
706 registry.save()?;
707
708 output::print_success(&format!("{key} = {}", (setting.get)(®istry.settings)));
711 Ok(())
712}
713
714fn key_column_width() -> usize {
716 SETTINGS.iter().map(|s| s.key.len()).max().unwrap_or(0)
717}
718
719pub fn run_show() -> Result<()> {
721 let registry = Registry::load()?;
722 let width = key_column_width();
723
724 output::print_header("dev-prune Global Configuration");
725 for setting in SETTINGS {
726 println!(
727 " {:<width$} = {}",
728 setting.key,
729 (setting.get)(®istry.settings)
730 );
731 }
732 println!(" {:<width$} = {}", "tracked_repos", registry.repo_count());
733
734 let reg_path = Registry::registry_path()
735 .map(|p| output::clean_path(&p))
736 .unwrap_or_else(|_| "unknown".to_string());
737 println!("\n {:<width$} = {reg_path}", "registry_file");
738 println!();
739 output::print_info("Change any of these with `devp config set <key> <value>`.");
740 output::print_info("Walk through them one at a time with `devp config wizard`.");
741
742 Ok(())
743}
744
745pub fn run_wizard(no_tui: bool) -> Result<()> {
756 if !no_tui && full_screen_is_usable() {
757 return run_wizard_tui();
758 }
759 run_wizard_prompts()
760}
761
762fn full_screen_is_usable() -> bool {
770 use std::io::IsTerminal;
771 if std::env::var_os(crate::constants::ENV_NO_TUI).is_some() {
772 return false;
773 }
774 std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
775}
776
777fn run_wizard_tui() -> Result<()> {
779 use crate::tui::config_view::{ConfigRow, ConfigSession, Control, Outcome};
780
781 let mut registry = Registry::load()?;
782 let new_keys = settings_added_since_review();
783
784 let rows: Vec<ConfigRow> = SETTINGS
785 .iter()
786 .map(|setting| {
787 let value = (setting.get)(®istry.settings);
788 ConfigRow {
789 key: setting.key,
790 help: setting.help,
791 plain: setting.plain,
792 control: match setting.kind {
793 Kind::Toggle => Control::Toggle,
794 Kind::Number => Control::Number,
795 Kind::Adapters => Control::Adapters,
796 Kind::AdapterDays => Control::AdapterDays,
797 },
798 original: value.clone(),
799 value,
800 is_new: new_keys.contains(&setting.key),
801 }
802 })
803 .collect();
804
805 let base = registry.settings.clone();
808 let validate = move |key: &str, value: &str| -> std::result::Result<(), String> {
809 let setting = find_setting(key).map_err(|e| e.to_string())?;
810 let mut probe = base.clone();
811 (setting.set)(&mut probe, value).map_err(|e| format!("{e}"))
812 };
813
814 let report = crate::commands::trust::build(®istry);
815 let adapters = crate::adapters::all_adapter_names();
816 let opt_in = crate::adapters::opt_in_adapter_names();
817
818 let outcome = crate::tui::config_view::run(ConfigSession {
819 declaration: declaration_lines(&report),
820 standing: NOTHING_DELETED_YET.to_string(),
821 suggestions: first_run_suggestions(),
822 rows,
823 adapters: &adapters,
824 opt_in_adapters: &opt_in,
825 groups: crate::adapters::ADAPTER_GROUPS,
826 validate: &validate,
827 title: "dev-prune configuration",
828 })?;
829
830 match outcome {
831 Outcome::Cancelled => {
835 output::print_info("Cancelled — nothing was changed.");
836 Ok(())
837 }
838 Outcome::KeepAll => {
839 mark_reviewed();
840 output::print_success(
841 "Keeping the current values. `devp config set <key> <value>` changes any.",
842 );
843 Ok(())
844 }
845 Outcome::Save(changed) => {
846 for row in &changed {
847 (find_setting(row.key)?.set)(&mut registry.settings, &row.value)?;
848 }
849 registry.save()?;
850 mark_reviewed();
851
852 output::print_header("Saved");
856 let width = changed.iter().map(|r| r.key.len()).max().unwrap_or(0);
857 for row in &changed {
858 println!(
859 " {:<width$} = {} (was {})",
860 row.key, row.value, row.original
861 );
862 }
863 println!();
864 output::print_success(&format!(
865 "{} {} saved. `devp config show` lists every setting.",
866 changed.len(),
867 output::plural(changed.len(), "change", "changes")
868 ));
869 Ok(())
870 }
871 }
872}
873
874fn first_run_suggestions() -> Vec<crate::tui::config_view::Suggestion> {
885 use crate::tui::config_view::Suggestion;
886
887 if reviewed_version().is_some() {
888 return Vec::new();
889 }
890 RECOMMENDED
891 .iter()
892 .filter_map(|r| {
893 let setting = find_setting(r.key).ok()?;
894 Some(Suggestion {
895 key: r.key,
896 label: r.label,
897 help: setting.help,
898 plain: setting.plain,
899 why: r.why,
900 value: r.value,
901 cautious: r.cautious,
902 })
903 })
904 .collect()
905}
906
907const NOTHING_DELETED_YET: &str =
909 "Nothing has been deleted, and nothing will be until a lockfile proves it comes back.";
910
911fn declaration_lines(
917 report: &crate::commands::trust::TrustReport,
918) -> Vec<crate::tui::config_view::DeclarationLine> {
919 use crate::commands::trust::{TrustRow, Verdict};
920 use crate::tui::config_view::DeclarationLine;
921
922 let heading = |text: &str| DeclarationLine {
923 mark: '#',
924 subject: text.to_string(),
925 state: String::new(),
926 };
927 let row = |r: &TrustRow| DeclarationLine {
928 mark: match r.verdict {
929 Verdict::Guaranteed | Verdict::Safe => '+',
930 Verdict::Widened => '!',
931 Verdict::Neutral => ' ',
932 },
933 subject: r.subject.to_string(),
934 state: r.state.clone(),
935 };
936
937 let mut lines = vec![heading("Guaranteed by the code")];
938 lines.extend(report.guarantees.iter().map(&row));
939 lines.push(heading(""));
940 lines.push(heading("On this machine"));
941 lines.extend(report.machine.iter().map(&row));
942 lines
943}
944
945fn run_wizard_prompts() -> Result<()> {
949 use std::io::{self, IsTerminal, Write};
950
951 if !io::stdin().is_terminal() {
952 bail!(
953 "`devp config wizard` needs a terminal to ask questions on.\n\
954 Use `devp config show` to read the settings and `devp config set <key> <value>` \
955 to change one."
956 );
957 }
958
959 let mut registry = Registry::load()?;
960 let width = key_column_width();
961 let new_keys = settings_added_since_review();
962
963 output::print_header("dev-prune configuration");
964 output::print_info("These are the defaults every run will use. Nothing has been changed yet.");
965 println!();
966 for setting in SETTINGS {
967 let badge = if new_keys.contains(&setting.key) {
970 " (new in this version)"
971 } else {
972 ""
973 };
974 println!(
975 " {:<width$} = {}{badge}",
976 setting.key,
977 (setting.get)(®istry.settings)
978 );
979 println!(" {:<width$} {}", "", setting.help);
980 println!(" {:<width$} {}", "", setting.plain);
983 }
984 println!();
985
986 print!("Keep all of these? [Y/n] ");
987 io::stdout().flush()?;
988 let mut answer = String::new();
989 io::stdin().read_line(&mut answer)?;
990 let keep = !matches!(answer.trim().to_lowercase().as_str(), "n" | "no");
991
992 if keep {
993 mark_reviewed();
994 output::print_success("Keeping the defaults. `devp config set <key> <value>` changes any.");
995 return Ok(());
996 }
997
998 println!();
999 output::print_info("Enter a new value, or press Enter to keep the one shown.");
1000 println!();
1001
1002 let mut changed = 0usize;
1003 for setting in SETTINGS {
1004 let current = (setting.get)(®istry.settings);
1005 loop {
1006 print!(" {} [{current}]: ", setting.key);
1007 io::stdout().flush()?;
1008 let mut line = String::new();
1009 if io::stdin().read_line(&mut line)? == 0 {
1012 println!();
1013 break;
1014 }
1015 let typed = line.trim();
1016 if typed.is_empty() {
1017 break;
1018 }
1019 match (setting.set)(&mut registry.settings, typed) {
1020 Ok(()) => {
1021 changed += 1;
1022 break;
1023 }
1024 Err(e) => output::print_error(&format!("{e}")),
1027 }
1028 }
1029 }
1030
1031 registry.save()?;
1032 mark_reviewed();
1033 println!();
1034 if changed == 0 {
1035 output::print_success("Nothing changed — the defaults are in place.");
1036 } else {
1037 output::print_success(&format!(
1038 "Saved {changed} {}. `devp config show` lists them all.",
1039 output::plural(changed, "change", "changes")
1040 ));
1041 }
1042 Ok(())
1043}
1044
1045const REVIEW_MARKER: &str = "config-reviewed";
1047
1048pub fn config_review_is_due() -> bool {
1057 let Ok(dir) = Registry::config_dir() else {
1058 return false;
1059 };
1060 if !dir.join(REVIEW_MARKER).exists() {
1061 return true;
1062 }
1063 !settings_added_since_review().is_empty()
1064}
1065
1066fn reviewed_version() -> Option<String> {
1068 let dir = Registry::config_dir().ok()?;
1069 let recorded = std::fs::read_to_string(dir.join(REVIEW_MARKER)).ok()?;
1070 let recorded = recorded.trim().to_string();
1071 (!recorded.is_empty()).then_some(recorded)
1072}
1073
1074pub fn settings_added_since_review() -> Vec<&'static str> {
1083 let Some(reviewed) = reviewed_version() else {
1084 return Vec::new();
1085 };
1086 SETTINGS
1087 .iter()
1088 .filter(|s| {
1089 crate::commands::update::compare_versions(s.since, &reviewed)
1090 == Some(std::cmp::Ordering::Greater)
1091 })
1092 .map(|s| s.key)
1093 .collect()
1094}
1095
1096fn mark_reviewed() {
1097 if let Ok(dir) = Registry::config_dir() {
1098 let _ = std::fs::create_dir_all(&dir);
1099 let _ = std::fs::write(dir.join(REVIEW_MARKER), crate::constants::VERSION);
1100 }
1101}
1102
1103pub fn skip_config_review() {
1108 mark_reviewed();
1109}
1110
1111pub fn run_global_update() -> Result<()> {
1113 output::print_header("dev-prune Global Configuration Audit & Sync");
1114
1115 let registry = Registry::load()?;
1116 let mut total_audited = 0;
1117 let mut errors_found = 0;
1118
1119 for repo_path in registry.repositories.keys() {
1120 let clean = output::clean_path(repo_path);
1121
1122 if !repo_path.exists() {
1126 output::print_warning(&format!(
1127 "Skipped {clean} — the path no longer exists. `devp unlink --missing` \
1128 clears such entries."
1129 ));
1130 continue;
1131 }
1132 total_audited += 1;
1133
1134 match PerRepoConfig::load_with_diagnostics(repo_path) {
1135 Ok(Some(cfg)) => {
1136 if let Err(e) = cfg.save_to_repo(repo_path) {
1137 output::print_error(&format!("Failed to write config for {clean}: {e}"));
1138 errors_found += 1;
1139 } else {
1140 output::print_success(&format!("Audited & synced config for {clean}"));
1141 }
1142 }
1143 Ok(None) => {
1144 output::print_info(&format!(
1148 "{clean} has no .devprune.json — global defaults apply."
1149 ));
1150 }
1151 Err(err_msg) => {
1152 errors_found += 1;
1153 output::print_error(&format!("Syntax/Schema Error in {clean}:"));
1154 for line in err_msg.lines() {
1155 eprintln!(" {line}");
1156 }
1157 output::print_info(&format!(
1158 "Hint: fix the syntax by hand, or run `devp config {clean} --update` to \
1159 replace the file with a valid default."
1160 ));
1161 }
1162 }
1163 }
1164
1165 if errors_found > 0 {
1166 anyhow::bail!(
1169 "Audit complete: {total_audited} repos checked, {errors_found} could not be read \
1170 or written."
1171 );
1172 }
1173 output::print_success(&format!(
1174 "Audit complete: All {total_audited} registered repositories are healthy & synced!"
1175 ));
1176
1177 Ok(())
1178}
1179
1180pub fn run_path_config(path_str: &str, force_update: bool) -> Result<()> {
1182 let raw_path = Path::new(path_str);
1183
1184 let path = if raw_path.exists() {
1185 raw_path
1186 .canonicalize()
1187 .unwrap_or_else(|_| raw_path.to_path_buf())
1188 } else {
1189 raw_path.to_path_buf()
1190 };
1191
1192 let clean = output::clean_path(&path);
1193
1194 if !path.exists() {
1195 bail!("Path does not exist: {clean}");
1196 }
1197
1198 if !crate::scanner::is_git_repo(&path) {
1199 bail!(
1201 "`{clean}` is not a Git repository.\n \
1202 Run `git init` there first, then `devp config {clean}` again."
1203 );
1204 }
1205
1206 let mut registry = Registry::load()?;
1207 if !registry.repositories.contains_key(&path) {
1208 output::print_info(&format!(
1209 "{clean} is not yet registered with dev-prune. Registering now..."
1210 ));
1211 registry.add_repo(path.clone());
1212 registry.save()?;
1213 }
1214
1215 let cfg_file = path.join(crate::constants::PER_REPO_CONFIG_FILE);
1216
1217 if cfg_file.exists() && !force_update {
1218 output::print_header(&format!("dev-prune Per-Repo Config for {clean}"));
1219 match PerRepoConfig::load_with_diagnostics(&path) {
1220 Ok(cfg) => {
1221 let json_str = serde_json::to_string_pretty(&cfg)?;
1222 println!("{json_str}");
1223 output::print_info("File location: .devprune.json");
1224 }
1225 Err(err_msg) => {
1226 output::print_error(&format!("Invalid configuration in {clean}:"));
1227 for line in err_msg.lines() {
1228 eprintln!(" {line}");
1229 }
1230 anyhow::bail!(
1233 "Run `devp config {clean} --update` to reset this file back to defaults \
1234 (your current overrides in it are discarded)."
1235 );
1236 }
1237 }
1238 } else {
1239 output::print_info(&format!("Initializing .devprune.json for {clean}..."));
1240 let cfg = PerRepoConfig::default();
1241 cfg.save_to_repo(&path)?;
1242 output::print_success(&format!("Created .devprune.json in {clean}"));
1243 }
1244
1245 Ok(())
1246}
1247
1248fn load_workspace_config_for_write(repo_path: &Path) -> Result<PerRepoConfig> {
1254 match PerRepoConfig::load_with_diagnostics(repo_path) {
1255 Ok(Some(cfg)) => Ok(cfg),
1256 Ok(None) => Ok(PerRepoConfig::default()),
1257 Err(e) => bail!(
1258 "{e}\n \
1259 Fix that file, or run `devp config {} --update` to reset it back to defaults \
1260 (your current overrides in it are discarded).",
1261 output::clean_path(repo_path)
1262 ),
1263 }
1264}
1265
1266pub fn run_daemon_toggle(path: Option<&str>, action: &str) -> Result<()> {
1268 if let Some(p) = path {
1269 let repo_path = resolve_workspace(p)?;
1270 let mut cfg = load_workspace_config_for_write(&repo_path)?;
1271 match parse_toggle(action)? {
1272 Toggle::Enable => {
1273 cfg.disable_daemon = false;
1274 cfg.save_to_repo(&repo_path)?;
1275 output::print_success(&format!(
1276 "Enabled background daemon for workspace: {}",
1277 output::clean_path(&repo_path)
1278 ));
1279 }
1280 Toggle::Disable => {
1281 cfg.disable_daemon = true;
1282 cfg.save_to_repo(&repo_path)?;
1283 output::print_success(&format!(
1284 "Disabled background daemon for workspace: {}",
1285 output::clean_path(&repo_path)
1286 ));
1287 }
1288 Toggle::Status => {
1289 let st = if cfg.disable_daemon {
1290 "Disabled for workspace"
1291 } else {
1292 "Enabled for workspace"
1293 };
1294 output::print_info(&format!(
1295 "Daemon Status ({}): {}",
1296 output::clean_path(&repo_path),
1297 st
1298 ));
1299 }
1300 }
1301 } else {
1302 match parse_toggle(action)? {
1303 Toggle::Enable => crate::commands::daemon::run_install()?,
1304 Toggle::Disable => crate::commands::daemon::run_uninstall()?,
1305 Toggle::Status => crate::commands::daemon::run_status()?,
1306 }
1307 }
1308 Ok(())
1309}
1310
1311pub fn run_hook_toggle(path: Option<&str>, action: &str, chain: bool) -> Result<()> {
1313 if let Some(p) = path {
1314 if chain {
1315 bail!(
1316 "`--chain` changes the single global `core.hooksPath`, so it has no \
1317 per-workspace form. Drop the path: `devp hook install --chain`."
1318 );
1319 }
1320 let repo_path = resolve_workspace(p)?;
1321 let mut cfg = load_workspace_config_for_write(&repo_path)?;
1322 match parse_toggle(action)? {
1323 Toggle::Enable => {
1324 cfg.disable_hooks = false;
1325 cfg.save_to_repo(&repo_path)?;
1326 output::print_success(&format!(
1327 "Enabled background Git hooks for workspace: {}",
1328 output::clean_path(&repo_path)
1329 ));
1330 }
1331 Toggle::Disable => {
1332 cfg.disable_hooks = true;
1333 cfg.save_to_repo(&repo_path)?;
1334 output::print_success(&format!(
1335 "Disabled background Git hooks for workspace: {}",
1336 output::clean_path(&repo_path)
1337 ));
1338 }
1339 Toggle::Status => {
1340 let st = if cfg.disable_hooks {
1341 "Disabled for workspace"
1342 } else {
1343 "Enabled for workspace"
1344 };
1345 output::print_info(&format!(
1346 "Git Hook Status ({}): {}",
1347 output::clean_path(&repo_path),
1348 st
1349 ));
1350 }
1351 }
1352 } else {
1353 match parse_toggle(action)? {
1354 Toggle::Enable => crate::commands::hook::run_install(chain)?,
1355 Toggle::Disable => crate::commands::hook::run_uninstall()?,
1356 Toggle::Status => crate::commands::hook::run_status()?,
1357 }
1358 }
1359 Ok(())
1360}
1361
1362#[cfg(test)]
1363mod tests {
1364 use super::*;
1365
1366 #[test]
1367 fn enable_synonyms_all_resolve_to_enable() {
1368 for word in ["enable", "install", "on", "INSTALL", "On"] {
1369 assert_eq!(parse_toggle(word).unwrap(), Toggle::Enable, "{word}");
1370 }
1371 }
1372
1373 #[test]
1374 fn disable_synonyms_all_resolve_to_disable() {
1375 for word in ["disable", "uninstall", "remove", "off", "Uninstall"] {
1376 assert_eq!(parse_toggle(word).unwrap(), Toggle::Disable, "{word}");
1377 }
1378 }
1379
1380 #[test]
1381 fn status_is_the_default_and_is_also_spellable() {
1382 for word in ["", "status", "show"] {
1383 assert_eq!(parse_toggle(word).unwrap(), Toggle::Status, "{word}");
1384 }
1385 }
1386
1387 #[test]
1388 fn a_typo_is_an_error_rather_than_a_silent_status_report() {
1389 let err = parse_toggle("enabel").unwrap_err().to_string();
1392 assert!(err.contains("enabel"), "{err}");
1393 assert!(err.contains("enable"), "{err}");
1394 }
1395
1396 #[test]
1397 fn a_workspace_toggle_refuses_to_write_over_a_broken_config() {
1398 let tmp = tempfile::TempDir::new().unwrap();
1401 let broken = r#"{ "project_name": "api", "override_idle_days": 90, }"#;
1402 std::fs::write(
1403 tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE),
1404 broken,
1405 )
1406 .unwrap();
1407
1408 let err = load_workspace_config_for_write(tmp.path())
1409 .unwrap_err()
1410 .to_string();
1411 assert!(err.contains("Syntax error"), "{err}");
1412 assert!(err.contains("--update"), "{err}");
1413
1414 let on_disk =
1416 std::fs::read_to_string(tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE))
1417 .unwrap();
1418 assert_eq!(on_disk, broken);
1419 }
1420
1421 #[test]
1422 fn a_workspace_with_no_config_yet_starts_from_the_defaults() {
1423 let tmp = tempfile::TempDir::new().unwrap();
1424 assert_eq!(
1425 load_workspace_config_for_write(tmp.path()).unwrap(),
1426 PerRepoConfig::default()
1427 );
1428 }
1429
1430 #[test]
1431 fn every_setting_round_trips_through_its_own_getter() {
1432 let mut settings = Settings::default();
1436 for setting in SETTINGS {
1437 let before = (setting.get)(&settings);
1438 let probe = match setting.kind {
1439 Kind::Toggle => if before == "true" { "false" } else { "true" }.to_string(),
1440 Kind::Number => "7".to_string(),
1443 Kind::Adapters => "cargo".to_string(),
1446 Kind::AdapterDays => "cargo=45".to_string(),
1449 };
1450 (setting.set)(&mut settings, &probe)
1451 .unwrap_or_else(|e| panic!("{} rejected `{probe}`: {e}", setting.key));
1452 assert_eq!(
1453 (setting.get)(&settings),
1454 probe,
1455 "{} reads back a different field than it writes",
1456 setting.key
1457 );
1458 }
1459 }
1460
1461 #[test]
1462 fn every_setting_is_documented_and_uniquely_named() {
1463 let mut seen = std::collections::HashSet::new();
1464 for setting in SETTINGS {
1465 assert!(seen.insert(setting.key), "duplicate key {}", setting.key);
1466 assert!(!setting.help.is_empty(), "{} has no help", setting.key);
1467 assert!(
1468 !setting.plain.is_empty(),
1469 "{} has no plain text",
1470 setting.key
1471 );
1472 assert!(
1474 setting.help.ends_with('.'),
1475 "{} help should read as a sentence",
1476 setting.key
1477 );
1478 assert!(
1479 setting.plain.ends_with('.'),
1480 "{} plain text should read as a sentence",
1481 setting.key
1482 );
1483 assert_ne!(
1486 setting.plain, setting.help,
1487 "{} says the same thing twice",
1488 setting.key
1489 );
1490 }
1491 }
1492
1493 #[test]
1494 fn the_settings_table_covers_every_field_of_settings() {
1495 let json = serde_json::to_value(Settings::default()).unwrap();
1499 let fields: Vec<String> = json.as_object().unwrap().keys().cloned().collect();
1500 for field in fields {
1501 assert!(
1502 SETTINGS.iter().any(|s| s.key == field),
1503 "`{field}` is a setting with no entry in SETTINGS, so `devp config set \
1504 {field}` cannot reach it"
1505 );
1506 }
1507 }
1508
1509 #[test]
1510 fn a_rejected_value_leaves_the_previous_one_in_place() {
1511 let mut settings = Settings::default();
1512 assert!((find_setting("scan_depth").unwrap().set)(&mut settings, "0").is_err());
1513 assert_eq!(settings.scan_depth, Settings::default().scan_depth);
1514
1515 assert!((find_setting("command_timeout_secs").unwrap().set)(&mut settings, "0").is_err());
1516 assert!((find_setting("check_interval_days").unwrap().set)(&mut settings, "0").is_err());
1517 assert!(
1518 (find_setting("update_check_interval_days").unwrap().set)(&mut settings, "0").is_err()
1519 );
1520 }
1521
1522 #[test]
1523 fn booleans_accept_the_words_people_actually_type() {
1524 assert!(parse_bool("k", "yes").unwrap());
1525 assert!(parse_bool("k", "ON").unwrap());
1526 assert!(!parse_bool("k", "0").unwrap());
1527 assert!(parse_bool("k", "maybe").is_err());
1528 }
1529
1530 #[test]
1531 fn an_unknown_key_lists_the_ones_that_exist() {
1532 let err = match find_setting("idel_days") {
1533 Ok(_) => panic!("`idel_days` is not a setting"),
1534 Err(e) => e.to_string(),
1535 };
1536 assert!(err.contains("idle_days"), "{err}");
1537 }
1538
1539 #[test]
1540 fn a_path_is_never_mistaken_for_an_action() {
1541 assert!(!is_toggle_word("~/Code/my-repo"));
1543 assert!(!is_toggle_word("."));
1544 assert!(!is_toggle_word(""));
1545 assert!(is_toggle_word("install"));
1546 }
1547}