1use anyhow::{Result, bail};
10use std::path::Path;
11
12use crate::config::{PerRepoConfig, Registry, Settings};
13use crate::i18n;
14use crate::output;
15
16struct Setting {
24 key: &'static str,
25 category: Category,
31 since: &'static str,
38 kind: Kind,
40 help: &'static str,
44 plain: &'static str,
51 get: fn(&Settings) -> String,
52 set: fn(&mut Settings, &str) -> Result<()>,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64enum Category {
65 Presentation,
70 Scope,
72 Safety,
74 BuildTrees,
77 Caches,
79 Unattended,
81 Updates,
83}
84
85impl Category {
86 fn title(self) -> &'static str {
93 match self {
94 Category::Presentation => i18n::t("config.category.presentation"),
95 Category::Scope => i18n::t("config.category.scope"),
96 Category::Safety => i18n::t("config.category.safety"),
97 Category::BuildTrees => i18n::t("config.category.build_trees"),
98 Category::Caches => i18n::t("config.category.caches"),
99 Category::Unattended => i18n::t("config.category.unattended"),
100 Category::Updates => i18n::t("config.category.updates"),
101 }
102 }
103}
104
105const CATEGORIES: &[Category] = &[
107 Category::Presentation,
108 Category::Scope,
109 Category::Safety,
110 Category::BuildTrees,
111 Category::Caches,
112 Category::Unattended,
113 Category::Updates,
114];
115
116fn settings_by_category() -> Vec<(Category, Vec<&'static Setting>)> {
122 CATEGORIES
123 .iter()
124 .map(|&category| {
125 (
126 category,
127 SETTINGS.iter().filter(|s| s.category == category).collect(),
128 )
129 })
130 .collect()
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139enum Kind {
140 Toggle,
142 Number,
144 Adapters,
146 CacheCaps,
152 Choice,
157 AdapterDays,
164}
165
166struct Recommendation {
172 key: &'static str,
173 label: &'static str,
175 why: &'static str,
177 value: &'static str,
180 cautious: bool,
182}
183
184const SAFE_TIER: &str = "Recommended";
191
192const CAUTIOUS_TIER: &str = "Recommended, with one thing to know first";
195
196const RECOMMENDED: &[Recommendation] = &[
203 Recommendation {
204 key: "enable_cargo",
205 label: "Rust build folders",
206 why: "Rust `target/` directories are usually the largest thing on a developer's disk — \
207 tens of gigabytes across a handful of old projects. Nothing is lost: `cargo build` \
208 rebuilds it, and a project has to sit untouched for 45 days before this one is even \
209 considered.",
210 value: "true",
211 cautious: false,
212 },
213 Recommendation {
214 key: "enable_gradle",
215 label: "Android / Gradle builds",
216 why: "`build/` and `.gradle/` grow with every Android build and are never cleaned up by \
217 anything else. They come back on the next build, under the same 45-day wait.",
218 value: "true",
219 cautious: false,
220 },
221 Recommendation {
222 key: "enable_maven",
223 label: "Maven builds",
224 why: "Maven `target/` directories accumulate quietly per module, so a multi-module project \
225 has several. `mvn package` brings them back.",
226 value: "true",
227 cautious: false,
228 },
229 Recommendation {
230 key: "enable_swift",
231 label: "Swift builds",
232 why: "`.build/` holds compiled modules for every configuration you have ever built, and \
233 `swift build` recreates the one you actually use.",
234 value: "true",
235 cautious: false,
236 },
237 Recommendation {
238 key: "enable_dart",
239 label: "Dart / Flutter caches",
240 why: "`.dart_tool/` carries the pub metadata — back in a second — alongside `build_runner` \
241 and `flutter_build` caches that are worth real disk space.",
242 value: "true",
243 cautious: false,
244 },
245 Recommendation {
246 key: "enable_mix_build",
247 label: "Elixir build trees",
248 why: "`_build/` holds compiled beam files for every Mix environment you have built, and \
249 `mix compile` recreates the one you are working in.",
250 value: "true",
251 cautious: false,
252 },
253 Recommendation {
254 key: "enable_vcpkg",
255 label: "C / C++ vcpkg trees",
256 why: "`vcpkg_installed/` holds libraries vcpkg compiled from source for one \
257 project, and `vcpkg install` builds them again from the manifest beside \
258 them.",
259 value: "true",
260 cautious: false,
261 },
262 Recommendation {
263 key: "enable_cmake_build",
264 label: "C / C++ CMake build trees",
265 why: "A configured CMake build tree is object files and linked binaries, and \
266 `cmake` writes a `CMakeCache.txt` at the top of it that says which sources \
267 build it again — so a `build/` you made by hand is left alone.",
268 value: "true",
269 cautious: false,
270 },
271 Recommendation {
272 key: "allow_manifest_rewrite",
273 label: "Let cargo and go tidy up",
274 why: "Cautious, not risky. The commands that restore a Rust or Go project can also update \
275 `Cargo.lock` or `go.mod` — files Git tracks. Nothing is lost and nothing is deleted, \
276 but the next `git status` may show a change you did not make by hand. Turn it on if \
277 that is fine; leave it off if a clean working tree matters more than a fully \
278 automatic restore.",
279 value: "true",
280 cautious: true,
281 },
282];
283
284const SETTINGS: &[Setting] = &[
286 Setting {
287 key: "language",
288 category: Category::Presentation,
289 since: "1.10.0",
290 kind: Kind::Choice,
291 help: "Language for dev-prune's own headings and summary lines. `--json`, exit codes, flag names and config keys stay English in every language.",
292 plain: "What language dev-prune talks to you in. Only its own headings change — the words you type and anything a script reads stay in English, so nothing breaks. Everything but English is a community translation, and some have not been proofread yet.",
293 get: |s| s.language.clone(),
294 set: |s, v| {
295 let code = v.trim();
296 let Some(meta) = i18n::language(code) else {
297 bail!(
298 "unknown language `{code}` — available: {}",
299 i18n::catalogue_line()
300 );
301 };
302 s.language = meta.code.clone();
303 Ok(())
304 },
305 },
306 Setting {
307 key: "idle_days",
308 category: Category::Scope,
309 since: "1.0.0",
310 kind: Kind::Number,
311 help: "Days a repository must sit untouched before it is eligible for pruning.",
312 plain: "How long a project has to sit untouched before dev-prune will clean it. Something you worked on yesterday is never touched.",
313 get: |s| s.idle_days.to_string(),
314 set: |s, v| {
315 s.idle_days = v
316 .parse()
317 .map_err(|_| anyhow::anyhow!("idle_days must be a whole number of days"))?;
318 Ok(())
319 },
320 },
321 Setting {
322 key: "min_size_mb",
323 category: Category::Scope,
324 since: "1.0.0",
325 kind: Kind::Number,
326 help: "Smallest bloat directory worth deleting, in MiB. 0 removes the floor.",
327 plain: "Ignore small folders. Deleting a 2 MB folder is not worth the download to get it back.",
328 get: |s| s.min_size_mb.to_string(),
329 set: |s, v| {
330 s.min_size_mb = v.parse().map_err(|_| {
331 anyhow::anyhow!("min_size_mb must be a whole number of MiB (0 disables the floor)")
332 })?;
333 Ok(())
334 },
335 },
336 Setting {
337 key: "scan_depth",
338 category: Category::Scope,
339 since: "1.0.0",
340 kind: Kind::Number,
341 help: "How many directory levels below a repo root project discovery descends.",
342 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.",
343 get: |s| s.scan_depth.to_string(),
344 set: |s, v| {
345 let depth: usize = v
346 .parse()
347 .map_err(|_| anyhow::anyhow!("scan_depth must be a positive integer"))?;
348 if depth == 0 {
352 bail!("scan_depth must be at least 1 — 0 would find no projects at all.");
353 }
354 if depth > crate::constants::MAX_SCAN_DEPTH_LIMIT {
355 bail!(
356 "scan_depth must be at most {} — deeper walks stall on generated trees.",
357 crate::constants::MAX_SCAN_DEPTH_LIMIT
358 );
359 }
360 s.scan_depth = depth;
361 Ok(())
362 },
363 },
364 Setting {
365 key: "require_confirmation",
366 category: Category::Safety,
367 since: "1.0.0",
368 kind: Kind::Toggle,
369 help: "Ask before deleting anything. Turning this off makes every run unattended.",
370 plain: "Whether dev-prune asks \"delete these?\" before it deletes. Leave this on unless you want it to run silently while you are away.",
371 get: |s| s.require_confirmation.to_string(),
372 set: |s, v| {
373 s.require_confirmation = parse_bool("require_confirmation", v)?;
374 Ok(())
375 },
376 },
377 Setting {
378 key: "allow_manifest_rewrite",
379 category: Category::Safety,
380 since: "1.0.0",
381 kind: Kind::Toggle,
382 help: "Let cargo and go run the sync command that rewrites tracked manifests.",
383 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`.",
384 get: |s| s.allow_manifest_rewrite.to_string(),
385 set: |s, v| {
386 s.allow_manifest_rewrite = parse_bool("allow_manifest_rewrite", v)?;
387 Ok(())
388 },
389 },
390 Setting {
391 key: "command_timeout_secs",
392 category: Category::Safety,
393 since: "1.0.0",
394 kind: Kind::Number,
395 help: "How long one package-manager command may run before it is killed — the lockfile check and `devp restore`, never a recompile.",
396 plain: "How long to wait for a package manager to answer before giving up on it: the lockfile check before a delete, and the reinstall `devp restore` runs. Nothing is compiled under it — the opt-in build adapters run no command at all during a prune — except a restore whose install builds a native module. Raise it on a slow connection.",
397 get: |s| s.command_timeout_secs.to_string(),
398 set: |s, v| {
399 let secs: u64 = v
400 .parse()
401 .map_err(|_| anyhow::anyhow!("command_timeout_secs must be a positive integer"))?;
402 if secs == 0 {
406 bail!(
407 "command_timeout_secs must be at least 1 — 0 would kill every command \
408 the instant it starts."
409 );
410 }
411 s.command_timeout_secs = secs;
412 Ok(())
413 },
414 },
415 Setting {
416 key: "auto_setup",
417 category: Category::Unattended,
418 since: "1.0.0",
419 kind: Kind::Toggle,
420 help: "Install missing integrations by itself, once per installed version.",
421 plain: "Whether dev-prune finishes setting itself up on its own instead of making you run `devp setup`.",
422 get: |s| s.auto_setup.to_string(),
423 set: |s, v| {
424 s.auto_setup = parse_bool("auto_setup", v)?;
425 Ok(())
426 },
427 },
428 Setting {
429 key: "auto_config",
430 category: Category::Unattended,
431 since: "1.3.0",
432 kind: Kind::Toggle,
433 help: "Write a default .devprune.json into repositories that link/init register.",
434 plain: "Drops a small settings file into each repository you register, so you can give that one project different rules later.",
435 get: |s| s.auto_config.to_string(),
436 set: |s, v| {
437 s.auto_config = parse_bool("auto_config", v)?;
438 Ok(())
439 },
440 },
441 Setting {
442 key: "auto_daemon",
443 category: Category::Unattended,
444 since: "1.0.0",
445 kind: Kind::Toggle,
446 help: "Register the OS scheduler so passes run without being remembered.",
447 plain: "Lets your operating system run dev-prune on a schedule, so you never have to remember to.",
448 get: |s| s.auto_daemon.to_string(),
449 set: |s, v| {
450 s.auto_daemon = parse_bool("auto_daemon", v)?;
451 Ok(())
452 },
453 },
454 Setting {
455 key: "check_interval_days",
456 category: Category::Unattended,
457 since: "1.0.0",
458 kind: Kind::Number,
459 help: "Days between scheduled background passes.",
460 plain: "How often that scheduled cleanup runs.",
461 get: |s| s.check_interval_days.to_string(),
462 set: |s, v| {
463 let days: u64 = v
464 .parse()
465 .map_err(|_| anyhow::anyhow!("check_interval_days must be a positive integer"))?;
466 if days == 0 {
468 bail!("check_interval_days must be at least 1.");
469 }
470 s.check_interval_days = days;
471 Ok(())
472 },
473 },
474 Setting {
475 key: "auto_discover",
476 category: Category::Unattended,
477 since: "1.14.0",
478 kind: Kind::Toggle,
479 help: "Let the scheduled pass find and register repositories you never added.",
480 plain: "Finds the rest of your projects on its own, so nothing is missed just because you never added it.",
481 get: |s| s.auto_discover.to_string(),
482 set: |s, v| {
483 s.auto_discover = parse_bool("auto_discover", v)?;
484 Ok(())
485 },
486 },
487 Setting {
488 key: "auto_hooks",
489 category: Category::Unattended,
490 since: "1.0.0",
491 kind: Kind::Toggle,
492 help: "Install the Git hooks that register repositories as you clone them.",
493 plain: "Registers new repositories automatically as you clone them, so you never have to add them by hand.",
494 get: |s| s.auto_hooks.to_string(),
495 set: |s, v| {
496 s.auto_hooks = parse_bool("auto_hooks", v)?;
497 Ok(())
498 },
499 },
500 Setting {
501 key: "auto_hooks_chain",
502 category: Category::Unattended,
503 since: "1.0.0",
504 kind: Kind::Toggle,
505 help: "If another tool owns core.hooksPath, install in front of it and forward. Off by default: that slot is machine-wide and already someone else's.",
506 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. Off by default because the slot is global to your machine and dev-prune would be taking over another tool's setup to use it. `devp doctor` names the command when it finds one of those tools holding it.",
507 get: |s| s.auto_hooks_chain.to_string(),
508 set: |s, v| {
509 s.auto_hooks_chain = parse_bool("auto_hooks_chain", v)?;
510 Ok(())
511 },
512 },
513 Setting {
514 key: "update_check",
515 category: Category::Updates,
516 since: "1.0.0",
517 kind: Kind::Toggle,
518 help: "Ask GitHub for the latest release from time to time. Sends nothing but the request.",
519 plain: "Whether dev-prune checks GitHub now and then to see if there is a newer version. It sends no information about you.",
520 get: |s| s.update_check.to_string(),
521 set: |s, v| {
522 s.update_check = parse_bool("update_check", v)?;
523 Ok(())
524 },
525 },
526 Setting {
527 key: "update_check_interval_days",
528 category: Category::Updates,
529 since: "1.0.0",
530 kind: Kind::Number,
531 help: "Days between automatic release checks.",
532 plain: "How often that version check happens.",
533 get: |s| s.update_check_interval_days.to_string(),
534 set: |s, v| {
535 let days: i64 = v.parse().map_err(|_| {
536 anyhow::anyhow!("update_check_interval_days must be a positive integer")
537 })?;
538 if days < 1 {
539 bail!("update_check_interval_days must be at least 1.");
540 }
541 s.update_check_interval_days = days;
542 Ok(())
543 },
544 },
545 Setting {
546 key: "update_check_timeout_secs",
547 category: Category::Updates,
548 since: "1.0.0",
549 kind: Kind::Number,
550 help: "Seconds the release check waits for GitHub. Raise it behind a slow proxy.",
551 plain: "How long the version check waits before giving up. Raise it if you are behind a slow proxy.",
552 get: |s| s.update_check_timeout_secs.to_string(),
553 set: |s, v| {
554 let secs: u64 = v.parse().map_err(|_| {
555 anyhow::anyhow!("update_check_timeout_secs must be a positive integer")
556 })?;
557 if secs == 0 {
558 bail!("update_check_timeout_secs must be at least 1.");
559 }
560 s.update_check_timeout_secs = secs;
561 Ok(())
562 },
563 },
564 Setting {
565 key: "enable_cargo",
566 category: Category::BuildTrees,
567 since: "1.5.0",
568 kind: Kind::Toggle,
569 help: "Turn on the opt-in Cargo adapter (target/ comes back by recompiling, not downloading).",
570 plain: "Clean Rust build folders too. These come back by recompiling, which takes minutes rather than a download — so it is off by default.",
571 get: |s| s.enable_cargo.to_string(),
572 set: |s, v| {
573 s.enable_cargo = parse_bool("enable_cargo", v)?;
574 Ok(())
575 },
576 },
577 Setting {
578 key: "enable_gradle",
579 category: Category::BuildTrees,
580 since: "1.3.0",
581 kind: Kind::Toggle,
582 help: "Turn on the opt-in Gradle adapter (build/ and .gradle/ come back by recompiling).",
583 plain: "Clean Android and Java build folders too. Same trade: they come back by recompiling, not downloading.",
584 get: |s| s.enable_gradle.to_string(),
585 set: |s, v| {
586 s.enable_gradle = parse_bool("enable_gradle", v)?;
587 Ok(())
588 },
589 },
590 Setting {
591 key: "enable_maven",
592 category: Category::BuildTrees,
593 since: "1.3.0",
594 kind: Kind::Toggle,
595 help: "Turn on the opt-in Maven adapter (target/ comes back by recompiling).",
596 plain: "Clean Maven build folders too. They come back by recompiling.",
597 get: |s| s.enable_maven.to_string(),
598 set: |s, v| {
599 s.enable_maven = parse_bool("enable_maven", v)?;
600 Ok(())
601 },
602 },
603 Setting {
604 key: "enable_swift",
605 category: Category::BuildTrees,
606 since: "1.4.0",
607 kind: Kind::Toggle,
608 help: "Turn on the opt-in SwiftPM adapter (.build/ comes back by recompiling).",
609 plain: "Clean Swift build folders too. They come back by recompiling.",
610 get: |s| s.enable_swift.to_string(),
611 set: |s, v| {
612 s.enable_swift = parse_bool("enable_swift", v)?;
613 Ok(())
614 },
615 },
616 Setting {
617 key: "enable_dart",
618 category: Category::BuildTrees,
619 since: "1.6.0",
620 kind: Kind::Toggle,
621 help: "Turn on the opt-in Dart/Flutter adapter (.dart_tool/ holds build caches).",
622 plain: "Clean Dart and Flutter caches too. Part comes back instantly, part by recompiling.",
623 get: |s| s.enable_dart.to_string(),
624 set: |s, v| {
625 s.enable_dart = parse_bool("enable_dart", v)?;
626 Ok(())
627 },
628 },
629 Setting {
630 key: "enable_mix_build",
631 category: Category::BuildTrees,
632 since: "1.7.0",
633 kind: Kind::Toggle,
634 help: "Turn on the opt-in Elixir Mix build-tree adapter (_build/ comes back by recompiling).",
635 plain: "Elixir projects only. Mix is Elixir's build tool, and it compiles your project and every dependency into `_build/` — this cleans that folder. The downloaded `deps/` folder beside it belongs to a different adapter that is already on. Off by default, because `_build/` comes back by recompiling rather than by downloading.",
636 get: |s| s.enable_mix_build.to_string(),
637 set: |s, v| {
638 s.enable_mix_build = parse_bool("enable_mix_build", v)?;
639 Ok(())
640 },
641 },
642 Setting {
643 key: "enable_vcpkg",
644 category: Category::BuildTrees,
645 since: "1.8.0",
646 kind: Kind::Toggle,
647 help: "Turn on the opt-in vcpkg adapter (vcpkg_installed/ comes back by recompiling).",
648 plain: "Clean C and C++ vcpkg_installed/ folders too. They come back by recompiling.",
649 get: |s| s.enable_vcpkg.to_string(),
650 set: |s, v| {
651 s.enable_vcpkg = parse_bool("enable_vcpkg", v)?;
652 Ok(())
653 },
654 },
655 Setting {
656 key: "enable_cmake_build",
657 category: Category::BuildTrees,
658 since: "1.8.0",
659 kind: Kind::Toggle,
660 help: "Turn on the opt-in CMake adapter (build trees proven by their CMakeCache.txt).",
661 plain: "Clean C and C++ build folders CMake configured. A `build/` you made by hand is \
662 never touched.",
663 get: |s| s.enable_cmake_build.to_string(),
664 set: |s, v| {
665 s.enable_cmake_build = parse_bool("enable_cmake_build", v)?;
666 Ok(())
667 },
668 },
669 Setting {
670 key: "build_idle_days",
671 category: Category::BuildTrees,
672 since: "1.3.0",
673 kind: Kind::Number,
674 help: "Idle days before the opt-in adapters' build trees are pruned. Applied as max(this, idle_days).",
675 plain: "A longer wait, used only for the build folders above, because getting those back costs a recompile rather than a download.",
676 get: |s| s.build_idle_days.to_string(),
677 set: |s, v| {
678 let days: u64 = v
679 .parse()
680 .map_err(|_| anyhow::anyhow!("build_idle_days must be a non-negative integer"))?;
681 s.build_idle_days = days;
682 Ok(())
683 },
684 },
685 Setting {
686 key: "auto_update",
687 category: Category::Updates,
688 since: "1.3.0",
689 kind: Kind::Toggle,
690 help: "Install a newer release by itself at the end of a prune pass. On by default.",
691 plain: "Whether dev-prune installs its own updates after a cleanup. The download is checked against its published fingerprint first.",
692 get: |s| s.auto_update.to_string(),
693 set: |s, v| {
694 s.auto_update = parse_bool("auto_update", v)?;
695 Ok(())
696 },
697 },
698 Setting {
699 key: "version_lock",
700 category: Category::Updates,
701 since: "1.8.0",
702 kind: Kind::Toggle,
703 help: "Pin this copy to the version it is. Overrides auto_update, `devp update \
704 --install`, `devp install --channel` and the install scripts.",
705 plain: "Stay on exactly this version. Nothing dev-prune does replaces the binary \
706 while this is on -- not the automatic update, not a re-run of the install \
707 one-liner.",
708 get: |s| s.version_lock.to_string(),
709 set: |s, v| {
710 s.version_lock = parse_bool("version_lock", v)?;
711 Ok(())
712 },
713 },
714 Setting {
715 key: "disabled_adapters",
716 category: Category::Scope,
717 since: "1.4.0",
718 kind: Kind::Adapters,
719 help: "Adapters to leave alone entirely, by name. Empty means every one of them is active.",
720 plain: "Ecosystems to ignore completely — as if you did not have them installed at all.",
721 get: |s| {
722 if s.disabled_adapters.is_empty() {
723 "(none)".to_string()
724 } else {
725 s.disabled_adapters.join(",")
726 }
727 },
728 set: |s, v| {
729 s.disabled_adapters = parse_adapter_list(v)?;
730 Ok(())
731 },
732 },
733 Setting {
734 key: "adapter_idle_days",
735 category: Category::Scope,
736 since: "1.5.0",
737 kind: Kind::AdapterDays,
738 help: "Per-adapter idle windows, as `cargo=60,npm=30`. Each one can only raise its own wait.",
739 plain: "A different waiting period for one ecosystem. Useful when your Rust projects should wait longer than your Node ones.",
740 get: |s| {
741 if s.adapter_idle_days.is_empty() {
742 "(none)".to_string()
743 } else {
744 s.adapter_idle_days
745 .iter()
746 .map(|(name, days)| format!("{name}={days}"))
747 .collect::<Vec<_>>()
748 .join(",")
749 }
750 },
751 set: |s, v| {
752 s.adapter_idle_days = parse_adapter_days(v)?;
753 Ok(())
754 },
755 },
756 Setting {
757 key: "cache_max_gb",
758 category: Category::Caches,
759 since: "1.8.0",
760 kind: Kind::CacheCaps,
761 help: "Per-manager cache size caps in GiB, as `npm=10,uv=10`. Reported by `devp caches`; cleared only by `devp caches clear --over-cap`.",
762 plain: "How big one ecosystem's download cache is allowed to get before dev-prune says so. It still never deletes a cache on its own.",
763 get: |s| {
764 if s.cache_max_gb.is_empty() {
765 "(none)".to_string()
766 } else {
767 s.cache_max_gb
768 .iter()
769 .map(|(name, gb)| format!("{name}={gb}"))
770 .collect::<Vec<_>>()
771 .join(",")
772 }
773 },
774 set: |s, v| {
775 s.cache_max_gb = parse_cache_caps(v)?;
776 Ok(())
777 },
778 },
779];
780
781fn parse_cache_caps(value: &str) -> Result<std::collections::BTreeMap<String, u64>> {
798 let trimmed = value.trim();
799 if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
800 return Ok(std::collections::BTreeMap::new());
801 }
802
803 let mut caps = std::collections::BTreeMap::new();
804 for raw in trimmed.split(',') {
805 let entry = raw.trim();
806 if entry.is_empty() {
807 continue;
808 }
809 let Some((name, value)) = entry.split_once('=') else {
810 bail!("`{entry}` must be written as `<manager>=<gib>`, for example `uv=10`.");
811 };
812 let name = name.trim().to_lowercase();
813 if !crate::commands::caches::is_cache_manager(&name) {
814 bail!(
815 "`{name}` is not a manager dev-prune knows a cache for. Valid names: {}",
816 crate::commands::caches::known_managers().join(", ")
817 );
818 }
819 let parsed: u64 = value.trim().parse().map_err(|_| {
820 anyhow::anyhow!(
821 "`{name}` needs a whole number of gibibytes, not `{}`.",
822 value.trim()
823 )
824 })?;
825 if parsed == 0 {
826 bail!(
827 "`{name}=0` would call the cache too big the moment it exists. Use `-` to clear the caps instead."
828 );
829 }
830 caps.insert(name, parsed);
831 }
832 Ok(caps)
833}
834
835fn parse_adapter_days(value: &str) -> Result<std::collections::BTreeMap<String, u64>> {
840 let trimmed = value.trim();
841 if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
842 return Ok(std::collections::BTreeMap::new());
843 }
844
845 let mut days = std::collections::BTreeMap::new();
846 for raw in trimmed.split(',') {
847 let entry = raw.trim();
848 if entry.is_empty() {
849 continue;
850 }
851 let Some((name, value)) = entry.split_once('=') else {
852 bail!("`{entry}` must be written as `<adapter>=<days>`, for example `cargo=60`.");
853 };
854 let name = name.trim().to_lowercase();
855 if !crate::adapters::is_adapter_name(&name) {
856 bail!(
857 "`{name}` is not an adapter. Valid names: {}",
858 crate::adapters::all_adapter_names().join(", ")
859 );
860 }
861 let parsed: u64 = value.trim().parse().map_err(|_| {
862 anyhow::anyhow!(
863 "`{name}` needs a whole number of days, not `{}`.",
864 value.trim()
865 )
866 })?;
867 days.insert(name, parsed);
868 }
869 Ok(days)
870}
871
872fn parse_adapter_list(value: &str) -> Result<Vec<String>> {
873 let trimmed = value.trim();
874 if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
877 return Ok(Vec::new());
878 }
879
880 let mut names: Vec<String> = Vec::new();
881 for raw in trimmed.split(',') {
882 let name = raw.trim().to_lowercase();
883 if name.is_empty() {
884 continue;
885 }
886 if !crate::adapters::is_adapter_name(&name) {
887 bail!(
888 "`{name}` is not an adapter. Valid names: {}",
889 crate::adapters::all_adapter_names().join(", ")
890 );
891 }
892 if !names.contains(&name) {
893 names.push(name);
894 }
895 }
896 Ok(names)
897}
898
899fn parse_bool(key: &str, value: &str) -> Result<bool> {
900 match value.trim().to_lowercase().as_str() {
901 "true" | "yes" | "y" | "on" | "1" => Ok(true),
902 "false" | "no" | "n" | "off" | "0" => Ok(false),
903 _ => bail!("{key} must be true or false"),
904 }
905}
906
907pub fn invalid_settings(settings: &Settings) -> Vec<(&'static str, String)> {
918 SETTINGS
919 .iter()
920 .filter_map(|setting| {
921 let mut probe = settings.clone();
922 (setting.set)(&mut probe, &(setting.get)(settings))
923 .err()
924 .map(|e| (setting.key, e.to_string()))
925 })
926 .collect()
927}
928
929pub fn setting_count() -> usize {
931 SETTINGS.len()
932}
933
934fn save_settings_edits<'a>(edits: impl Iterator<Item = (&'a str, &'a str)>) -> Result<()> {
943 let mut fresh = Registry::load()?;
944 for (key, value) in edits {
945 (find_setting(key)?.set)(&mut fresh.settings, value)?;
946 }
947 fresh.save()
948}
949
950fn find_setting(key: &str) -> Result<&'static Setting> {
951 SETTINGS
952 .iter()
953 .find(|s| s.key == key)
954 .ok_or_else(|| anyhow::anyhow!("Unknown config key: {key}. Valid keys: {}", valid_keys()))
955}
956
957fn valid_keys() -> String {
958 SETTINGS
959 .iter()
960 .map(|s| s.key)
961 .collect::<Vec<_>>()
962 .join(", ")
963}
964
965#[derive(Debug, PartialEq, Eq)]
967pub enum Toggle {
968 Enable,
969 Disable,
970 Status,
971}
972
973pub fn parse_toggle(action: &str) -> Result<Toggle> {
983 match action.to_lowercase().as_str() {
984 "enable" | "install" | "on" => Ok(Toggle::Enable),
985 "disable" | "uninstall" | "remove" | "off" => Ok(Toggle::Disable),
986 "" | "status" | "show" => Ok(Toggle::Status),
987 other => bail!(
988 "Unknown action `{other}`. Expected `enable`, `disable` or `status` \
989 (`install` / `uninstall` / `on` / `off` also work)."
990 ),
991 }
992}
993
994pub fn is_toggle_word(word: &str) -> bool {
1000 parse_toggle(word).is_ok() && !word.is_empty()
1001}
1002
1003fn resolve_workspace(path: &str) -> Result<std::path::PathBuf> {
1010 let raw = Path::new(path);
1011 if !raw.is_dir() {
1012 bail!(
1013 "`{path}` is neither an action nor an existing directory.\n\
1014 Expected `enable`, `disable` or `status`, or a path to a repository."
1015 );
1016 }
1017 Ok(raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf()))
1018}
1019
1020pub fn run_get(key: &str) -> Result<()> {
1022 let registry = Registry::load()?;
1023 let setting = find_setting(key)?;
1024 println!("{key} = {}", (setting.get)(®istry.settings));
1025 Ok(())
1026}
1027
1028pub fn run_set(key: &str, value: &str) -> Result<()> {
1030 let mut registry = Registry::load()?;
1031 let setting = find_setting(key)?;
1032 (setting.set)(&mut registry.settings, value)?;
1033 registry.save()?;
1034
1035 output::print_success(&format!("{key} = {}", (setting.get)(®istry.settings)));
1038
1039 if key == "language"
1043 && let Some(meta) = i18n::language(®istry.settings.language)
1044 && !meta.reviewed
1045 {
1046 output::print_info(&format!(
1047 "No native speaker has reviewed the {} translation yet. Corrections are welcome — see docs/TRANSLATIONS.md.",
1048 meta.english_name
1049 ));
1050 }
1051 Ok(())
1052}
1053
1054fn key_column_width() -> usize {
1056 SETTINGS.iter().map(|s| s.key.len()).max().unwrap_or(0)
1057}
1058
1059pub fn run_show() -> Result<()> {
1061 let registry = Registry::load()?;
1062 let width = key_column_width();
1063
1064 output::print_header("dev-prune Global Configuration");
1065 for (category, settings) in settings_by_category() {
1066 output::print_section(category.title());
1067 for setting in settings {
1068 println!(
1069 " {:<width$} = {}",
1070 setting.key,
1071 (setting.get)(®istry.settings)
1072 );
1073 }
1074 }
1075
1076 output::print_section("This machine");
1079 println!(
1080 " {:<width$} = {}",
1081 "tracked_repos",
1082 registry.repo_count()
1083 );
1084 let reg_path = Registry::registry_path()
1085 .map(|p| output::clean_path(&p))
1086 .unwrap_or_else(|_| "unknown".to_string());
1087 println!(" {:<width$} = {reg_path}", "registry_file");
1088
1089 print_recommendation_summary(®istry.settings);
1093
1094 println!();
1095 output::print_info("Change any of these with `devp config set <key> <value>`.");
1096 output::print_info("Walk through them one at a time with `devp config wizard`.");
1097
1098 Ok(())
1099}
1100
1101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1103pub enum Opened {
1104 ByRequest,
1106 OnItsOwn,
1110}
1111
1112fn why_this_opened(opened: Opened) -> Option<String> {
1119 if opened == Opened::ByRequest {
1120 return None;
1121 }
1122 let new = settings_added_since_review().len();
1123 Some(if reviewed_version().is_none() || new == 0 {
1124 "You did not ask for this screen. dev-prune opens it once, on the first command \
1125 after it is installed, so that you see what its defaults do before they start \
1126 doing it. Whatever you typed runs as soon as you leave. It will not open by \
1127 itself again unless an upgrade adds a setting."
1128 .to_string()
1129 } else {
1130 format!(
1131 "You did not ask for this screen. This upgrade added {new} {}, and dev-prune \
1132 shows a new one once before its default goes on applying. Nothing else about \
1133 your configuration changed. Whatever you typed runs as soon as you leave.",
1134 output::plural(new, "setting", "settings"),
1135 )
1136 })
1137}
1138
1139pub fn run_wizard(no_tui: bool, opened: Opened) -> Result<()> {
1150 if !no_tui && full_screen_is_usable() {
1151 return run_wizard_tui(opened);
1152 }
1153 run_wizard_prompts(opened)
1154}
1155
1156fn full_screen_is_usable() -> bool {
1164 use std::io::IsTerminal;
1165 if std::env::var_os(crate::constants::ENV_NO_TUI).is_some() {
1166 return false;
1167 }
1168 std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
1169}
1170
1171fn run_wizard_tui(opened: Opened) -> Result<()> {
1173 use crate::tui::config_view::{ConfigRow, ConfigSession, Control, Outcome};
1174
1175 let registry = Registry::load()?;
1176 let new_keys = settings_added_since_review();
1177 let fresh = Settings::default();
1181
1182 let rows: Vec<ConfigRow> = settings_by_category()
1185 .into_iter()
1186 .flat_map(|(category, settings)| {
1187 settings.into_iter().map(move |setting| (category, setting))
1188 })
1189 .map(|(category, setting)| {
1190 let value = (setting.get)(®istry.settings);
1191 ConfigRow {
1192 key: setting.key,
1193 category: category.title(),
1194 help: setting.help,
1195 plain: setting.plain,
1196 control: match setting.kind {
1197 Kind::Toggle => Control::Toggle,
1198 Kind::Choice => Control::Choice(i18n::choices()),
1199 Kind::Number => Control::Number,
1200 Kind::Adapters => Control::Adapters,
1201 Kind::AdapterDays => Control::AdapterDays,
1202 Kind::CacheCaps => Control::CacheCaps,
1203 },
1204 original: value.clone(),
1205 default: (setting.get)(&fresh),
1206 recommended: recommended_value(setting.key),
1207 cautious: recommendation(setting.key).is_some_and(|r| r.cautious),
1208 value,
1209 is_new: new_keys.contains(&setting.key),
1210 }
1211 })
1212 .collect();
1213
1214 let base = registry.settings.clone();
1217 let validate = move |key: &str, value: &str| -> std::result::Result<(), String> {
1218 let setting = find_setting(key).map_err(|e| e.to_string())?;
1219 let mut probe = base.clone();
1220 (setting.set)(&mut probe, value).map_err(|e| format!("{e}"))
1221 };
1222
1223 let report = crate::commands::trust::build(®istry);
1224 let adapters = crate::adapters::all_adapter_names();
1225 let opt_in = crate::adapters::opt_in_adapter_names();
1226 let capped: Vec<&'static str> = adapters
1229 .iter()
1230 .copied()
1231 .filter(|name| crate::commands::caches::is_cache_manager(name))
1232 .collect();
1233
1234 let why = why_this_opened(opened);
1235 let outcome = crate::tui::config_view::run(ConfigSession {
1236 declaration: declaration_lines(&report),
1237 standing: NOTHING_DELETED_YET.to_string(),
1238 suggestions: first_run_suggestions(),
1239 rows,
1240 adapters: &adapters,
1241 opt_in_adapters: &opt_in,
1242 capped_adapters: &capped,
1243 groups: crate::adapters::ADAPTER_GROUPS,
1244 validate: &validate,
1245 title: "dev-prune configuration",
1246 uninvited: why.as_deref(),
1247 })?;
1248
1249 match outcome {
1250 Outcome::Cancelled => {
1254 output::print_info("Cancelled — nothing was changed.");
1255 Ok(())
1256 }
1257 Outcome::KeepAll => {
1258 mark_reviewed();
1259 output::print_success(
1260 "Keeping the current values. `devp config set <key> <value>` changes any.",
1261 );
1262 Ok(())
1263 }
1264 Outcome::Save(changed) => {
1265 save_settings_edits(changed.iter().map(|row| (row.key, row.value.as_str())))?;
1266 mark_reviewed();
1267
1268 output::print_header("Saved");
1272 let width = changed.iter().map(|r| r.key.len()).max().unwrap_or(0);
1273 for row in &changed {
1274 println!(
1275 " {:<width$} = {} (was {})",
1276 row.key, row.value, row.original
1277 );
1278 }
1279 println!();
1280 output::print_success(&format!(
1281 "{} {} saved. `devp config show` lists every setting.",
1282 changed.len(),
1283 output::plural(changed.len(), "change", "changes")
1284 ));
1285 Ok(())
1286 }
1287 }
1288}
1289
1290fn recommended_value(key: &str) -> Option<&'static str> {
1307 recommendation(key).map(|r| r.value)
1308}
1309
1310fn recommendation(key: &str) -> Option<&'static Recommendation> {
1312 RECOMMENDED.iter().find(|r| r.key == key)
1313}
1314
1315fn outstanding(settings: &Settings) -> Vec<&'static Recommendation> {
1317 RECOMMENDED
1318 .iter()
1319 .filter(|r| {
1320 find_setting(r.key)
1321 .map(|s| (s.get)(settings))
1322 .ok()
1323 .as_deref()
1324 != Some(r.value)
1325 })
1326 .collect()
1327}
1328
1329fn print_recommendation_summary(settings: &Settings) {
1336 let outstanding = outstanding(settings);
1337 if outstanding.is_empty() {
1338 return;
1339 }
1340 let width = key_column_width();
1341
1342 let safe: Vec<_> = outstanding.iter().filter(|r| !r.cautious).collect();
1343 if !safe.is_empty() {
1344 output::print_section(SAFE_TIER);
1345 for r in &safe {
1346 println!(" {:<width$} = {} {}", r.key, r.value, r.label);
1347 }
1348 println!();
1349 output::print_info(&format!(
1350 "`devp config recommended` sets {} {} in one command.",
1351 safe.len(),
1352 output::plural(safe.len(), "setting", "settings")
1353 ));
1354 }
1355
1356 let cautious: Vec<_> = outstanding.iter().filter(|r| r.cautious).collect();
1357 if !cautious.is_empty() {
1358 output::print_section(CAUTIOUS_TIER);
1359 for r in &cautious {
1360 println!(" {:<width$} = {} {}", r.key, r.value, r.label);
1361 println!(" {:<width$} {}", "", r.why);
1362 }
1363 println!();
1364 output::print_info(
1365 "Not included above. `devp config recommended --with-cautious` includes it; \
1366 `devp config set <key> <value>` sets one on its own.",
1367 );
1368 }
1369}
1370
1371pub fn run_recommended(with_cautious: bool) -> Result<()> {
1385 let mut registry = Registry::load()?;
1386 let width = key_column_width();
1387
1388 output::print_header("dev-prune recommended settings");
1389
1390 let mut applied: Vec<(&'static str, String, &'static str)> = Vec::new();
1391 let mut already: Vec<&'static Recommendation> = Vec::new();
1392 let mut held_back: Vec<&'static Recommendation> = Vec::new();
1393
1394 for rec in RECOMMENDED {
1395 let setting = find_setting(rec.key)?;
1396 let current = (setting.get)(®istry.settings);
1397 if current == rec.value {
1398 already.push(rec);
1399 } else if rec.cautious && !with_cautious {
1400 held_back.push(rec);
1401 } else {
1402 (setting.set)(&mut registry.settings, rec.value)?;
1403 applied.push((rec.key, current, rec.value));
1404 }
1405 }
1406
1407 if !applied.is_empty() {
1408 registry.save()?;
1409 output::print_section("Turned on");
1410 for (key, from, to) in &applied {
1411 println!(" {:<width$} {from} → {to}", key);
1412 }
1413 }
1414 if !already.is_empty() {
1415 output::print_section("Already set");
1416 for rec in &already {
1417 println!(" {:<width$} {}", rec.key, rec.label);
1418 }
1419 }
1420 if !held_back.is_empty() {
1421 output::print_section(CAUTIOUS_TIER);
1422 for rec in &held_back {
1423 println!(" {:<width$} = {} {}", rec.key, rec.value, rec.label);
1424 println!(" {:<width$} {}", "", rec.why);
1425 }
1426 println!();
1427 output::print_info(
1428 "Left alone. `devp config recommended --with-cautious` includes it; \
1429 `devp config set <key> <value>` sets one on its own.",
1430 );
1431 }
1432
1433 println!();
1434 if applied.is_empty() {
1435 output::print_success(
1436 "Nothing changed — everything recommended without a caveat is already set.",
1437 );
1438 } else {
1439 output::print_success(&format!(
1440 "{} {} changed. `devp config show` lists them all.",
1441 applied.len(),
1442 output::plural(applied.len(), "setting", "settings")
1443 ));
1444 }
1445 Ok(())
1446}
1447
1448fn first_run_suggestions() -> Vec<crate::tui::config_view::Suggestion> {
1449 use crate::tui::config_view::Suggestion;
1450
1451 if reviewed_version().is_some() {
1452 return Vec::new();
1453 }
1454 RECOMMENDED
1455 .iter()
1456 .filter_map(|r| {
1457 let setting = find_setting(r.key).ok()?;
1458 Some(Suggestion {
1459 key: r.key,
1460 label: r.label,
1461 help: setting.help,
1462 plain: setting.plain,
1463 why: r.why,
1464 value: r.value,
1465 cautious: r.cautious,
1466 })
1467 })
1468 .collect()
1469}
1470
1471const NOTHING_DELETED_YET: &str =
1473 "Nothing has been deleted, and nothing will be until a lockfile proves it comes back.";
1474
1475fn provenance_rows() -> Vec<(&'static str, String)> {
1492 let url = |u: &str| u.trim_start_matches("https://").to_string();
1495 vec![
1496 (
1497 "What you are running",
1498 format!(
1499 "{} v{}",
1500 crate::constants::APP_NAME,
1501 crate::constants::VERSION
1502 ),
1503 ),
1504 (
1505 "Written by",
1506 format!("{}, under Apache-2.0", crate::constants::AUTHOR),
1507 ),
1508 ("Source code", url(crate::constants::REPO_URL)),
1509 (
1510 "Official downloads",
1511 format!("{} · GitHub releases", url(crate::constants::HOMEPAGE_URL)),
1512 ),
1513 (
1514 "Package registries",
1515 "crates.io · PyPI · npm, all named dev-prune".to_string(),
1516 ),
1517 (
1518 "Editor extension",
1519 "VS Code Marketplace · Open VSX".to_string(),
1520 ),
1521 (
1522 "Any other source",
1523 "is not a copy the author published".to_string(),
1524 ),
1525 ]
1526}
1527
1528fn declaration_lines(
1534 report: &crate::commands::trust::TrustReport,
1535) -> Vec<crate::tui::config_view::DeclarationLine> {
1536 use crate::commands::trust::{TrustRow, Verdict};
1537 use crate::tui::config_view::DeclarationLine;
1538
1539 let heading = |text: &str| DeclarationLine {
1540 mark: '#',
1541 subject: text.to_string(),
1542 state: String::new(),
1543 };
1544 let row = |r: &TrustRow| DeclarationLine {
1545 mark: match r.verdict {
1546 Verdict::Guaranteed | Verdict::Safe => '+',
1547 Verdict::Widened => '!',
1548 Verdict::Neutral => ' ',
1549 },
1550 subject: r.subject.to_string(),
1551 state: r.state.clone(),
1552 };
1553
1554 let mut lines = vec![heading("What this is, and where it came from")];
1555 lines.extend(
1556 provenance_rows()
1557 .into_iter()
1558 .map(|(subject, state)| DeclarationLine {
1559 mark: ' ',
1560 subject: subject.to_string(),
1561 state,
1562 }),
1563 );
1564 lines.push(heading(""));
1565 lines.push(heading("Guaranteed by the code"));
1566 lines.extend(report.guarantees.iter().map(&row));
1567 lines.push(heading(""));
1568 lines.push(heading("On this machine"));
1569 lines.extend(report.machine.iter().map(&row));
1570 lines
1571}
1572
1573fn run_wizard_prompts(opened: Opened) -> Result<()> {
1577 use std::io::{self, IsTerminal, Write};
1578
1579 if !io::stdin().is_terminal() {
1580 bail!(
1581 "`devp config wizard` needs a terminal to ask questions on.\n\
1582 Use `devp config show` to read the settings and `devp config set <key> <value>` \
1583 to change one."
1584 );
1585 }
1586
1587 let mut registry = Registry::load()?;
1588 let width = key_column_width();
1589 let new_keys = settings_added_since_review();
1590 let fresh = Settings::default();
1591
1592 output::print_header("dev-prune configuration");
1593 if let Some(why) = why_this_opened(opened) {
1596 output::print_warning(&why);
1597 println!();
1598 }
1599 output::print_section("What this is, and where it came from");
1600 for (subject, state) in provenance_rows() {
1601 println!(" {} {state}", output::pad_display(subject, 22));
1602 }
1603 println!(" {}", crate::constants::LICENCE_NOTICE);
1604 println!();
1605
1606 output::print_info("These are the defaults every run will use. Nothing has been changed yet.");
1607 println!();
1608 for (category, settings) in settings_by_category() {
1609 output::print_section(category.title());
1610 for setting in settings {
1611 let badge = if new_keys.contains(&setting.key) {
1614 " (new in this version)"
1615 } else {
1616 ""
1617 };
1618 println!(
1619 " {:<width$} = {}{badge}",
1620 setting.key,
1621 (setting.get)(®istry.settings)
1622 );
1623 println!(" {:<width$} {}", "", setting.help);
1624 println!(" {:<width$} {}", "", setting.plain);
1627 let mut facts = format!("default {}", (setting.get)(&fresh));
1631 if let Some(rec) = recommendation(setting.key) {
1635 facts.push_str(&format!(
1636 " · recommended {} ({}, not required)",
1637 rec.value,
1638 if rec.cautious {
1639 "read the note below first"
1640 } else {
1641 "suggested"
1642 }
1643 ));
1644 }
1645 println!(" {:<width$} {facts}", "");
1646 if let Some(rec) = recommendation(setting.key).filter(|r| r.cautious) {
1647 println!(" {:<width$} {}", "", rec.why);
1648 }
1649 }
1650 }
1651 println!();
1652
1653 print_recommendation_summary(®istry.settings);
1654 println!();
1655
1656 if confirmed_twice("Press Enter twice to keep all of these, or type anything to change them: ")?
1661 {
1662 mark_reviewed();
1663 output::print_success("Keeping the defaults. `devp config set <key> <value>` changes any.");
1664 return Ok(());
1665 }
1666
1667 println!();
1668 output::print_info("Enter a new value, or press Enter to keep the one shown.");
1669 println!();
1670
1671 let mut edits: Vec<(&'static str, String, String)> = Vec::new();
1672 let mut input_ended = false;
1673 for setting in SETTINGS {
1674 if input_ended {
1675 break;
1676 }
1677 let current = (setting.get)(®istry.settings);
1678 loop {
1679 print!(" {} [{current}]: ", setting.key);
1680 io::stdout().flush()?;
1681 let mut line = String::new();
1682 if io::stdin().read_line(&mut line)? == 0 {
1685 println!();
1686 input_ended = true;
1687 break;
1688 }
1689 let typed = line.trim();
1690 if typed.is_empty() {
1691 break;
1692 }
1693 match (setting.set)(&mut registry.settings, typed) {
1694 Ok(()) => {
1695 let now = (setting.get)(®istry.settings);
1699 if now != current {
1700 edits.push((setting.key, current.clone(), now));
1701 }
1702 break;
1703 }
1704 Err(e) => output::print_error(&format!("{e}")),
1707 }
1708 }
1709 }
1710
1711 println!();
1712 if edits.is_empty() {
1713 if input_ended {
1717 output::print_info("Input ended — nothing was changed.");
1718 return Ok(());
1719 }
1720 mark_reviewed();
1721 output::print_success("Nothing changed — the defaults are in place.");
1722 return Ok(());
1723 }
1724
1725 output::print_section("About to be saved");
1728 for (key, from, to) in &edits {
1729 println!(" {:<width$} {from} → {to}", key);
1730 }
1731 println!();
1732 if !confirmed_twice("Press Enter twice to save, or type anything to abandon: ")? {
1733 output::print_info("Nothing was written.");
1734 return Ok(());
1735 }
1736
1737 save_settings_edits(edits.iter().map(|(key, _, to)| (*key, to.as_str())))?;
1738 mark_reviewed();
1739 let changed = edits.len();
1740 println!();
1741 output::print_success(&format!(
1742 "Saved {changed} {}. `devp config show` lists them all.",
1743 output::plural(changed, "change", "changes")
1744 ));
1745 Ok(())
1746}
1747
1748fn confirmed_twice(prompt: &str) -> Result<bool> {
1757 use std::io::{self, Write};
1758
1759 for pass in 0..2 {
1760 print!(
1761 "{}",
1762 if pass == 0 {
1763 prompt
1764 } else {
1765 "Press Enter once more to confirm: "
1766 }
1767 );
1768 io::stdout().flush()?;
1769 let mut line = String::new();
1770 if io::stdin().read_line(&mut line)? == 0 {
1771 println!();
1772 return Ok(false);
1773 }
1774 if !line.trim().is_empty() {
1775 return Ok(false);
1776 }
1777 }
1778 Ok(true)
1779}
1780
1781const REVIEW_MARKER: &str = "config-reviewed";
1783
1784pub fn config_review_is_due() -> bool {
1793 let Ok(dir) = Registry::config_dir() else {
1794 return false;
1795 };
1796 if !dir.join(REVIEW_MARKER).exists() {
1797 return true;
1798 }
1799 !settings_added_since_review().is_empty()
1800}
1801
1802fn reviewed_version() -> Option<String> {
1804 let dir = Registry::config_dir().ok()?;
1805 let recorded = std::fs::read_to_string(dir.join(REVIEW_MARKER)).ok()?;
1806 let recorded = recorded.trim().to_string();
1807 (!recorded.is_empty()).then_some(recorded)
1808}
1809
1810pub fn settings_added_since_review() -> Vec<&'static str> {
1819 let Some(reviewed) = reviewed_version() else {
1820 return Vec::new();
1821 };
1822 SETTINGS
1823 .iter()
1824 .filter(|s| {
1825 crate::commands::update::compare_versions(s.since, &reviewed)
1826 == Some(std::cmp::Ordering::Greater)
1827 })
1828 .map(|s| s.key)
1829 .collect()
1830}
1831
1832fn mark_reviewed() {
1833 if let Ok(dir) = Registry::config_dir() {
1834 let _ = std::fs::create_dir_all(&dir);
1835 let _ = std::fs::write(dir.join(REVIEW_MARKER), crate::constants::VERSION);
1836 }
1837}
1838
1839pub fn skip_config_review() {
1844 mark_reviewed();
1845}
1846
1847pub fn run_global_update() -> Result<()> {
1849 output::print_header("dev-prune Global Configuration Audit & Sync");
1850
1851 let registry = Registry::load()?;
1852 let mut total_audited = 0;
1853 let mut errors_found = 0;
1854
1855 for repo_path in registry.repositories.keys() {
1856 let clean = output::clean_path(repo_path);
1857
1858 if !repo_path.exists() {
1862 output::print_warning(&format!(
1863 "Skipped {clean} — the path no longer exists. `devp unlink --missing` \
1864 clears such entries."
1865 ));
1866 continue;
1867 }
1868 total_audited += 1;
1869
1870 match PerRepoConfig::load_personal_for_write(repo_path) {
1871 Ok(Some(cfg)) => {
1872 if let Err(e) = cfg.save_to_repo(repo_path) {
1873 output::print_error(&format!("Failed to write config for {clean}: {e}"));
1874 errors_found += 1;
1875 } else {
1876 output::print_success(&format!("Audited & synced config for {clean}"));
1877 }
1878 }
1879 Ok(None) => {
1880 output::print_info(&format!(
1884 "{clean} has no .devprune.json — global defaults apply."
1885 ));
1886 }
1887 Err(err_msg) => {
1888 errors_found += 1;
1889 output::print_error(&format!("Syntax/Schema Error in {clean}:"));
1890 for line in err_msg.lines() {
1891 eprintln!(" {line}");
1892 }
1893 output::print_info(&format!(
1894 "Hint: fix the syntax by hand, or run `devp config {clean} --update` to \
1895 replace the file with a valid default."
1896 ));
1897 }
1898 }
1899 }
1900
1901 if errors_found > 0 {
1902 anyhow::bail!(
1905 "Audit complete: {total_audited} repos checked, {errors_found} could not be read \
1906 or written."
1907 );
1908 }
1909 output::print_success(&format!(
1910 "Audit complete: All {total_audited} registered repositories are healthy & synced!"
1911 ));
1912
1913 Ok(())
1914}
1915
1916pub fn run_path_config(path_str: &str, force_update: bool, team: bool) -> Result<()> {
1921 let raw_path = Path::new(path_str);
1922
1923 let path = if raw_path.exists() {
1924 raw_path
1925 .canonicalize()
1926 .unwrap_or_else(|_| raw_path.to_path_buf())
1927 } else {
1928 raw_path.to_path_buf()
1929 };
1930
1931 let clean = output::clean_path(&path);
1932
1933 if !path.exists() {
1934 bail!("Path does not exist: {clean}");
1935 }
1936
1937 if !crate::scanner::is_git_repo(&path) {
1938 bail!(
1940 "`{clean}` is not a Git repository.\n \
1941 Run `git init` there first, then `devp config {clean}` again."
1942 );
1943 }
1944
1945 let mut registry = Registry::load()?;
1946 if !registry.repositories.contains_key(&path) {
1947 output::print_info(&format!(
1948 "{clean} is not yet registered with dev-prune. Registering now..."
1949 ));
1950 registry.add_repo(path.clone());
1951 registry.save()?;
1952 }
1953
1954 let name = if team {
1955 crate::constants::PROJECT_REPO_CONFIG_FILE
1956 } else {
1957 crate::constants::PER_REPO_CONFIG_FILE
1958 };
1959 let cfg_file = path.join(name);
1960
1961 if cfg_file.exists() && !force_update {
1962 output::print_header(&format!("dev-prune Per-Repo Config for {clean}"));
1963 match crate::config::RepoConfigLayers::load(&path) {
1964 Ok(layers) => {
1965 let addressed = if team {
1966 layers.project_config()
1967 } else {
1968 layers.personal_config()
1969 };
1970 println!("{}", serde_json::to_string_pretty(&addressed)?);
1971 output::print_info(&format!("File location: {name}"));
1972 print_layer_provenance(&layers);
1973 }
1974 Err(err_msg) => {
1975 output::print_error(&format!("Invalid configuration in {clean}:"));
1976 for line in err_msg.lines() {
1977 eprintln!(" {line}");
1978 }
1979 anyhow::bail!(
1982 "Run `devp config {clean} --update` to reset this file back to defaults \
1983 (your current overrides in it are discarded)."
1984 );
1985 }
1986 }
1987 } else {
1988 output::print_info(&format!("Initializing {name} for {clean}..."));
1989 if team {
1990 crate::config::write_project_starter(&path)?;
1991 } else {
1992 PerRepoConfig::default().save_to_repo(&path)?;
1993 }
1994 output::print_success(&format!("Created {name} in {clean}"));
1995 if team {
1996 output::print_info(
1997 "It starts empty on purpose: every key it names overrules \
1998 `.devprune.json`, so it should only name the ones your team decides.",
1999 );
2000 output::print_info(
2001 "`prunable.directories` is the exception — the two files' lists add \
2002 up, so naming one here never discards somebody's own.",
2003 );
2004 output::print_info(
2005 "Commit it. Unlike `.devprune.json`, this file is not added to \
2006 `.git/info/exclude` — being shared is the whole reason it exists.",
2007 );
2008 }
2009 }
2010
2011 Ok(())
2012}
2013
2014fn print_layer_provenance(layers: &crate::config::RepoConfigLayers) {
2022 if layers.project_config().is_none() || layers.personal_config().is_none() {
2023 return;
2024 }
2025 output::print_section("Effective values");
2026 for (key, value, source) in layers.rows() {
2027 println!(
2028 " {} {} {}",
2029 output::pad_display(key, 20),
2030 output::pad_display(&value, 14),
2031 source.label()
2032 );
2033 }
2034}
2035
2036fn load_workspace_config_for_write(repo_path: &Path) -> Result<PerRepoConfig> {
2042 match PerRepoConfig::load_personal_for_write(repo_path) {
2043 Ok(Some(cfg)) => Ok(cfg),
2044 Ok(None) => Ok(PerRepoConfig::default()),
2045 Err(e) => bail!(
2046 "{e}\n \
2047 Fix that file, or run `devp config {} --update` to reset it back to defaults \
2048 (your current overrides in it are discarded).",
2049 output::clean_path(repo_path)
2050 ),
2051 }
2052}
2053
2054pub fn run_daemon_toggle(path: Option<&str>, action: &str) -> Result<()> {
2056 if let Some(p) = path {
2057 let repo_path = resolve_workspace(p)?;
2058 let mut cfg = load_workspace_config_for_write(&repo_path)?;
2059 match parse_toggle(action)? {
2060 Toggle::Enable => {
2061 cfg.disable_daemon = false;
2062 cfg.save_to_repo(&repo_path)?;
2063 output::print_success(&format!(
2064 "Enabled background daemon for workspace: {}",
2065 output::clean_path(&repo_path)
2066 ));
2067 }
2068 Toggle::Disable => {
2069 cfg.disable_daemon = true;
2070 cfg.save_to_repo(&repo_path)?;
2071 output::print_success(&format!(
2072 "Disabled background daemon for workspace: {}",
2073 output::clean_path(&repo_path)
2074 ));
2075 }
2076 Toggle::Status => {
2077 let st = if cfg.disable_daemon {
2078 "Disabled for workspace"
2079 } else {
2080 "Enabled for workspace"
2081 };
2082 output::print_info(&format!(
2083 "Daemon Status ({}): {}",
2084 output::clean_path(&repo_path),
2085 st
2086 ));
2087 }
2088 }
2089 } else {
2090 match parse_toggle(action)? {
2091 Toggle::Enable => crate::commands::daemon::run_install()?,
2092 Toggle::Disable => crate::commands::daemon::run_uninstall()?,
2093 Toggle::Status => crate::commands::daemon::run_status()?,
2094 }
2095 }
2096 Ok(())
2097}
2098
2099pub fn run_hook_toggle(path: Option<&str>, action: &str, chain: bool) -> Result<()> {
2101 if let Some(p) = path {
2102 if chain {
2103 bail!(
2104 "`--chain` changes the single global `core.hooksPath`, so it has no \
2105 per-workspace form. Drop the path: `devp hook install --chain`."
2106 );
2107 }
2108 let repo_path = resolve_workspace(p)?;
2109 let mut cfg = load_workspace_config_for_write(&repo_path)?;
2110 match parse_toggle(action)? {
2111 Toggle::Enable => {
2112 cfg.disable_hooks = false;
2113 cfg.save_to_repo(&repo_path)?;
2114 output::print_success(&format!(
2115 "Enabled background Git hooks for workspace: {}",
2116 output::clean_path(&repo_path)
2117 ));
2118 }
2119 Toggle::Disable => {
2120 cfg.disable_hooks = true;
2121 cfg.save_to_repo(&repo_path)?;
2122 output::print_success(&format!(
2123 "Disabled background Git hooks for workspace: {}",
2124 output::clean_path(&repo_path)
2125 ));
2126 }
2127 Toggle::Status => {
2128 let st = if cfg.disable_hooks {
2129 "Disabled for workspace"
2130 } else {
2131 "Enabled for workspace"
2132 };
2133 output::print_info(&format!(
2134 "Git Hook Status ({}): {}",
2135 output::clean_path(&repo_path),
2136 st
2137 ));
2138 }
2139 }
2140 } else {
2141 match parse_toggle(action)? {
2142 Toggle::Enable => crate::commands::hook::run_install(chain)?,
2143 Toggle::Disable => crate::commands::hook::run_uninstall()?,
2144 Toggle::Status => crate::commands::hook::run_status()?,
2145 }
2146 }
2147 Ok(())
2148}
2149
2150#[cfg(test)]
2151mod tests {
2152 use super::*;
2153
2154 #[test]
2155 fn enable_synonyms_all_resolve_to_enable() {
2156 for word in ["enable", "install", "on", "INSTALL", "On"] {
2157 assert_eq!(parse_toggle(word).unwrap(), Toggle::Enable, "{word}");
2158 }
2159 }
2160
2161 #[test]
2162 fn disable_synonyms_all_resolve_to_disable() {
2163 for word in ["disable", "uninstall", "remove", "off", "Uninstall"] {
2164 assert_eq!(parse_toggle(word).unwrap(), Toggle::Disable, "{word}");
2165 }
2166 }
2167
2168 #[test]
2169 fn status_is_the_default_and_is_also_spellable() {
2170 for word in ["", "status", "show"] {
2171 assert_eq!(parse_toggle(word).unwrap(), Toggle::Status, "{word}");
2172 }
2173 }
2174
2175 #[test]
2176 fn a_typo_is_an_error_rather_than_a_silent_status_report() {
2177 let err = parse_toggle("enabel").unwrap_err().to_string();
2180 assert!(err.contains("enabel"), "{err}");
2181 assert!(err.contains("enable"), "{err}");
2182 }
2183
2184 #[test]
2185 fn a_workspace_toggle_refuses_to_write_over_a_broken_config() {
2186 let tmp = tempfile::TempDir::new().unwrap();
2189 let broken = r#"{ "project_name": "api", "override_idle_days": 90, }"#;
2190 std::fs::write(
2191 tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE),
2192 broken,
2193 )
2194 .unwrap();
2195
2196 let err = load_workspace_config_for_write(tmp.path())
2197 .unwrap_err()
2198 .to_string();
2199 assert!(err.contains("Syntax error"), "{err}");
2200 assert!(err.contains("--update"), "{err}");
2201
2202 let on_disk =
2204 std::fs::read_to_string(tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE))
2205 .unwrap();
2206 assert_eq!(on_disk, broken);
2207 }
2208
2209 #[test]
2210 fn a_workspace_with_no_config_yet_starts_from_the_defaults() {
2211 let tmp = tempfile::TempDir::new().unwrap();
2212 assert_eq!(
2213 load_workspace_config_for_write(tmp.path()).unwrap(),
2214 PerRepoConfig::default()
2215 );
2216 }
2217
2218 #[test]
2219 fn a_cache_cap_is_written_the_way_it_is_read_back() {
2220 let caps = parse_cache_caps("uv=10,npm=4").unwrap();
2221 assert_eq!(caps.get("uv"), Some(&10));
2222 assert_eq!(caps.get("npm"), Some(&4));
2223 let settings = Settings {
2226 cache_max_gb: parse_cache_caps("UV = 10 , npm=4").unwrap(),
2227 ..Settings::default()
2228 };
2229 let printed = SETTINGS
2230 .iter()
2231 .find(|s| s.key == "cache_max_gb")
2232 .map(|s| (s.get)(&settings))
2233 .unwrap();
2234 assert_eq!(printed, "npm=4,uv=10");
2235 assert_eq!(parse_cache_caps(&printed).unwrap(), settings.cache_max_gb);
2236 }
2237
2238 #[test]
2239 fn clearing_the_caps_is_spelled_the_way_the_getter_prints_an_empty_map() {
2240 for blank in ["", "-", "none", "(none)", "NONE"] {
2241 assert!(
2242 parse_cache_caps(blank).unwrap().is_empty(),
2243 "`{blank}` should clear every cap"
2244 );
2245 }
2246 }
2247
2248 #[test]
2249 fn a_cap_on_something_that_is_not_a_cache_is_refused_with_the_list() {
2250 let err = parse_cache_caps("venv=10").unwrap_err().to_string();
2253 assert!(err.contains("venv"), "{err}");
2254 assert!(err.contains("npm"), "the error lists what is valid: {err}");
2255 }
2256
2257 #[test]
2258 fn a_cap_has_to_be_a_whole_number_of_gibibytes() {
2259 for bad in ["uv=10.5", "uv=ten", "uv=-1", "uv="] {
2260 assert!(parse_cache_caps(bad).is_err(), "`{bad}` was accepted");
2261 }
2262 assert!(parse_cache_caps("uv").is_err());
2265 }
2266
2267 #[test]
2268 fn a_cap_of_zero_is_refused_rather_than_stored() {
2269 let err = parse_cache_caps("uv=0").unwrap_err().to_string();
2272 assert!(
2273 err.contains("`-`"),
2274 "the error names the way to clear it: {err}"
2275 );
2276 }
2277
2278 #[test]
2279 fn every_setting_round_trips_through_its_own_getter() {
2280 let mut settings = Settings::default();
2284 for setting in SETTINGS {
2285 let before = (setting.get)(&settings);
2286 let probe = match setting.kind {
2287 Kind::Toggle => if before == "true" { "false" } else { "true" }.to_string(),
2288 Kind::Number => "7".to_string(),
2291 Kind::Adapters => "cargo".to_string(),
2294 Kind::AdapterDays => "cargo=45".to_string(),
2297 Kind::CacheCaps => "cargo=10".to_string(),
2300 Kind::Choice => "hi".to_string(),
2303 };
2304 (setting.set)(&mut settings, &probe)
2305 .unwrap_or_else(|e| panic!("{} rejected `{probe}`: {e}", setting.key));
2306 assert_eq!(
2307 (setting.get)(&settings),
2308 probe,
2309 "{} reads back a different field than it writes",
2310 setting.key
2311 );
2312 }
2313 }
2314
2315 #[test]
2316 fn every_setting_is_documented_and_uniquely_named() {
2317 let mut seen = std::collections::HashSet::new();
2318 for setting in SETTINGS {
2319 assert!(seen.insert(setting.key), "duplicate key {}", setting.key);
2320 assert!(!setting.help.is_empty(), "{} has no help", setting.key);
2321 assert!(
2322 !setting.plain.is_empty(),
2323 "{} has no plain text",
2324 setting.key
2325 );
2326 assert!(
2328 setting.help.ends_with('.'),
2329 "{} help should read as a sentence",
2330 setting.key
2331 );
2332 assert!(
2333 setting.plain.ends_with('.'),
2334 "{} plain text should read as a sentence",
2335 setting.key
2336 );
2337 assert_ne!(
2340 setting.plain, setting.help,
2341 "{} says the same thing twice",
2342 setting.key
2343 );
2344 }
2345 }
2346
2347 #[test]
2348 fn the_settings_table_covers_every_field_of_settings() {
2349 let json = serde_json::to_value(Settings::default()).unwrap();
2353 let fields: Vec<String> = json.as_object().unwrap().keys().cloned().collect();
2354 for field in fields {
2355 assert!(
2356 SETTINGS.iter().any(|s| s.key == field),
2357 "`{field}` is a setting with no entry in SETTINGS, so `devp config set \
2358 {field}` cannot reach it"
2359 );
2360 }
2361 }
2362
2363 #[test]
2364 fn a_rejected_value_leaves_the_previous_one_in_place() {
2365 let mut settings = Settings::default();
2366 assert!((find_setting("scan_depth").unwrap().set)(&mut settings, "0").is_err());
2367 assert_eq!(settings.scan_depth, Settings::default().scan_depth);
2368
2369 assert!((find_setting("command_timeout_secs").unwrap().set)(&mut settings, "0").is_err());
2370 assert!((find_setting("check_interval_days").unwrap().set)(&mut settings, "0").is_err());
2371 assert!(
2372 (find_setting("update_check_interval_days").unwrap().set)(&mut settings, "0").is_err()
2373 );
2374 }
2375
2376 #[test]
2377 fn booleans_accept_the_words_people_actually_type() {
2378 assert!(parse_bool("k", "yes").unwrap());
2379 assert!(parse_bool("k", "ON").unwrap());
2380 assert!(!parse_bool("k", "0").unwrap());
2381 assert!(parse_bool("k", "maybe").is_err());
2382 }
2383
2384 #[test]
2385 fn an_unknown_key_lists_the_ones_that_exist() {
2386 let err = match find_setting("idel_days") {
2387 Ok(_) => panic!("`idel_days` is not a setting"),
2388 Err(e) => e.to_string(),
2389 };
2390 assert!(err.contains("idle_days"), "{err}");
2391 }
2392
2393 #[test]
2394 fn a_path_is_never_mistaken_for_an_action() {
2395 assert!(!is_toggle_word("~/Code/my-repo"));
2397 assert!(!is_toggle_word("."));
2398 assert!(!is_toggle_word(""));
2399 assert!(is_toggle_word("install"));
2400 }
2401}