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 taken: Option<fn(&str) -> bool>,
192}
193
194const SAFE_TIER: &str = "Recommended";
201
202const CAUTIOUS_TIER: &str = "Recommended, with one thing to know first";
205
206const RECOMMENDED: &[Recommendation] = &[
213 Recommendation {
214 key: "enable_cargo",
215 label: "Rust build folders",
216 why: "Rust `target/` directories are usually the largest thing on a developer's disk — \
217 tens of gigabytes across a handful of old projects. Nothing is lost: `cargo build` \
218 rebuilds it, and a project has to sit untouched for 45 days before this one is even \
219 considered.",
220 value: "true",
221 cautious: false,
222 taken: None,
223 },
224 Recommendation {
225 key: "enable_gradle",
226 label: "Android / Gradle builds",
227 why: "`build/` and `.gradle/` grow with every Android build and are never cleaned up by \
228 anything else. They come back on the next build, under the same 45-day wait.",
229 value: "true",
230 cautious: false,
231 taken: None,
232 },
233 Recommendation {
234 key: "enable_maven",
235 label: "Maven builds",
236 why: "Maven `target/` directories accumulate quietly per module, so a multi-module project \
237 has several. `mvn package` brings them back.",
238 value: "true",
239 cautious: false,
240 taken: None,
241 },
242 Recommendation {
243 key: "enable_swift",
244 label: "Swift builds",
245 why: "`.build/` holds compiled modules for every configuration you have ever built, and \
246 `swift build` recreates the one you actually use.",
247 value: "true",
248 cautious: false,
249 taken: None,
250 },
251 Recommendation {
252 key: "enable_dart",
253 label: "Dart / Flutter caches",
254 why: "`.dart_tool/` carries the pub metadata — back in a second — alongside `build_runner` \
255 and `flutter_build` caches that are worth real disk space.",
256 value: "true",
257 cautious: false,
258 taken: None,
259 },
260 Recommendation {
261 key: "enable_mix_build",
262 label: "Elixir build trees",
263 why: "`_build/` holds compiled beam files for every Mix environment you have built, and \
264 `mix compile` recreates the one you are working in.",
265 value: "true",
266 cautious: false,
267 taken: None,
268 },
269 Recommendation {
270 key: "enable_vcpkg",
271 label: "C / C++ vcpkg trees",
272 why: "`vcpkg_installed/` holds libraries vcpkg compiled from source for one \
273 project, and `vcpkg install` builds them again from the manifest beside \
274 them.",
275 value: "true",
276 cautious: false,
277 taken: None,
278 },
279 Recommendation {
280 key: "enable_cmake_build",
281 label: "C / C++ CMake build trees",
282 why: "A configured CMake build tree is object files and linked binaries, and \
283 `cmake` writes a `CMakeCache.txt` at the top of it that says which sources \
284 build it again — so a `build/` you made by hand is left alone.",
285 value: "true",
286 cautious: false,
287 taken: None,
288 },
289 Recommendation {
290 key: "cache_max_gb",
291 label: "Cache size ceilings",
292 why: "Every other suggestion here is about one project's folders. This one is about the \
293 download caches all of them share, which only ever grow — npm's on the machine this \
294 was written on had passed 10 GiB while every project it served fit in a fraction of \
295 that. `default=10` is one ceiling for every manager at once. Crossing it deletes \
296 nothing: `devp caches` says which manager is over, and `devp caches clear \
297 --over-cap all` is still typed by hand. Name a manager to give it a figure of its \
298 own — `devp config set cache_max_gb default=10,npm=4`.",
299 value: crate::constants::RECOMMENDED_CACHE_CAP,
300 cautious: false,
301 taken: Some(|current| parse_cache_caps(current).is_ok_and(|caps| !caps.is_empty())),
303 },
304 Recommendation {
305 key: "allow_manifest_rewrite",
306 label: "Let cargo and go tidy up",
307 why: "Cautious, not risky. The commands that restore a Rust or Go project can also update \
308 `Cargo.lock` or `go.mod` — files Git tracks. Nothing is lost and nothing is deleted, \
309 but the next `git status` may show a change you did not make by hand. Turn it on if \
310 that is fine; leave it off if a clean working tree matters more than a fully \
311 automatic restore.",
312 value: "true",
313 cautious: true,
314 taken: None,
315 },
316];
317
318const SETTINGS: &[Setting] = &[
320 Setting {
321 key: "language",
322 category: Category::Presentation,
323 since: "1.10.0",
324 kind: Kind::Choice,
325 help: "Language for dev-prune's own headings and summary lines. `--json`, exit codes, flag names and config keys stay English in every language.",
326 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.",
327 get: |s| s.language.clone(),
328 set: |s, v| {
329 let code = v.trim();
330 let Some(meta) = i18n::language(code) else {
331 bail!(
332 "unknown language `{code}` — available: {}",
333 i18n::catalogue_line()
334 );
335 };
336 s.language = meta.code.clone();
337 Ok(())
338 },
339 },
340 Setting {
341 key: "idle_days",
342 category: Category::Scope,
343 since: "1.0.0",
344 kind: Kind::Number,
345 help: "Days a repository must sit untouched before it is eligible for pruning.",
346 plain: "How long a project has to sit untouched before dev-prune will clean it. Something you worked on yesterday is never touched.",
347 get: |s| s.idle_days.to_string(),
348 set: |s, v| {
349 s.idle_days = v
350 .parse()
351 .map_err(|_| anyhow::anyhow!("idle_days must be a whole number of days"))?;
352 Ok(())
353 },
354 },
355 Setting {
356 key: "min_size_mb",
357 category: Category::Scope,
358 since: "1.0.0",
359 kind: Kind::Number,
360 help: "Smallest bloat directory worth deleting, in MiB. 0 removes the floor.",
361 plain: "Ignore small folders. Deleting a 2 MB folder is not worth the download to get it back.",
362 get: |s| s.min_size_mb.to_string(),
363 set: |s, v| {
364 s.min_size_mb = v.parse().map_err(|_| {
365 anyhow::anyhow!("min_size_mb must be a whole number of MiB (0 disables the floor)")
366 })?;
367 Ok(())
368 },
369 },
370 Setting {
371 key: "scan_depth",
372 category: Category::Scope,
373 since: "1.0.0",
374 kind: Kind::Number,
375 help: "How many directory levels below a repo root project discovery descends.",
376 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.",
377 get: |s| s.scan_depth.to_string(),
378 set: |s, v| {
379 let depth: usize = v
380 .parse()
381 .map_err(|_| anyhow::anyhow!("scan_depth must be a positive integer"))?;
382 if depth == 0 {
386 bail!("scan_depth must be at least 1 — 0 would find no projects at all.");
387 }
388 if depth > crate::constants::MAX_SCAN_DEPTH_LIMIT {
389 bail!(
390 "scan_depth must be at most {} — deeper walks stall on generated trees.",
391 crate::constants::MAX_SCAN_DEPTH_LIMIT
392 );
393 }
394 s.scan_depth = depth;
395 Ok(())
396 },
397 },
398 Setting {
399 key: "require_confirmation",
400 category: Category::Safety,
401 since: "1.0.0",
402 kind: Kind::Toggle,
403 help: "Ask before deleting anything. Turning this off makes every run unattended.",
404 plain: "Whether dev-prune asks \"delete these?\" before it deletes. Leave this on unless you want it to run silently while you are away.",
405 get: |s| s.require_confirmation.to_string(),
406 set: |s, v| {
407 s.require_confirmation = parse_bool("require_confirmation", v)?;
408 Ok(())
409 },
410 },
411 Setting {
412 key: "allow_manifest_rewrite",
413 category: Category::Safety,
414 since: "1.0.0",
415 kind: Kind::Toggle,
416 help: "Let cargo and go run the sync command that rewrites tracked manifests.",
417 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`.",
418 get: |s| s.allow_manifest_rewrite.to_string(),
419 set: |s, v| {
420 s.allow_manifest_rewrite = parse_bool("allow_manifest_rewrite", v)?;
421 Ok(())
422 },
423 },
424 Setting {
425 key: "command_timeout_secs",
426 category: Category::Safety,
427 since: "1.0.0",
428 kind: Kind::Number,
429 help: "How long one package-manager command may run before it is killed — the lockfile check and `devp restore`, never a recompile.",
430 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.",
431 get: |s| s.command_timeout_secs.to_string(),
432 set: |s, v| {
433 let secs: u64 = v
434 .parse()
435 .map_err(|_| anyhow::anyhow!("command_timeout_secs must be a positive integer"))?;
436 if secs == 0 {
440 bail!(
441 "command_timeout_secs must be at least 1 — 0 would kill every command \
442 the instant it starts."
443 );
444 }
445 s.command_timeout_secs = secs;
446 Ok(())
447 },
448 },
449 Setting {
450 key: "auto_setup",
451 category: Category::Unattended,
452 since: "1.0.0",
453 kind: Kind::Toggle,
454 help: "Install missing integrations by itself, once per installed version.",
455 plain: "Whether dev-prune finishes setting itself up on its own instead of making you run `devp setup`.",
456 get: |s| s.auto_setup.to_string(),
457 set: |s, v| {
458 s.auto_setup = parse_bool("auto_setup", v)?;
459 Ok(())
460 },
461 },
462 Setting {
463 key: "auto_config",
464 category: Category::Unattended,
465 since: "1.3.0",
466 kind: Kind::Toggle,
467 help: "Write a default .devprune.json into repositories that link/init register.",
468 plain: "Drops a small settings file into each repository you register, so you can give that one project different rules later.",
469 get: |s| s.auto_config.to_string(),
470 set: |s, v| {
471 s.auto_config = parse_bool("auto_config", v)?;
472 Ok(())
473 },
474 },
475 Setting {
476 key: "auto_daemon",
477 category: Category::Unattended,
478 since: "1.0.0",
479 kind: Kind::Toggle,
480 help: "Register the OS scheduler so passes run without being remembered.",
481 plain: "Lets your operating system run dev-prune on a schedule, so you never have to remember to.",
482 get: |s| s.auto_daemon.to_string(),
483 set: |s, v| {
484 s.auto_daemon = parse_bool("auto_daemon", v)?;
485 Ok(())
486 },
487 },
488 Setting {
489 key: "check_interval_days",
490 category: Category::Unattended,
491 since: "1.0.0",
492 kind: Kind::Number,
493 help: "Days between scheduled background passes.",
494 plain: "How often that scheduled cleanup runs.",
495 get: |s| s.check_interval_days.to_string(),
496 set: |s, v| {
497 let days: u64 = v
498 .parse()
499 .map_err(|_| anyhow::anyhow!("check_interval_days must be a positive integer"))?;
500 if days == 0 {
502 bail!("check_interval_days must be at least 1.");
503 }
504 s.check_interval_days = days;
505 Ok(())
506 },
507 },
508 Setting {
509 key: "auto_discover",
510 category: Category::Unattended,
511 since: "1.14.0",
512 kind: Kind::Toggle,
513 help: "Let the scheduled pass register repositories no Git hook could see — unzipped, copied or restored ones.",
514 plain: "Finds projects you never added by looking beside the ones you already have, so a repository you unzipped or copied from another machine still gets cleaned up.",
515 get: |s| s.auto_discover.to_string(),
516 set: |s, v| {
517 s.auto_discover = parse_bool("auto_discover", v)?;
518 Ok(())
519 },
520 },
521 Setting {
522 key: "auto_hooks",
523 category: Category::Unattended,
524 since: "1.0.0",
525 kind: Kind::Toggle,
526 help: "Install the Git hooks that register a repository when you clone, commit or merge in it.",
527 plain: "Adds repositories as Git creates them. Anything Git did not create — a copied or unzipped project — is left to `auto_discover`.",
528 get: |s| s.auto_hooks.to_string(),
529 set: |s, v| {
530 s.auto_hooks = parse_bool("auto_hooks", v)?;
531 Ok(())
532 },
533 },
534 Setting {
535 key: "auto_hooks_chain",
536 category: Category::Unattended,
537 since: "1.0.0",
538 kind: Kind::Toggle,
539 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.",
540 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.",
541 get: |s| s.auto_hooks_chain.to_string(),
542 set: |s, v| {
543 s.auto_hooks_chain = parse_bool("auto_hooks_chain", v)?;
544 Ok(())
545 },
546 },
547 Setting {
548 key: "update_check",
549 category: Category::Updates,
550 since: "1.0.0",
551 kind: Kind::Toggle,
552 help: "Ask GitHub for the latest release from time to time. Sends nothing but the request.",
553 plain: "Whether dev-prune checks GitHub now and then to see if there is a newer version. It sends no information about you.",
554 get: |s| s.update_check.to_string(),
555 set: |s, v| {
556 s.update_check = parse_bool("update_check", v)?;
557 Ok(())
558 },
559 },
560 Setting {
561 key: "update_check_interval_days",
562 category: Category::Updates,
563 since: "1.0.0",
564 kind: Kind::Number,
565 help: "Days between automatic release checks.",
566 plain: "How often that version check happens.",
567 get: |s| s.update_check_interval_days.to_string(),
568 set: |s, v| {
569 let days: i64 = v.parse().map_err(|_| {
570 anyhow::anyhow!("update_check_interval_days must be a positive integer")
571 })?;
572 if days < 1 {
573 bail!("update_check_interval_days must be at least 1.");
574 }
575 s.update_check_interval_days = days;
576 Ok(())
577 },
578 },
579 Setting {
580 key: "update_check_timeout_secs",
581 category: Category::Updates,
582 since: "1.0.0",
583 kind: Kind::Number,
584 help: "Seconds the release check waits for GitHub. Raise it behind a slow proxy.",
585 plain: "How long the version check waits before giving up. Raise it if you are behind a slow proxy.",
586 get: |s| s.update_check_timeout_secs.to_string(),
587 set: |s, v| {
588 let secs: u64 = v.parse().map_err(|_| {
589 anyhow::anyhow!("update_check_timeout_secs must be a positive integer")
590 })?;
591 if secs == 0 {
592 bail!("update_check_timeout_secs must be at least 1.");
593 }
594 s.update_check_timeout_secs = secs;
595 Ok(())
596 },
597 },
598 Setting {
599 key: "enable_cargo",
600 category: Category::BuildTrees,
601 since: "1.5.0",
602 kind: Kind::Toggle,
603 help: "Turn on the opt-in Cargo adapter (target/ comes back by recompiling, not downloading).",
604 plain: "Clean Rust build folders too. These come back by recompiling, which takes minutes rather than a download — so it is off by default.",
605 get: |s| s.enable_cargo.to_string(),
606 set: |s, v| {
607 s.enable_cargo = parse_bool("enable_cargo", v)?;
608 Ok(())
609 },
610 },
611 Setting {
612 key: "enable_gradle",
613 category: Category::BuildTrees,
614 since: "1.3.0",
615 kind: Kind::Toggle,
616 help: "Turn on the opt-in Gradle adapter (build/ and .gradle/ come back by recompiling).",
617 plain: "Clean Android and Java build folders too. Same trade: they come back by recompiling, not downloading.",
618 get: |s| s.enable_gradle.to_string(),
619 set: |s, v| {
620 s.enable_gradle = parse_bool("enable_gradle", v)?;
621 Ok(())
622 },
623 },
624 Setting {
625 key: "enable_maven",
626 category: Category::BuildTrees,
627 since: "1.3.0",
628 kind: Kind::Toggle,
629 help: "Turn on the opt-in Maven adapter (target/ comes back by recompiling).",
630 plain: "Clean Maven build folders too. They come back by recompiling.",
631 get: |s| s.enable_maven.to_string(),
632 set: |s, v| {
633 s.enable_maven = parse_bool("enable_maven", v)?;
634 Ok(())
635 },
636 },
637 Setting {
638 key: "enable_swift",
639 category: Category::BuildTrees,
640 since: "1.4.0",
641 kind: Kind::Toggle,
642 help: "Turn on the opt-in SwiftPM adapter (.build/ comes back by recompiling).",
643 plain: "Clean Swift build folders too. They come back by recompiling.",
644 get: |s| s.enable_swift.to_string(),
645 set: |s, v| {
646 s.enable_swift = parse_bool("enable_swift", v)?;
647 Ok(())
648 },
649 },
650 Setting {
651 key: "enable_dart",
652 category: Category::BuildTrees,
653 since: "1.6.0",
654 kind: Kind::Toggle,
655 help: "Turn on the opt-in Dart/Flutter adapter (.dart_tool/ holds build caches).",
656 plain: "Clean Dart and Flutter caches too. Part comes back instantly, part by recompiling.",
657 get: |s| s.enable_dart.to_string(),
658 set: |s, v| {
659 s.enable_dart = parse_bool("enable_dart", v)?;
660 Ok(())
661 },
662 },
663 Setting {
664 key: "enable_mix_build",
665 category: Category::BuildTrees,
666 since: "1.7.0",
667 kind: Kind::Toggle,
668 help: "Turn on the opt-in Elixir Mix build-tree adapter (_build/ comes back by recompiling).",
669 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.",
670 get: |s| s.enable_mix_build.to_string(),
671 set: |s, v| {
672 s.enable_mix_build = parse_bool("enable_mix_build", v)?;
673 Ok(())
674 },
675 },
676 Setting {
677 key: "enable_vcpkg",
678 category: Category::BuildTrees,
679 since: "1.8.0",
680 kind: Kind::Toggle,
681 help: "Turn on the opt-in vcpkg adapter (vcpkg_installed/ comes back by recompiling).",
682 plain: "Clean C and C++ vcpkg_installed/ folders too. They come back by recompiling.",
683 get: |s| s.enable_vcpkg.to_string(),
684 set: |s, v| {
685 s.enable_vcpkg = parse_bool("enable_vcpkg", v)?;
686 Ok(())
687 },
688 },
689 Setting {
690 key: "enable_cmake_build",
691 category: Category::BuildTrees,
692 since: "1.8.0",
693 kind: Kind::Toggle,
694 help: "Turn on the opt-in CMake adapter (build trees proven by their CMakeCache.txt).",
695 plain: "Clean C and C++ build folders CMake configured. A `build/` you made by hand is \
696 never touched.",
697 get: |s| s.enable_cmake_build.to_string(),
698 set: |s, v| {
699 s.enable_cmake_build = parse_bool("enable_cmake_build", v)?;
700 Ok(())
701 },
702 },
703 Setting {
704 key: "build_idle_days",
705 category: Category::BuildTrees,
706 since: "1.3.0",
707 kind: Kind::Number,
708 help: "Idle days before the opt-in adapters' build trees are pruned. Applied as max(this, idle_days).",
709 plain: "A longer wait, used only for the build folders above, because getting those back costs a recompile rather than a download.",
710 get: |s| s.build_idle_days.to_string(),
711 set: |s, v| {
712 let days: u64 = v
713 .parse()
714 .map_err(|_| anyhow::anyhow!("build_idle_days must be a non-negative integer"))?;
715 s.build_idle_days = days;
716 Ok(())
717 },
718 },
719 Setting {
720 key: "auto_update",
721 category: Category::Updates,
722 since: "1.3.0",
723 kind: Kind::Toggle,
724 help: "Install a newer release by itself at the end of a prune pass. On by default.",
725 plain: "Whether dev-prune installs its own updates after a cleanup. The download is checked against its published fingerprint first.",
726 get: |s| s.auto_update.to_string(),
727 set: |s, v| {
728 s.auto_update = parse_bool("auto_update", v)?;
729 Ok(())
730 },
731 },
732 Setting {
733 key: "version_lock",
734 category: Category::Updates,
735 since: "1.8.0",
736 kind: Kind::Toggle,
737 help: "Pin this copy to the version it is. Overrides auto_update, `devp update \
738 --install`, `devp install --channel` and the install scripts.",
739 plain: "Stay on exactly this version. Nothing dev-prune does replaces the binary \
740 while this is on -- not the automatic update, not a re-run of the install \
741 one-liner.",
742 get: |s| s.version_lock.to_string(),
743 set: |s, v| {
744 s.version_lock = parse_bool("version_lock", v)?;
745 Ok(())
746 },
747 },
748 Setting {
749 key: "disabled_adapters",
750 category: Category::Scope,
751 since: "1.4.0",
752 kind: Kind::Adapters,
753 help: "Adapters to leave alone entirely, by name. Empty means every one of them is active.",
754 plain: "Ecosystems to ignore completely — as if you did not have them installed at all.",
755 get: |s| {
756 if s.disabled_adapters.is_empty() {
757 "(none)".to_string()
758 } else {
759 s.disabled_adapters.join(",")
760 }
761 },
762 set: |s, v| {
763 s.disabled_adapters = parse_adapter_list(v)?;
764 Ok(())
765 },
766 },
767 Setting {
768 key: "adapter_idle_days",
769 category: Category::Scope,
770 since: "1.5.0",
771 kind: Kind::AdapterDays,
772 help: "Per-adapter idle windows, as `cargo=60,npm=30`. Each one can only raise its own wait.",
773 plain: "A different waiting period for one ecosystem. Useful when your Rust projects should wait longer than your Node ones.",
774 get: |s| {
775 if s.adapter_idle_days.is_empty() {
776 "(none)".to_string()
777 } else {
778 s.adapter_idle_days
779 .iter()
780 .map(|(name, days)| format!("{name}={days}"))
781 .collect::<Vec<_>>()
782 .join(",")
783 }
784 },
785 set: |s, v| {
786 s.adapter_idle_days = parse_adapter_days(v)?;
787 Ok(())
788 },
789 },
790 Setting {
791 key: "cache_max_gb",
792 category: Category::Caches,
793 since: "1.8.0",
794 kind: Kind::CacheCaps,
795 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`.",
796 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.",
797 get: |s| {
798 if s.cache_max_gb.is_empty() {
799 "(none)".to_string()
800 } else {
801 s.cache_max_gb
802 .iter()
803 .map(|(name, gb)| format!("{name}={gb}"))
804 .collect::<Vec<_>>()
805 .join(",")
806 }
807 },
808 set: |s, v| {
809 s.cache_max_gb = parse_cache_caps(v)?;
810 Ok(())
811 },
812 },
813];
814
815fn parse_cache_caps(value: &str) -> Result<std::collections::BTreeMap<String, u64>> {
836 let trimmed = value.trim();
837 if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
838 return Ok(std::collections::BTreeMap::new());
839 }
840
841 let mut caps = std::collections::BTreeMap::new();
842 for raw in trimmed.split(',') {
843 let entry = raw.trim();
844 if entry.is_empty() {
845 continue;
846 }
847 let Some((name, value)) = entry.split_once('=') else {
848 bail!("`{entry}` must be written as `<manager>=<gib>`, for example `uv=10`.");
849 };
850 let name = name.trim().to_lowercase();
851 if name != crate::constants::CACHE_CAP_DEFAULT_KEY
852 && !crate::commands::caches::is_cache_manager(&name)
853 {
854 bail!(
855 "`{name}` is not a manager dev-prune knows a cache for. Valid names: {}. \
856 `{}` caps every manager that is not named separately.",
857 crate::commands::caches::known_managers().join(", "),
858 crate::constants::CACHE_CAP_DEFAULT_KEY
859 );
860 }
861 let parsed: u64 = value.trim().parse().map_err(|_| {
862 anyhow::anyhow!(
863 "`{name}` needs a whole number of gibibytes, not `{}`.",
864 value.trim()
865 )
866 })?;
867 if parsed == 0 {
868 bail!(
869 "`{name}=0` would call the cache too big the moment it exists. Use `-` to clear the caps instead."
870 );
871 }
872 caps.insert(name, parsed);
873 }
874 Ok(caps)
875}
876
877fn parse_adapter_days(value: &str) -> Result<std::collections::BTreeMap<String, u64>> {
882 let trimmed = value.trim();
883 if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
884 return Ok(std::collections::BTreeMap::new());
885 }
886
887 let mut days = std::collections::BTreeMap::new();
888 for raw in trimmed.split(',') {
889 let entry = raw.trim();
890 if entry.is_empty() {
891 continue;
892 }
893 let Some((name, value)) = entry.split_once('=') else {
894 bail!("`{entry}` must be written as `<adapter>=<days>`, for example `cargo=60`.");
895 };
896 let name = name.trim().to_lowercase();
897 if !crate::adapters::is_adapter_name(&name) {
898 bail!(
899 "`{name}` is not an adapter. Valid names: {}",
900 crate::adapters::all_adapter_names().join(", ")
901 );
902 }
903 let parsed: u64 = value.trim().parse().map_err(|_| {
904 anyhow::anyhow!(
905 "`{name}` needs a whole number of days, not `{}`.",
906 value.trim()
907 )
908 })?;
909 days.insert(name, parsed);
910 }
911 Ok(days)
912}
913
914fn parse_adapter_list(value: &str) -> Result<Vec<String>> {
915 let trimmed = value.trim();
916 if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
919 return Ok(Vec::new());
920 }
921
922 let mut names: Vec<String> = Vec::new();
923 for raw in trimmed.split(',') {
924 let name = raw.trim().to_lowercase();
925 if name.is_empty() {
926 continue;
927 }
928 if !crate::adapters::is_adapter_name(&name) {
929 bail!(
930 "`{name}` is not an adapter. Valid names: {}",
931 crate::adapters::all_adapter_names().join(", ")
932 );
933 }
934 if !names.contains(&name) {
935 names.push(name);
936 }
937 }
938 Ok(names)
939}
940
941fn parse_bool(key: &str, value: &str) -> Result<bool> {
942 match value.trim().to_lowercase().as_str() {
943 "true" | "yes" | "y" | "on" | "1" => Ok(true),
944 "false" | "no" | "n" | "off" | "0" => Ok(false),
945 _ => bail!("{key} must be true or false"),
946 }
947}
948
949pub fn invalid_settings(settings: &Settings) -> Vec<(&'static str, String)> {
960 SETTINGS
961 .iter()
962 .filter_map(|setting| {
963 let mut probe = settings.clone();
964 (setting.set)(&mut probe, &(setting.get)(settings))
965 .err()
966 .map(|e| (setting.key, e.to_string()))
967 })
968 .collect()
969}
970
971pub fn setting_count() -> usize {
973 SETTINGS.len()
974}
975
976fn save_settings_edits<'a>(edits: impl Iterator<Item = (&'a str, &'a str)>) -> Result<()> {
985 let mut fresh = Registry::load()?;
986 for (key, value) in edits {
987 (find_setting(key)?.set)(&mut fresh.settings, value)?;
988 }
989 fresh.save()
990}
991
992fn find_setting(key: &str) -> Result<&'static Setting> {
993 SETTINGS
994 .iter()
995 .find(|s| s.key == key)
996 .ok_or_else(|| anyhow::anyhow!("Unknown config key: {key}. Valid keys: {}", valid_keys()))
997}
998
999fn valid_keys() -> String {
1000 SETTINGS
1001 .iter()
1002 .map(|s| s.key)
1003 .collect::<Vec<_>>()
1004 .join(", ")
1005}
1006
1007#[derive(Debug, PartialEq, Eq)]
1009pub enum Toggle {
1010 Enable,
1011 Disable,
1012 Status,
1013}
1014
1015pub fn parse_toggle(action: &str) -> Result<Toggle> {
1025 match action.to_lowercase().as_str() {
1026 "enable" | "install" | "on" => Ok(Toggle::Enable),
1027 "disable" | "uninstall" | "remove" | "off" => Ok(Toggle::Disable),
1028 "" | "status" | "show" => Ok(Toggle::Status),
1029 other => bail!(
1030 "Unknown action `{other}`. Expected `enable`, `disable` or `status` \
1031 (`install` / `uninstall` / `on` / `off` also work)."
1032 ),
1033 }
1034}
1035
1036pub fn is_toggle_word(word: &str) -> bool {
1042 parse_toggle(word).is_ok() && !word.is_empty()
1043}
1044
1045fn resolve_workspace(path: &str) -> Result<std::path::PathBuf> {
1052 let raw = Path::new(path);
1053 if !raw.is_dir() {
1054 bail!(
1055 "`{path}` is neither an action nor an existing directory.\n\
1056 Expected `enable`, `disable` or `status`, or a path to a repository."
1057 );
1058 }
1059 Ok(raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf()))
1060}
1061
1062pub fn run_get(key: &str) -> Result<()> {
1064 let registry = Registry::load()?;
1065 let setting = find_setting(key)?;
1066 println!("{key} = {}", (setting.get)(®istry.settings));
1067 Ok(())
1068}
1069
1070pub fn run_set(key: &str, value: &str) -> Result<()> {
1072 let mut registry = Registry::load()?;
1073 let setting = find_setting(key)?;
1074 (setting.set)(&mut registry.settings, value)?;
1075 registry.save()?;
1076
1077 output::print_success(&format!("{key} = {}", (setting.get)(®istry.settings)));
1080
1081 if key == "language"
1085 && let Some(meta) = i18n::language(®istry.settings.language)
1086 && !meta.reviewed
1087 {
1088 output::print_info(&format!(
1089 "No native speaker has reviewed the {} translation yet. Corrections are welcome — see docs/TRANSLATIONS.md.",
1090 meta.english_name
1091 ));
1092 }
1093 Ok(())
1094}
1095
1096fn key_column_width() -> usize {
1098 SETTINGS.iter().map(|s| s.key.len()).max().unwrap_or(0)
1099}
1100
1101pub fn run_show() -> Result<()> {
1103 let registry = Registry::load()?;
1104 let width = key_column_width();
1105
1106 output::print_header("dev-prune Global Configuration");
1107 for (category, settings) in settings_by_category() {
1108 output::print_section(category.title());
1109 for setting in settings {
1110 println!(
1111 " {:<width$} = {}",
1112 setting.key,
1113 (setting.get)(®istry.settings)
1114 );
1115 }
1116 }
1117
1118 output::print_section("This machine");
1121 println!(
1122 " {:<width$} = {}",
1123 "tracked_repos",
1124 registry.repo_count()
1125 );
1126 let reg_path = Registry::registry_path()
1127 .map(|p| output::clean_path(&p))
1128 .unwrap_or_else(|_| "unknown".to_string());
1129 println!(" {:<width$} = {reg_path}", "registry_file");
1130
1131 print_recommendation_summary(®istry.settings);
1135
1136 println!();
1137 output::print_info("Change any of these with `devp config set <key> <value>`.");
1138 output::print_info("Walk through them one at a time with `devp config wizard`.");
1139
1140 Ok(())
1141}
1142
1143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1145pub enum Opened {
1146 ByRequest,
1148 OnItsOwn,
1152}
1153
1154fn why_this_opened(opened: Opened) -> Option<String> {
1161 if opened == Opened::ByRequest {
1162 return None;
1163 }
1164 let new = settings_added_since_review().len();
1165 Some(if reviewed_version().is_none() || new == 0 {
1166 "You did not ask for this screen. dev-prune opens it once, on the first command \
1167 after it is installed, so that you see what its defaults do before they start \
1168 doing it. Whatever you typed runs as soon as you leave. It will not open by \
1169 itself again unless an upgrade adds a setting."
1170 .to_string()
1171 } else {
1172 format!(
1173 "You did not ask for this screen. This upgrade added {new} {}, and dev-prune \
1174 shows a new one once before its default goes on applying. Nothing else about \
1175 your configuration changed. Whatever you typed runs as soon as you leave.",
1176 output::plural(new, "setting", "settings"),
1177 )
1178 })
1179}
1180
1181pub fn run_wizard(no_tui: bool, opened: Opened) -> Result<()> {
1192 if !no_tui && full_screen_is_usable() {
1193 return run_wizard_tui(opened);
1194 }
1195 run_wizard_prompts(opened)
1196}
1197
1198fn full_screen_is_usable() -> bool {
1206 use std::io::IsTerminal;
1207 if std::env::var_os(crate::constants::ENV_NO_TUI).is_some() {
1208 return false;
1209 }
1210 std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
1211}
1212
1213fn run_wizard_tui(opened: Opened) -> Result<()> {
1215 use crate::tui::config_view::{ConfigRow, ConfigSession, Control, Outcome};
1216
1217 let registry = Registry::load()?;
1218 let new_keys = settings_added_since_review();
1219 let fresh = Settings::default();
1223
1224 let rows: Vec<ConfigRow> = settings_by_category()
1227 .into_iter()
1228 .flat_map(|(category, settings)| {
1229 settings.into_iter().map(move |setting| (category, setting))
1230 })
1231 .map(|(category, setting)| {
1232 let value = (setting.get)(®istry.settings);
1233 ConfigRow {
1234 key: setting.key,
1235 category: category.title(),
1236 help: setting.help,
1237 plain: setting.plain,
1238 control: match setting.kind {
1239 Kind::Toggle => Control::Toggle,
1240 Kind::Choice => Control::Choice(i18n::choices()),
1241 Kind::Number => Control::Number,
1242 Kind::Adapters => Control::Adapters,
1243 Kind::AdapterDays => Control::AdapterDays,
1244 Kind::CacheCaps => Control::CacheCaps,
1245 },
1246 original: value.clone(),
1247 default: (setting.get)(&fresh),
1248 recommended: recommended_value(setting.key),
1249 cautious: recommendation(setting.key).is_some_and(|r| r.cautious),
1250 value,
1251 is_new: new_keys.contains(&setting.key),
1252 }
1253 })
1254 .collect();
1255
1256 let base = registry.settings.clone();
1259 let validate = move |key: &str, value: &str| -> std::result::Result<(), String> {
1260 let setting = find_setting(key).map_err(|e| e.to_string())?;
1261 let mut probe = base.clone();
1262 (setting.set)(&mut probe, value).map_err(|e| format!("{e}"))
1263 };
1264
1265 let report = crate::commands::trust::build(®istry);
1266 let adapters = crate::adapters::all_adapter_names();
1267 let opt_in = crate::adapters::opt_in_adapter_names();
1268 let capped: Vec<&'static str> = adapters
1271 .iter()
1272 .copied()
1273 .filter(|name| crate::commands::caches::is_cache_manager(name))
1274 .collect();
1275
1276 let why = why_this_opened(opened);
1277 let outcome = crate::tui::config_view::run(ConfigSession {
1278 declaration: declaration_lines(&report),
1279 standing: NOTHING_DELETED_YET.to_string(),
1280 suggestions: first_run_suggestions(),
1281 rows,
1282 adapters: &adapters,
1283 opt_in_adapters: &opt_in,
1284 capped_adapters: &capped,
1285 groups: crate::adapters::ADAPTER_GROUPS,
1286 validate: &validate,
1287 title: "dev-prune configuration",
1288 uninvited: why.as_deref(),
1289 })?;
1290
1291 match outcome {
1292 Outcome::Cancelled => {
1296 output::print_info("Cancelled — nothing was changed.");
1297 Ok(())
1298 }
1299 Outcome::KeepAll => {
1300 mark_reviewed();
1301 output::print_success(
1302 "Keeping the current values. `devp config set <key> <value>` changes any.",
1303 );
1304 Ok(())
1305 }
1306 Outcome::Save(changed) => {
1307 save_settings_edits(changed.iter().map(|row| (row.key, row.value.as_str())))?;
1308 mark_reviewed();
1309
1310 output::print_header("Saved");
1314 let width = changed.iter().map(|r| r.key.len()).max().unwrap_or(0);
1315 for row in &changed {
1316 println!(
1317 " {:<width$} = {} (was {})",
1318 row.key, row.value, row.original
1319 );
1320 }
1321 println!();
1322 output::print_success(&format!(
1323 "{} {} saved. `devp config show` lists every setting.",
1324 changed.len(),
1325 output::plural(changed.len(), "change", "changes")
1326 ));
1327 Ok(())
1328 }
1329 }
1330}
1331
1332fn recommended_value(key: &str) -> Option<&'static str> {
1349 recommendation(key).map(|r| r.value)
1350}
1351
1352fn recommendation(key: &str) -> Option<&'static Recommendation> {
1354 RECOMMENDED.iter().find(|r| r.key == key)
1355}
1356
1357fn already_taken(rec: &Recommendation, current: &str) -> bool {
1363 match rec.taken {
1364 Some(is_taken) => is_taken(current),
1365 None => current == rec.value,
1366 }
1367}
1368
1369fn outstanding(settings: &Settings) -> Vec<&'static Recommendation> {
1371 RECOMMENDED
1372 .iter()
1373 .filter(|r| {
1374 find_setting(r.key)
1375 .map(|s| (s.get)(settings))
1376 .is_ok_and(|current| !already_taken(r, ¤t))
1377 })
1378 .collect()
1379}
1380
1381fn print_recommendation_summary(settings: &Settings) {
1388 let outstanding = outstanding(settings);
1389 if outstanding.is_empty() {
1390 return;
1391 }
1392 let width = key_column_width();
1393
1394 let safe: Vec<_> = outstanding.iter().filter(|r| !r.cautious).collect();
1395 if !safe.is_empty() {
1396 output::print_section(SAFE_TIER);
1397 for r in &safe {
1398 println!(" {:<width$} = {} {}", r.key, r.value, r.label);
1399 }
1400 println!();
1401 output::print_info(&format!(
1402 "`devp config recommended` sets {} {} in one command.",
1403 safe.len(),
1404 output::plural(safe.len(), "setting", "settings")
1405 ));
1406 }
1407
1408 let cautious: Vec<_> = outstanding.iter().filter(|r| r.cautious).collect();
1409 if !cautious.is_empty() {
1410 output::print_section(CAUTIOUS_TIER);
1411 for r in &cautious {
1412 println!(" {:<width$} = {} {}", r.key, r.value, r.label);
1413 println!(" {:<width$} {}", "", r.why);
1414 }
1415 println!();
1416 output::print_info(
1417 "Not included above. `devp config recommended --with-cautious` includes it; \
1418 `devp config set <key> <value>` sets one on its own.",
1419 );
1420 }
1421}
1422
1423pub fn run_recommended(with_cautious: bool) -> Result<()> {
1437 let mut registry = Registry::load()?;
1438 let width = key_column_width();
1439
1440 output::print_header("dev-prune recommended settings");
1441
1442 let mut applied: Vec<(&'static str, String, &'static str)> = Vec::new();
1443 let mut already: Vec<&'static Recommendation> = Vec::new();
1444 let mut held_back: Vec<&'static Recommendation> = Vec::new();
1445
1446 for rec in RECOMMENDED {
1447 let setting = find_setting(rec.key)?;
1448 let current = (setting.get)(®istry.settings);
1449 if already_taken(rec, ¤t) {
1450 already.push(rec);
1451 } else if rec.cautious && !with_cautious {
1452 held_back.push(rec);
1453 } else {
1454 (setting.set)(&mut registry.settings, rec.value)?;
1455 applied.push((rec.key, current, rec.value));
1456 }
1457 }
1458
1459 if !applied.is_empty() {
1460 registry.save()?;
1461 output::print_section("Turned on");
1462 for (key, from, to) in &applied {
1463 println!(" {:<width$} {from} → {to}", key);
1464 }
1465 }
1466 if !already.is_empty() {
1467 output::print_section("Already set");
1468 for rec in &already {
1469 println!(" {:<width$} {}", rec.key, rec.label);
1470 }
1471 }
1472 if !held_back.is_empty() {
1473 output::print_section(CAUTIOUS_TIER);
1474 for rec in &held_back {
1475 println!(" {:<width$} = {} {}", rec.key, rec.value, rec.label);
1476 println!(" {:<width$} {}", "", rec.why);
1477 }
1478 println!();
1479 output::print_info(
1480 "Left alone. `devp config recommended --with-cautious` includes it; \
1481 `devp config set <key> <value>` sets one on its own.",
1482 );
1483 }
1484
1485 println!();
1486 if applied.is_empty() {
1487 output::print_success(
1488 "Nothing changed — everything recommended without a caveat is already set.",
1489 );
1490 } else {
1491 output::print_success(&format!(
1492 "{} {} changed. `devp config show` lists them all.",
1493 applied.len(),
1494 output::plural(applied.len(), "setting", "settings")
1495 ));
1496 }
1497 Ok(())
1498}
1499
1500fn first_run_suggestions() -> Vec<crate::tui::config_view::Suggestion> {
1501 use crate::tui::config_view::Suggestion;
1502
1503 if reviewed_version().is_some() {
1504 return Vec::new();
1505 }
1506 RECOMMENDED
1507 .iter()
1508 .filter_map(|r| {
1509 let setting = find_setting(r.key).ok()?;
1510 Some(Suggestion {
1511 key: r.key,
1512 label: r.label,
1513 help: setting.help,
1514 plain: setting.plain,
1515 why: r.why,
1516 value: r.value,
1517 cautious: r.cautious,
1518 })
1519 })
1520 .collect()
1521}
1522
1523const NOTHING_DELETED_YET: &str =
1525 "Nothing has been deleted, and nothing will be until a lockfile proves it comes back.";
1526
1527fn provenance_rows() -> Vec<(&'static str, String)> {
1544 let url = |u: &str| u.trim_start_matches("https://").to_string();
1547 vec![
1548 (
1549 "What you are running",
1550 format!(
1551 "{} v{}",
1552 crate::constants::APP_NAME,
1553 crate::constants::VERSION
1554 ),
1555 ),
1556 (
1557 "Written by",
1558 format!("{}, under Apache-2.0", crate::constants::AUTHOR),
1559 ),
1560 ("Source code", url(crate::constants::REPO_URL)),
1561 (
1562 "Official downloads",
1563 format!("{} · GitHub releases", url(crate::constants::HOMEPAGE_URL)),
1564 ),
1565 (
1566 "Package registries",
1567 "crates.io · PyPI · npm, all named dev-prune".to_string(),
1568 ),
1569 (
1570 "Editor extension",
1571 "VS Code Marketplace · Open VSX".to_string(),
1572 ),
1573 (
1574 "Any other source",
1575 "is not a copy the author published".to_string(),
1576 ),
1577 ]
1578}
1579
1580fn declaration_lines(
1586 report: &crate::commands::trust::TrustReport,
1587) -> Vec<crate::tui::config_view::DeclarationLine> {
1588 use crate::commands::trust::{TrustRow, Verdict};
1589 use crate::tui::config_view::DeclarationLine;
1590
1591 let heading = |text: &str| DeclarationLine {
1592 mark: '#',
1593 subject: text.to_string(),
1594 state: String::new(),
1595 };
1596 let row = |r: &TrustRow| DeclarationLine {
1597 mark: match r.verdict {
1598 Verdict::Guaranteed | Verdict::Safe => '+',
1599 Verdict::Widened => '!',
1600 Verdict::Neutral => ' ',
1601 },
1602 subject: r.subject.to_string(),
1603 state: r.state.clone(),
1604 };
1605
1606 let mut lines = vec![heading("What this is, and where it came from")];
1607 lines.extend(
1608 provenance_rows()
1609 .into_iter()
1610 .map(|(subject, state)| DeclarationLine {
1611 mark: ' ',
1612 subject: subject.to_string(),
1613 state,
1614 }),
1615 );
1616 lines.push(heading(""));
1617 lines.push(heading("Guaranteed by the code"));
1618 lines.extend(report.guarantees.iter().map(&row));
1619 lines.push(heading(""));
1620 lines.push(heading("On this machine"));
1621 lines.extend(report.machine.iter().map(&row));
1622 lines
1623}
1624
1625fn run_wizard_prompts(opened: Opened) -> Result<()> {
1629 use std::io::{self, IsTerminal, Write};
1630
1631 if !io::stdin().is_terminal() {
1632 bail!(
1633 "`devp config wizard` needs a terminal to ask questions on.\n\
1634 Use `devp config show` to read the settings and `devp config set <key> <value>` \
1635 to change one."
1636 );
1637 }
1638
1639 let mut registry = Registry::load()?;
1640 let width = key_column_width();
1641 let new_keys = settings_added_since_review();
1642 let fresh = Settings::default();
1643
1644 output::print_header("dev-prune configuration");
1645 if let Some(why) = why_this_opened(opened) {
1648 output::print_warning(&why);
1649 println!();
1650 }
1651 output::print_section("What this is, and where it came from");
1652 for (subject, state) in provenance_rows() {
1653 println!(" {} {state}", output::pad_display(subject, 22));
1654 }
1655 println!(" {}", crate::constants::LICENCE_NOTICE);
1656 println!();
1657
1658 output::print_info("These are the defaults every run will use. Nothing has been changed yet.");
1659 println!();
1660 for (category, settings) in settings_by_category() {
1661 output::print_section(category.title());
1662 for setting in settings {
1663 let badge = if new_keys.contains(&setting.key) {
1666 " (new in this version)"
1667 } else {
1668 ""
1669 };
1670 println!(
1671 " {:<width$} = {}{badge}",
1672 setting.key,
1673 (setting.get)(®istry.settings)
1674 );
1675 println!(" {:<width$} {}", "", setting.help);
1676 println!(" {:<width$} {}", "", setting.plain);
1679 let mut facts = format!("default {}", (setting.get)(&fresh));
1683 if let Some(rec) = recommendation(setting.key) {
1687 facts.push_str(&format!(
1688 " · recommended {} ({}, not required)",
1689 rec.value,
1690 if rec.cautious {
1691 "read the note below first"
1692 } else {
1693 "suggested"
1694 }
1695 ));
1696 }
1697 println!(" {:<width$} {facts}", "");
1698 if let Some(rec) = recommendation(setting.key).filter(|r| r.cautious) {
1699 println!(" {:<width$} {}", "", rec.why);
1700 }
1701 }
1702 }
1703 println!();
1704
1705 print_recommendation_summary(®istry.settings);
1706 println!();
1707
1708 if confirmed_twice("Press Enter twice to keep all of these, or type anything to change them: ")?
1713 {
1714 mark_reviewed();
1715 output::print_success("Keeping the defaults. `devp config set <key> <value>` changes any.");
1716 return Ok(());
1717 }
1718
1719 println!();
1720 output::print_info("Enter a new value, or press Enter to keep the one shown.");
1721 println!();
1722
1723 let mut edits: Vec<(&'static str, String, String)> = Vec::new();
1724 let mut input_ended = false;
1725 for setting in SETTINGS {
1726 if input_ended {
1727 break;
1728 }
1729 let current = (setting.get)(®istry.settings);
1730 loop {
1731 print!(" {} [{current}]: ", setting.key);
1732 io::stdout().flush()?;
1733 let mut line = String::new();
1734 if io::stdin().read_line(&mut line)? == 0 {
1737 println!();
1738 input_ended = true;
1739 break;
1740 }
1741 let typed = line.trim();
1742 if typed.is_empty() {
1743 break;
1744 }
1745 match (setting.set)(&mut registry.settings, typed) {
1746 Ok(()) => {
1747 let now = (setting.get)(®istry.settings);
1751 if now != current {
1752 edits.push((setting.key, current.clone(), now));
1753 }
1754 break;
1755 }
1756 Err(e) => output::print_error(&format!("{e}")),
1759 }
1760 }
1761 }
1762
1763 println!();
1764 if edits.is_empty() {
1765 if input_ended {
1769 output::print_info("Input ended — nothing was changed.");
1770 return Ok(());
1771 }
1772 mark_reviewed();
1773 output::print_success("Nothing changed — the defaults are in place.");
1774 return Ok(());
1775 }
1776
1777 output::print_section("About to be saved");
1780 for (key, from, to) in &edits {
1781 println!(" {:<width$} {from} → {to}", key);
1782 }
1783 println!();
1784 if !confirmed_twice("Press Enter twice to save, or type anything to abandon: ")? {
1785 output::print_info("Nothing was written.");
1786 return Ok(());
1787 }
1788
1789 save_settings_edits(edits.iter().map(|(key, _, to)| (*key, to.as_str())))?;
1790 mark_reviewed();
1791 let changed = edits.len();
1792 println!();
1793 output::print_success(&format!(
1794 "Saved {changed} {}. `devp config show` lists them all.",
1795 output::plural(changed, "change", "changes")
1796 ));
1797 Ok(())
1798}
1799
1800fn confirmed_twice(prompt: &str) -> Result<bool> {
1809 use std::io::{self, Write};
1810
1811 for pass in 0..2 {
1812 print!(
1813 "{}",
1814 if pass == 0 {
1815 prompt
1816 } else {
1817 "Press Enter once more to confirm: "
1818 }
1819 );
1820 io::stdout().flush()?;
1821 let mut line = String::new();
1822 if io::stdin().read_line(&mut line)? == 0 {
1823 println!();
1824 return Ok(false);
1825 }
1826 if !line.trim().is_empty() {
1827 return Ok(false);
1828 }
1829 }
1830 Ok(true)
1831}
1832
1833const REVIEW_MARKER: &str = "config-reviewed";
1835
1836pub fn config_review_is_due() -> bool {
1845 let Ok(dir) = Registry::config_dir() else {
1846 return false;
1847 };
1848 if !dir.join(REVIEW_MARKER).exists() {
1849 return true;
1850 }
1851 !settings_added_since_review().is_empty()
1852}
1853
1854fn reviewed_version() -> Option<String> {
1856 let dir = Registry::config_dir().ok()?;
1857 let recorded = std::fs::read_to_string(dir.join(REVIEW_MARKER)).ok()?;
1858 let recorded = recorded.trim().to_string();
1859 (!recorded.is_empty()).then_some(recorded)
1860}
1861
1862pub fn settings_added_since_review() -> Vec<&'static str> {
1871 let Some(reviewed) = reviewed_version() else {
1872 return Vec::new();
1873 };
1874 SETTINGS
1875 .iter()
1876 .filter(|s| {
1877 crate::commands::update::compare_versions(s.since, &reviewed)
1878 == Some(std::cmp::Ordering::Greater)
1879 })
1880 .map(|s| s.key)
1881 .collect()
1882}
1883
1884fn mark_reviewed() {
1885 if let Ok(dir) = Registry::config_dir() {
1886 let _ = std::fs::create_dir_all(&dir);
1887 let _ = std::fs::write(dir.join(REVIEW_MARKER), crate::constants::VERSION);
1888 }
1889}
1890
1891pub fn skip_config_review() {
1896 mark_reviewed();
1897}
1898
1899pub fn run_global_update() -> Result<()> {
1901 output::print_header("dev-prune Global Configuration Audit & Sync");
1902
1903 let registry = Registry::load()?;
1904 let mut total_audited = 0;
1905 let mut errors_found = 0;
1906
1907 for repo_path in registry.repositories.keys() {
1908 let clean = output::clean_path(repo_path);
1909
1910 if !repo_path.exists() {
1914 output::print_warning(&format!(
1915 "Skipped {clean} — the path no longer exists. `devp unlink --missing` \
1916 clears such entries."
1917 ));
1918 continue;
1919 }
1920 total_audited += 1;
1921
1922 match PerRepoConfig::load_personal_for_write(repo_path) {
1923 Ok(Some(cfg)) => {
1924 if let Err(e) = cfg.save_to_repo(repo_path) {
1925 output::print_error(&format!("Failed to write config for {clean}: {e}"));
1926 errors_found += 1;
1927 } else {
1928 output::print_success(&format!("Audited & synced config for {clean}"));
1929 }
1930 }
1931 Ok(None) => {
1932 output::print_info(&format!(
1936 "{clean} has no .devprune.json — global defaults apply."
1937 ));
1938 }
1939 Err(err_msg) => {
1940 errors_found += 1;
1941 output::print_error(&format!("Syntax/Schema Error in {clean}:"));
1942 for line in err_msg.lines() {
1943 eprintln!(" {line}");
1944 }
1945 output::print_info(&format!(
1946 "Hint: fix the syntax by hand, or run `devp config {clean} --update` to \
1947 replace the file with a valid default."
1948 ));
1949 }
1950 }
1951 }
1952
1953 if errors_found > 0 {
1954 anyhow::bail!(
1957 "Audit complete: {total_audited} repos checked, {errors_found} could not be read \
1958 or written."
1959 );
1960 }
1961 output::print_success(&format!(
1962 "Audit complete: All {total_audited} registered repositories are healthy & synced!"
1963 ));
1964
1965 Ok(())
1966}
1967
1968pub fn run_path_config(path_str: &str, force_update: bool, team: bool) -> Result<()> {
1973 let raw_path = Path::new(path_str);
1974
1975 let path = if raw_path.exists() {
1976 raw_path
1977 .canonicalize()
1978 .unwrap_or_else(|_| raw_path.to_path_buf())
1979 } else {
1980 raw_path.to_path_buf()
1981 };
1982
1983 let clean = output::clean_path(&path);
1984
1985 if !path.exists() {
1986 bail!("Path does not exist: {clean}");
1987 }
1988
1989 if !crate::scanner::is_git_repo(&path) {
1990 bail!(
1992 "`{clean}` is not a Git repository.\n \
1993 Run `git init` there first, then `devp config {clean}` again."
1994 );
1995 }
1996
1997 let mut registry = Registry::load()?;
1998 if !registry.repositories.contains_key(&path) {
1999 output::print_info(&format!(
2000 "{clean} is not yet registered with dev-prune. Registering now..."
2001 ));
2002 registry.add_repo(path.clone());
2003 registry.save()?;
2004 }
2005
2006 let name = if team {
2007 crate::constants::PROJECT_REPO_CONFIG_FILE
2008 } else {
2009 crate::constants::PER_REPO_CONFIG_FILE
2010 };
2011 let cfg_file = path.join(name);
2012
2013 if cfg_file.exists() && !force_update {
2014 output::print_header(&format!("dev-prune Per-Repo Config for {clean}"));
2015 match crate::config::RepoConfigLayers::load(&path) {
2016 Ok(layers) => {
2017 let addressed = if team {
2018 layers.project_config()
2019 } else {
2020 layers.personal_config()
2021 };
2022 println!("{}", serde_json::to_string_pretty(&addressed)?);
2023 output::print_info(&format!("File location: {name}"));
2024 print_layer_provenance(&layers);
2025 }
2026 Err(err_msg) => {
2027 output::print_error(&format!("Invalid configuration in {clean}:"));
2028 for line in err_msg.lines() {
2029 eprintln!(" {line}");
2030 }
2031 anyhow::bail!(
2034 "Run `devp config {clean} --update` to reset this file back to defaults \
2035 (your current overrides in it are discarded)."
2036 );
2037 }
2038 }
2039 } else {
2040 output::print_info(&format!("Initializing {name} for {clean}..."));
2041 if team {
2042 crate::config::write_project_starter(&path)?;
2043 } else {
2044 PerRepoConfig::default().save_to_repo(&path)?;
2045 }
2046 output::print_success(&format!("Created {name} in {clean}"));
2047 if team {
2048 output::print_info(
2049 "It starts empty on purpose: every key it names overrules \
2050 `.devprune.json`, so it should only name the ones your team decides.",
2051 );
2052 output::print_info(
2053 "`prunable.directories` is the exception — the two files' lists add \
2054 up, so naming one here never discards somebody's own.",
2055 );
2056 output::print_info(
2057 "Commit it. Unlike `.devprune.json`, this file is not added to \
2058 `.git/info/exclude` — being shared is the whole reason it exists.",
2059 );
2060 }
2061 }
2062
2063 Ok(())
2064}
2065
2066fn print_layer_provenance(layers: &crate::config::RepoConfigLayers) {
2074 if layers.project_config().is_none() || layers.personal_config().is_none() {
2075 return;
2076 }
2077 output::print_section("Effective values");
2078 for (key, value, source) in layers.rows() {
2079 println!(
2080 " {} {} {}",
2081 output::pad_display(key, 20),
2082 output::pad_display(&value, 14),
2083 source.label()
2084 );
2085 }
2086}
2087
2088fn load_workspace_config_for_write(repo_path: &Path) -> Result<PerRepoConfig> {
2094 match PerRepoConfig::load_personal_for_write(repo_path) {
2095 Ok(Some(cfg)) => Ok(cfg),
2096 Ok(None) => Ok(PerRepoConfig::default()),
2097 Err(e) => bail!(
2098 "{e}\n \
2099 Fix that file, or run `devp config {} --update` to reset it back to defaults \
2100 (your current overrides in it are discarded).",
2101 output::clean_path(repo_path)
2102 ),
2103 }
2104}
2105
2106pub fn run_daemon_toggle(path: Option<&str>, action: &str) -> Result<()> {
2108 if let Some(p) = path {
2109 let repo_path = resolve_workspace(p)?;
2110 let mut cfg = load_workspace_config_for_write(&repo_path)?;
2111 match parse_toggle(action)? {
2112 Toggle::Enable => {
2113 cfg.disable_daemon = false;
2114 cfg.save_to_repo(&repo_path)?;
2115 output::print_success(&format!(
2116 "Enabled background daemon for workspace: {}",
2117 output::clean_path(&repo_path)
2118 ));
2119 }
2120 Toggle::Disable => {
2121 cfg.disable_daemon = true;
2122 cfg.save_to_repo(&repo_path)?;
2123 output::print_success(&format!(
2124 "Disabled background daemon for workspace: {}",
2125 output::clean_path(&repo_path)
2126 ));
2127 }
2128 Toggle::Status => {
2129 let st = if cfg.disable_daemon {
2130 "Disabled for workspace"
2131 } else {
2132 "Enabled for workspace"
2133 };
2134 output::print_info(&format!(
2135 "Daemon Status ({}): {}",
2136 output::clean_path(&repo_path),
2137 st
2138 ));
2139 }
2140 }
2141 } else {
2142 match parse_toggle(action)? {
2143 Toggle::Enable => crate::commands::daemon::run_install()?,
2144 Toggle::Disable => crate::commands::daemon::run_uninstall()?,
2145 Toggle::Status => crate::commands::daemon::run_status()?,
2146 }
2147 }
2148 Ok(())
2149}
2150
2151pub fn run_hook_toggle(path: Option<&str>, action: &str, chain: bool) -> Result<()> {
2153 if let Some(p) = path {
2154 if chain {
2155 bail!(
2156 "`--chain` changes the single global `core.hooksPath`, so it has no \
2157 per-workspace form. Drop the path: `devp hook install --chain`."
2158 );
2159 }
2160 let repo_path = resolve_workspace(p)?;
2161 let mut cfg = load_workspace_config_for_write(&repo_path)?;
2162 match parse_toggle(action)? {
2163 Toggle::Enable => {
2164 cfg.disable_hooks = false;
2165 cfg.save_to_repo(&repo_path)?;
2166 output::print_success(&format!(
2167 "Enabled background Git hooks for workspace: {}",
2168 output::clean_path(&repo_path)
2169 ));
2170 }
2171 Toggle::Disable => {
2172 cfg.disable_hooks = true;
2173 cfg.save_to_repo(&repo_path)?;
2174 output::print_success(&format!(
2175 "Disabled background Git hooks for workspace: {}",
2176 output::clean_path(&repo_path)
2177 ));
2178 }
2179 Toggle::Status => {
2180 let st = if cfg.disable_hooks {
2181 "Disabled for workspace"
2182 } else {
2183 "Enabled for workspace"
2184 };
2185 output::print_info(&format!(
2186 "Git Hook Status ({}): {}",
2187 output::clean_path(&repo_path),
2188 st
2189 ));
2190 }
2191 }
2192 } else {
2193 match parse_toggle(action)? {
2194 Toggle::Enable => crate::commands::hook::run_install(chain)?,
2195 Toggle::Disable => crate::commands::hook::run_uninstall()?,
2196 Toggle::Status => crate::commands::hook::run_status()?,
2197 }
2198 }
2199 Ok(())
2200}
2201
2202#[cfg(test)]
2203mod tests {
2204 use super::*;
2205
2206 #[test]
2207 fn enable_synonyms_all_resolve_to_enable() {
2208 for word in ["enable", "install", "on", "INSTALL", "On"] {
2209 assert_eq!(parse_toggle(word).unwrap(), Toggle::Enable, "{word}");
2210 }
2211 }
2212
2213 #[test]
2214 fn disable_synonyms_all_resolve_to_disable() {
2215 for word in ["disable", "uninstall", "remove", "off", "Uninstall"] {
2216 assert_eq!(parse_toggle(word).unwrap(), Toggle::Disable, "{word}");
2217 }
2218 }
2219
2220 #[test]
2221 fn status_is_the_default_and_is_also_spellable() {
2222 for word in ["", "status", "show"] {
2223 assert_eq!(parse_toggle(word).unwrap(), Toggle::Status, "{word}");
2224 }
2225 }
2226
2227 #[test]
2228 fn a_typo_is_an_error_rather_than_a_silent_status_report() {
2229 let err = parse_toggle("enabel").unwrap_err().to_string();
2232 assert!(err.contains("enabel"), "{err}");
2233 assert!(err.contains("enable"), "{err}");
2234 }
2235
2236 #[test]
2237 fn a_workspace_toggle_refuses_to_write_over_a_broken_config() {
2238 let tmp = tempfile::TempDir::new().unwrap();
2241 let broken = r#"{ "project_name": "api", "override_idle_days": 90, }"#;
2242 std::fs::write(
2243 tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE),
2244 broken,
2245 )
2246 .unwrap();
2247
2248 let err = load_workspace_config_for_write(tmp.path())
2249 .unwrap_err()
2250 .to_string();
2251 assert!(err.contains("Syntax error"), "{err}");
2252 assert!(err.contains("--update"), "{err}");
2253
2254 let on_disk =
2256 std::fs::read_to_string(tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE))
2257 .unwrap();
2258 assert_eq!(on_disk, broken);
2259 }
2260
2261 #[test]
2262 fn the_suggested_cap_is_the_constant_it_claims_to_be() {
2263 let caps = parse_cache_caps(crate::constants::RECOMMENDED_CACHE_CAP).unwrap();
2268 assert_eq!(
2269 caps,
2270 std::collections::BTreeMap::from([(
2271 crate::constants::CACHE_CAP_DEFAULT_KEY.to_string(),
2272 crate::constants::RECOMMENDED_CACHE_MAX_GB,
2273 )])
2274 );
2275 }
2276
2277 #[test]
2278 fn a_ceiling_of_your_own_counts_as_having_taken_the_advice() {
2279 let rec = recommendation("cache_max_gb").expect("the cap is recommended");
2284 assert!(already_taken(rec, "npm=4"));
2285 assert!(already_taken(rec, crate::constants::RECOMMENDED_CACHE_CAP));
2286 assert!(!already_taken(rec, "(none)"));
2287
2288 let toggle = recommendation("enable_cargo").expect("cargo is recommended");
2290 assert!(already_taken(toggle, "true"));
2291 assert!(!already_taken(toggle, "false"));
2292 }
2293
2294 #[test]
2295 fn a_workspace_with_no_config_yet_starts_from_the_defaults() {
2296 let tmp = tempfile::TempDir::new().unwrap();
2297 assert_eq!(
2298 load_workspace_config_for_write(tmp.path()).unwrap(),
2299 PerRepoConfig::default()
2300 );
2301 }
2302
2303 #[test]
2304 fn a_cache_cap_is_written_the_way_it_is_read_back() {
2305 let caps = parse_cache_caps("uv=10,npm=4").unwrap();
2306 assert_eq!(caps.get("uv"), Some(&10));
2307 assert_eq!(caps.get("npm"), Some(&4));
2308 let settings = Settings {
2311 cache_max_gb: parse_cache_caps("UV = 10 , npm=4").unwrap(),
2312 ..Settings::default()
2313 };
2314 let printed = SETTINGS
2315 .iter()
2316 .find(|s| s.key == "cache_max_gb")
2317 .map(|s| (s.get)(&settings))
2318 .unwrap();
2319 assert_eq!(printed, "npm=4,uv=10");
2320 assert_eq!(parse_cache_caps(&printed).unwrap(), settings.cache_max_gb);
2321 }
2322
2323 #[test]
2324 fn clearing_the_caps_is_spelled_the_way_the_getter_prints_an_empty_map() {
2325 for blank in ["", "-", "none", "(none)", "NONE"] {
2326 assert!(
2327 parse_cache_caps(blank).unwrap().is_empty(),
2328 "`{blank}` should clear every cap"
2329 );
2330 }
2331 }
2332
2333 #[test]
2334 fn a_cap_on_something_that_is_not_a_cache_is_refused_with_the_list() {
2335 let err = parse_cache_caps("venv=10").unwrap_err().to_string();
2338 assert!(err.contains("venv"), "{err}");
2339 assert!(err.contains("npm"), "the error lists what is valid: {err}");
2340 }
2341
2342 #[test]
2343 fn a_cap_has_to_be_a_whole_number_of_gibibytes() {
2344 for bad in ["uv=10.5", "uv=ten", "uv=-1", "uv="] {
2345 assert!(parse_cache_caps(bad).is_err(), "`{bad}` was accepted");
2346 }
2347 assert!(parse_cache_caps("uv").is_err());
2350 }
2351
2352 #[test]
2353 fn a_cap_of_zero_is_refused_rather_than_stored() {
2354 let err = parse_cache_caps("uv=0").unwrap_err().to_string();
2357 assert!(
2358 err.contains("`-`"),
2359 "the error names the way to clear it: {err}"
2360 );
2361 }
2362
2363 #[test]
2364 fn every_setting_round_trips_through_its_own_getter() {
2365 let mut settings = Settings::default();
2369 for setting in SETTINGS {
2370 let before = (setting.get)(&settings);
2371 let probe = match setting.kind {
2372 Kind::Toggle => if before == "true" { "false" } else { "true" }.to_string(),
2373 Kind::Number => "7".to_string(),
2376 Kind::Adapters => "cargo".to_string(),
2379 Kind::AdapterDays => "cargo=45".to_string(),
2382 Kind::CacheCaps => "cargo=10".to_string(),
2385 Kind::Choice => "hi".to_string(),
2388 };
2389 (setting.set)(&mut settings, &probe)
2390 .unwrap_or_else(|e| panic!("{} rejected `{probe}`: {e}", setting.key));
2391 assert_eq!(
2392 (setting.get)(&settings),
2393 probe,
2394 "{} reads back a different field than it writes",
2395 setting.key
2396 );
2397 }
2398 }
2399
2400 #[test]
2401 fn every_setting_is_documented_and_uniquely_named() {
2402 let mut seen = std::collections::HashSet::new();
2403 for setting in SETTINGS {
2404 assert!(seen.insert(setting.key), "duplicate key {}", setting.key);
2405 assert!(!setting.help.is_empty(), "{} has no help", setting.key);
2406 assert!(
2407 !setting.plain.is_empty(),
2408 "{} has no plain text",
2409 setting.key
2410 );
2411 assert!(
2413 setting.help.ends_with('.'),
2414 "{} help should read as a sentence",
2415 setting.key
2416 );
2417 assert!(
2418 setting.plain.ends_with('.'),
2419 "{} plain text should read as a sentence",
2420 setting.key
2421 );
2422 assert_ne!(
2425 setting.plain, setting.help,
2426 "{} says the same thing twice",
2427 setting.key
2428 );
2429 }
2430 }
2431
2432 #[test]
2433 fn the_settings_table_covers_every_field_of_settings() {
2434 let json = serde_json::to_value(Settings::default()).unwrap();
2438 let fields: Vec<String> = json.as_object().unwrap().keys().cloned().collect();
2439 for field in fields {
2440 assert!(
2441 SETTINGS.iter().any(|s| s.key == field),
2442 "`{field}` is a setting with no entry in SETTINGS, so `devp config set \
2443 {field}` cannot reach it"
2444 );
2445 }
2446 }
2447
2448 #[test]
2449 fn a_rejected_value_leaves_the_previous_one_in_place() {
2450 let mut settings = Settings::default();
2451 assert!((find_setting("scan_depth").unwrap().set)(&mut settings, "0").is_err());
2452 assert_eq!(settings.scan_depth, Settings::default().scan_depth);
2453
2454 assert!((find_setting("command_timeout_secs").unwrap().set)(&mut settings, "0").is_err());
2455 assert!((find_setting("check_interval_days").unwrap().set)(&mut settings, "0").is_err());
2456 assert!(
2457 (find_setting("update_check_interval_days").unwrap().set)(&mut settings, "0").is_err()
2458 );
2459 }
2460
2461 #[test]
2462 fn booleans_accept_the_words_people_actually_type() {
2463 assert!(parse_bool("k", "yes").unwrap());
2464 assert!(parse_bool("k", "ON").unwrap());
2465 assert!(!parse_bool("k", "0").unwrap());
2466 assert!(parse_bool("k", "maybe").is_err());
2467 }
2468
2469 #[test]
2470 fn an_unknown_key_lists_the_ones_that_exist() {
2471 let err = match find_setting("idel_days") {
2472 Ok(_) => panic!("`idel_days` is not a setting"),
2473 Err(e) => e.to_string(),
2474 };
2475 assert!(err.contains("idle_days"), "{err}");
2476 }
2477
2478 #[test]
2479 fn a_path_is_never_mistaken_for_an_action() {
2480 assert!(!is_toggle_word("~/Code/my-repo"));
2482 assert!(!is_toggle_word("."));
2483 assert!(!is_toggle_word(""));
2484 assert!(is_toggle_word("install"));
2485 }
2486}