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_hooks",
476 category: Category::Unattended,
477 since: "1.0.0",
478 kind: Kind::Toggle,
479 help: "Install the Git hooks that register repositories as you clone them.",
480 plain: "Registers new repositories automatically as you clone them, so you never have to add them by hand.",
481 get: |s| s.auto_hooks.to_string(),
482 set: |s, v| {
483 s.auto_hooks = parse_bool("auto_hooks", v)?;
484 Ok(())
485 },
486 },
487 Setting {
488 key: "auto_hooks_chain",
489 category: Category::Unattended,
490 since: "1.0.0",
491 kind: Kind::Toggle,
492 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.",
493 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.",
494 get: |s| s.auto_hooks_chain.to_string(),
495 set: |s, v| {
496 s.auto_hooks_chain = parse_bool("auto_hooks_chain", v)?;
497 Ok(())
498 },
499 },
500 Setting {
501 key: "update_check",
502 category: Category::Updates,
503 since: "1.0.0",
504 kind: Kind::Toggle,
505 help: "Ask GitHub for the latest release from time to time. Sends nothing but the request.",
506 plain: "Whether dev-prune checks GitHub now and then to see if there is a newer version. It sends no information about you.",
507 get: |s| s.update_check.to_string(),
508 set: |s, v| {
509 s.update_check = parse_bool("update_check", v)?;
510 Ok(())
511 },
512 },
513 Setting {
514 key: "update_check_interval_days",
515 category: Category::Updates,
516 since: "1.0.0",
517 kind: Kind::Number,
518 help: "Days between automatic release checks.",
519 plain: "How often that version check happens.",
520 get: |s| s.update_check_interval_days.to_string(),
521 set: |s, v| {
522 let days: i64 = v.parse().map_err(|_| {
523 anyhow::anyhow!("update_check_interval_days must be a positive integer")
524 })?;
525 if days < 1 {
526 bail!("update_check_interval_days must be at least 1.");
527 }
528 s.update_check_interval_days = days;
529 Ok(())
530 },
531 },
532 Setting {
533 key: "update_check_timeout_secs",
534 category: Category::Updates,
535 since: "1.0.0",
536 kind: Kind::Number,
537 help: "Seconds the release check waits for GitHub. Raise it behind a slow proxy.",
538 plain: "How long the version check waits before giving up. Raise it if you are behind a slow proxy.",
539 get: |s| s.update_check_timeout_secs.to_string(),
540 set: |s, v| {
541 let secs: u64 = v.parse().map_err(|_| {
542 anyhow::anyhow!("update_check_timeout_secs must be a positive integer")
543 })?;
544 if secs == 0 {
545 bail!("update_check_timeout_secs must be at least 1.");
546 }
547 s.update_check_timeout_secs = secs;
548 Ok(())
549 },
550 },
551 Setting {
552 key: "enable_cargo",
553 category: Category::BuildTrees,
554 since: "1.5.0",
555 kind: Kind::Toggle,
556 help: "Turn on the opt-in Cargo adapter (target/ comes back by recompiling, not downloading).",
557 plain: "Clean Rust build folders too. These come back by recompiling, which takes minutes rather than a download — so it is off by default.",
558 get: |s| s.enable_cargo.to_string(),
559 set: |s, v| {
560 s.enable_cargo = parse_bool("enable_cargo", v)?;
561 Ok(())
562 },
563 },
564 Setting {
565 key: "enable_gradle",
566 category: Category::BuildTrees,
567 since: "1.3.0",
568 kind: Kind::Toggle,
569 help: "Turn on the opt-in Gradle adapter (build/ and .gradle/ come back by recompiling).",
570 plain: "Clean Android and Java build folders too. Same trade: they come back by recompiling, not downloading.",
571 get: |s| s.enable_gradle.to_string(),
572 set: |s, v| {
573 s.enable_gradle = parse_bool("enable_gradle", v)?;
574 Ok(())
575 },
576 },
577 Setting {
578 key: "enable_maven",
579 category: Category::BuildTrees,
580 since: "1.3.0",
581 kind: Kind::Toggle,
582 help: "Turn on the opt-in Maven adapter (target/ comes back by recompiling).",
583 plain: "Clean Maven build folders too. They come back by recompiling.",
584 get: |s| s.enable_maven.to_string(),
585 set: |s, v| {
586 s.enable_maven = parse_bool("enable_maven", v)?;
587 Ok(())
588 },
589 },
590 Setting {
591 key: "enable_swift",
592 category: Category::BuildTrees,
593 since: "1.4.0",
594 kind: Kind::Toggle,
595 help: "Turn on the opt-in SwiftPM adapter (.build/ comes back by recompiling).",
596 plain: "Clean Swift build folders too. They come back by recompiling.",
597 get: |s| s.enable_swift.to_string(),
598 set: |s, v| {
599 s.enable_swift = parse_bool("enable_swift", v)?;
600 Ok(())
601 },
602 },
603 Setting {
604 key: "enable_dart",
605 category: Category::BuildTrees,
606 since: "1.6.0",
607 kind: Kind::Toggle,
608 help: "Turn on the opt-in Dart/Flutter adapter (.dart_tool/ holds build caches).",
609 plain: "Clean Dart and Flutter caches too. Part comes back instantly, part by recompiling.",
610 get: |s| s.enable_dart.to_string(),
611 set: |s, v| {
612 s.enable_dart = parse_bool("enable_dart", v)?;
613 Ok(())
614 },
615 },
616 Setting {
617 key: "enable_mix_build",
618 category: Category::BuildTrees,
619 since: "1.7.0",
620 kind: Kind::Toggle,
621 help: "Turn on the opt-in Elixir Mix build-tree adapter (_build/ comes back by recompiling).",
622 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.",
623 get: |s| s.enable_mix_build.to_string(),
624 set: |s, v| {
625 s.enable_mix_build = parse_bool("enable_mix_build", v)?;
626 Ok(())
627 },
628 },
629 Setting {
630 key: "enable_vcpkg",
631 category: Category::BuildTrees,
632 since: "1.8.0",
633 kind: Kind::Toggle,
634 help: "Turn on the opt-in vcpkg adapter (vcpkg_installed/ comes back by recompiling).",
635 plain: "Clean C and C++ vcpkg_installed/ folders too. They come back by recompiling.",
636 get: |s| s.enable_vcpkg.to_string(),
637 set: |s, v| {
638 s.enable_vcpkg = parse_bool("enable_vcpkg", v)?;
639 Ok(())
640 },
641 },
642 Setting {
643 key: "enable_cmake_build",
644 category: Category::BuildTrees,
645 since: "1.8.0",
646 kind: Kind::Toggle,
647 help: "Turn on the opt-in CMake adapter (build trees proven by their CMakeCache.txt).",
648 plain: "Clean C and C++ build folders CMake configured. A `build/` you made by hand is \
649 never touched.",
650 get: |s| s.enable_cmake_build.to_string(),
651 set: |s, v| {
652 s.enable_cmake_build = parse_bool("enable_cmake_build", v)?;
653 Ok(())
654 },
655 },
656 Setting {
657 key: "build_idle_days",
658 category: Category::BuildTrees,
659 since: "1.3.0",
660 kind: Kind::Number,
661 help: "Idle days before the opt-in adapters' build trees are pruned. Applied as max(this, idle_days).",
662 plain: "A longer wait, used only for the build folders above, because getting those back costs a recompile rather than a download.",
663 get: |s| s.build_idle_days.to_string(),
664 set: |s, v| {
665 let days: u64 = v
666 .parse()
667 .map_err(|_| anyhow::anyhow!("build_idle_days must be a non-negative integer"))?;
668 s.build_idle_days = days;
669 Ok(())
670 },
671 },
672 Setting {
673 key: "auto_update",
674 category: Category::Updates,
675 since: "1.3.0",
676 kind: Kind::Toggle,
677 help: "Install a newer release by itself at the end of a prune pass. On by default.",
678 plain: "Whether dev-prune installs its own updates after a cleanup. The download is checked against its published fingerprint first.",
679 get: |s| s.auto_update.to_string(),
680 set: |s, v| {
681 s.auto_update = parse_bool("auto_update", v)?;
682 Ok(())
683 },
684 },
685 Setting {
686 key: "version_lock",
687 category: Category::Updates,
688 since: "1.8.0",
689 kind: Kind::Toggle,
690 help: "Pin this copy to the version it is. Overrides auto_update, `devp update \
691 --install`, `devp install --channel` and the install scripts.",
692 plain: "Stay on exactly this version. Nothing dev-prune does replaces the binary \
693 while this is on -- not the automatic update, not a re-run of the install \
694 one-liner.",
695 get: |s| s.version_lock.to_string(),
696 set: |s, v| {
697 s.version_lock = parse_bool("version_lock", v)?;
698 Ok(())
699 },
700 },
701 Setting {
702 key: "disabled_adapters",
703 category: Category::Scope,
704 since: "1.4.0",
705 kind: Kind::Adapters,
706 help: "Adapters to leave alone entirely, by name. Empty means every one of them is active.",
707 plain: "Ecosystems to ignore completely — as if you did not have them installed at all.",
708 get: |s| {
709 if s.disabled_adapters.is_empty() {
710 "(none)".to_string()
711 } else {
712 s.disabled_adapters.join(",")
713 }
714 },
715 set: |s, v| {
716 s.disabled_adapters = parse_adapter_list(v)?;
717 Ok(())
718 },
719 },
720 Setting {
721 key: "adapter_idle_days",
722 category: Category::Scope,
723 since: "1.5.0",
724 kind: Kind::AdapterDays,
725 help: "Per-adapter idle windows, as `cargo=60,npm=30`. Each one can only raise its own wait.",
726 plain: "A different waiting period for one ecosystem. Useful when your Rust projects should wait longer than your Node ones.",
727 get: |s| {
728 if s.adapter_idle_days.is_empty() {
729 "(none)".to_string()
730 } else {
731 s.adapter_idle_days
732 .iter()
733 .map(|(name, days)| format!("{name}={days}"))
734 .collect::<Vec<_>>()
735 .join(",")
736 }
737 },
738 set: |s, v| {
739 s.adapter_idle_days = parse_adapter_days(v)?;
740 Ok(())
741 },
742 },
743 Setting {
744 key: "cache_max_gb",
745 category: Category::Caches,
746 since: "1.8.0",
747 kind: Kind::CacheCaps,
748 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`.",
749 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.",
750 get: |s| {
751 if s.cache_max_gb.is_empty() {
752 "(none)".to_string()
753 } else {
754 s.cache_max_gb
755 .iter()
756 .map(|(name, gb)| format!("{name}={gb}"))
757 .collect::<Vec<_>>()
758 .join(",")
759 }
760 },
761 set: |s, v| {
762 s.cache_max_gb = parse_cache_caps(v)?;
763 Ok(())
764 },
765 },
766];
767
768fn parse_cache_caps(value: &str) -> Result<std::collections::BTreeMap<String, u64>> {
785 let trimmed = value.trim();
786 if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
787 return Ok(std::collections::BTreeMap::new());
788 }
789
790 let mut caps = std::collections::BTreeMap::new();
791 for raw in trimmed.split(',') {
792 let entry = raw.trim();
793 if entry.is_empty() {
794 continue;
795 }
796 let Some((name, value)) = entry.split_once('=') else {
797 bail!("`{entry}` must be written as `<manager>=<gib>`, for example `uv=10`.");
798 };
799 let name = name.trim().to_lowercase();
800 if !crate::commands::caches::is_cache_manager(&name) {
801 bail!(
802 "`{name}` is not a manager dev-prune knows a cache for. Valid names: {}",
803 crate::commands::caches::known_managers().join(", ")
804 );
805 }
806 let parsed: u64 = value.trim().parse().map_err(|_| {
807 anyhow::anyhow!(
808 "`{name}` needs a whole number of gibibytes, not `{}`.",
809 value.trim()
810 )
811 })?;
812 if parsed == 0 {
813 bail!(
814 "`{name}=0` would call the cache too big the moment it exists. Use `-` to clear the caps instead."
815 );
816 }
817 caps.insert(name, parsed);
818 }
819 Ok(caps)
820}
821
822fn parse_adapter_days(value: &str) -> Result<std::collections::BTreeMap<String, u64>> {
827 let trimmed = value.trim();
828 if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
829 return Ok(std::collections::BTreeMap::new());
830 }
831
832 let mut days = std::collections::BTreeMap::new();
833 for raw in trimmed.split(',') {
834 let entry = raw.trim();
835 if entry.is_empty() {
836 continue;
837 }
838 let Some((name, value)) = entry.split_once('=') else {
839 bail!("`{entry}` must be written as `<adapter>=<days>`, for example `cargo=60`.");
840 };
841 let name = name.trim().to_lowercase();
842 if !crate::adapters::is_adapter_name(&name) {
843 bail!(
844 "`{name}` is not an adapter. Valid names: {}",
845 crate::adapters::all_adapter_names().join(", ")
846 );
847 }
848 let parsed: u64 = value.trim().parse().map_err(|_| {
849 anyhow::anyhow!(
850 "`{name}` needs a whole number of days, not `{}`.",
851 value.trim()
852 )
853 })?;
854 days.insert(name, parsed);
855 }
856 Ok(days)
857}
858
859fn parse_adapter_list(value: &str) -> Result<Vec<String>> {
860 let trimmed = value.trim();
861 if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
864 return Ok(Vec::new());
865 }
866
867 let mut names: Vec<String> = Vec::new();
868 for raw in trimmed.split(',') {
869 let name = raw.trim().to_lowercase();
870 if name.is_empty() {
871 continue;
872 }
873 if !crate::adapters::is_adapter_name(&name) {
874 bail!(
875 "`{name}` is not an adapter. Valid names: {}",
876 crate::adapters::all_adapter_names().join(", ")
877 );
878 }
879 if !names.contains(&name) {
880 names.push(name);
881 }
882 }
883 Ok(names)
884}
885
886fn parse_bool(key: &str, value: &str) -> Result<bool> {
887 match value.trim().to_lowercase().as_str() {
888 "true" | "yes" | "y" | "on" | "1" => Ok(true),
889 "false" | "no" | "n" | "off" | "0" => Ok(false),
890 _ => bail!("{key} must be true or false"),
891 }
892}
893
894pub fn invalid_settings(settings: &Settings) -> Vec<(&'static str, String)> {
905 SETTINGS
906 .iter()
907 .filter_map(|setting| {
908 let mut probe = settings.clone();
909 (setting.set)(&mut probe, &(setting.get)(settings))
910 .err()
911 .map(|e| (setting.key, e.to_string()))
912 })
913 .collect()
914}
915
916pub fn setting_count() -> usize {
918 SETTINGS.len()
919}
920
921fn save_settings_edits<'a>(edits: impl Iterator<Item = (&'a str, &'a str)>) -> Result<()> {
930 let mut fresh = Registry::load()?;
931 for (key, value) in edits {
932 (find_setting(key)?.set)(&mut fresh.settings, value)?;
933 }
934 fresh.save()
935}
936
937fn find_setting(key: &str) -> Result<&'static Setting> {
938 SETTINGS
939 .iter()
940 .find(|s| s.key == key)
941 .ok_or_else(|| anyhow::anyhow!("Unknown config key: {key}. Valid keys: {}", valid_keys()))
942}
943
944fn valid_keys() -> String {
945 SETTINGS
946 .iter()
947 .map(|s| s.key)
948 .collect::<Vec<_>>()
949 .join(", ")
950}
951
952#[derive(Debug, PartialEq, Eq)]
954pub enum Toggle {
955 Enable,
956 Disable,
957 Status,
958}
959
960pub fn parse_toggle(action: &str) -> Result<Toggle> {
970 match action.to_lowercase().as_str() {
971 "enable" | "install" | "on" => Ok(Toggle::Enable),
972 "disable" | "uninstall" | "remove" | "off" => Ok(Toggle::Disable),
973 "" | "status" | "show" => Ok(Toggle::Status),
974 other => bail!(
975 "Unknown action `{other}`. Expected `enable`, `disable` or `status` \
976 (`install` / `uninstall` / `on` / `off` also work)."
977 ),
978 }
979}
980
981pub fn is_toggle_word(word: &str) -> bool {
987 parse_toggle(word).is_ok() && !word.is_empty()
988}
989
990fn resolve_workspace(path: &str) -> Result<std::path::PathBuf> {
997 let raw = Path::new(path);
998 if !raw.is_dir() {
999 bail!(
1000 "`{path}` is neither an action nor an existing directory.\n\
1001 Expected `enable`, `disable` or `status`, or a path to a repository."
1002 );
1003 }
1004 Ok(raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf()))
1005}
1006
1007pub fn run_get(key: &str) -> Result<()> {
1009 let registry = Registry::load()?;
1010 let setting = find_setting(key)?;
1011 println!("{key} = {}", (setting.get)(®istry.settings));
1012 Ok(())
1013}
1014
1015pub fn run_set(key: &str, value: &str) -> Result<()> {
1017 let mut registry = Registry::load()?;
1018 let setting = find_setting(key)?;
1019 (setting.set)(&mut registry.settings, value)?;
1020 registry.save()?;
1021
1022 output::print_success(&format!("{key} = {}", (setting.get)(®istry.settings)));
1025
1026 if key == "language"
1030 && let Some(meta) = i18n::language(®istry.settings.language)
1031 && !meta.reviewed
1032 {
1033 output::print_info(&format!(
1034 "No native speaker has reviewed the {} translation yet. Corrections are welcome — see docs/TRANSLATIONS.md.",
1035 meta.english_name
1036 ));
1037 }
1038 Ok(())
1039}
1040
1041fn key_column_width() -> usize {
1043 SETTINGS.iter().map(|s| s.key.len()).max().unwrap_or(0)
1044}
1045
1046pub fn run_show() -> Result<()> {
1048 let registry = Registry::load()?;
1049 let width = key_column_width();
1050
1051 output::print_header("dev-prune Global Configuration");
1052 for (category, settings) in settings_by_category() {
1053 output::print_section(category.title());
1054 for setting in settings {
1055 println!(
1056 " {:<width$} = {}",
1057 setting.key,
1058 (setting.get)(®istry.settings)
1059 );
1060 }
1061 }
1062
1063 output::print_section("This machine");
1066 println!(
1067 " {:<width$} = {}",
1068 "tracked_repos",
1069 registry.repo_count()
1070 );
1071 let reg_path = Registry::registry_path()
1072 .map(|p| output::clean_path(&p))
1073 .unwrap_or_else(|_| "unknown".to_string());
1074 println!(" {:<width$} = {reg_path}", "registry_file");
1075
1076 print_recommendation_summary(®istry.settings);
1080
1081 println!();
1082 output::print_info("Change any of these with `devp config set <key> <value>`.");
1083 output::print_info("Walk through them one at a time with `devp config wizard`.");
1084
1085 Ok(())
1086}
1087
1088#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1090pub enum Opened {
1091 ByRequest,
1093 OnItsOwn,
1097}
1098
1099fn why_this_opened(opened: Opened) -> Option<String> {
1106 if opened == Opened::ByRequest {
1107 return None;
1108 }
1109 let new = settings_added_since_review().len();
1110 Some(if reviewed_version().is_none() || new == 0 {
1111 "You did not ask for this screen. dev-prune opens it once, on the first command \
1112 after it is installed, so that you see what its defaults do before they start \
1113 doing it. Whatever you typed runs as soon as you leave. It will not open by \
1114 itself again unless an upgrade adds a setting."
1115 .to_string()
1116 } else {
1117 format!(
1118 "You did not ask for this screen. This upgrade added {new} {}, and dev-prune \
1119 shows a new one once before its default goes on applying. Nothing else about \
1120 your configuration changed. Whatever you typed runs as soon as you leave.",
1121 output::plural(new, "setting", "settings"),
1122 )
1123 })
1124}
1125
1126pub fn run_wizard(no_tui: bool, opened: Opened) -> Result<()> {
1137 if !no_tui && full_screen_is_usable() {
1138 return run_wizard_tui(opened);
1139 }
1140 run_wizard_prompts(opened)
1141}
1142
1143fn full_screen_is_usable() -> bool {
1151 use std::io::IsTerminal;
1152 if std::env::var_os(crate::constants::ENV_NO_TUI).is_some() {
1153 return false;
1154 }
1155 std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
1156}
1157
1158fn run_wizard_tui(opened: Opened) -> Result<()> {
1160 use crate::tui::config_view::{ConfigRow, ConfigSession, Control, Outcome};
1161
1162 let registry = Registry::load()?;
1163 let new_keys = settings_added_since_review();
1164 let fresh = Settings::default();
1168
1169 let rows: Vec<ConfigRow> = settings_by_category()
1172 .into_iter()
1173 .flat_map(|(category, settings)| {
1174 settings.into_iter().map(move |setting| (category, setting))
1175 })
1176 .map(|(category, setting)| {
1177 let value = (setting.get)(®istry.settings);
1178 ConfigRow {
1179 key: setting.key,
1180 category: category.title(),
1181 help: setting.help,
1182 plain: setting.plain,
1183 control: match setting.kind {
1184 Kind::Toggle => Control::Toggle,
1185 Kind::Choice => Control::Choice(i18n::choices()),
1186 Kind::Number => Control::Number,
1187 Kind::Adapters => Control::Adapters,
1188 Kind::AdapterDays => Control::AdapterDays,
1189 Kind::CacheCaps => Control::CacheCaps,
1190 },
1191 original: value.clone(),
1192 default: (setting.get)(&fresh),
1193 recommended: recommended_value(setting.key),
1194 cautious: recommendation(setting.key).is_some_and(|r| r.cautious),
1195 value,
1196 is_new: new_keys.contains(&setting.key),
1197 }
1198 })
1199 .collect();
1200
1201 let base = registry.settings.clone();
1204 let validate = move |key: &str, value: &str| -> std::result::Result<(), String> {
1205 let setting = find_setting(key).map_err(|e| e.to_string())?;
1206 let mut probe = base.clone();
1207 (setting.set)(&mut probe, value).map_err(|e| format!("{e}"))
1208 };
1209
1210 let report = crate::commands::trust::build(®istry);
1211 let adapters = crate::adapters::all_adapter_names();
1212 let opt_in = crate::adapters::opt_in_adapter_names();
1213 let capped: Vec<&'static str> = adapters
1216 .iter()
1217 .copied()
1218 .filter(|name| crate::commands::caches::is_cache_manager(name))
1219 .collect();
1220
1221 let why = why_this_opened(opened);
1222 let outcome = crate::tui::config_view::run(ConfigSession {
1223 declaration: declaration_lines(&report),
1224 standing: NOTHING_DELETED_YET.to_string(),
1225 suggestions: first_run_suggestions(),
1226 rows,
1227 adapters: &adapters,
1228 opt_in_adapters: &opt_in,
1229 capped_adapters: &capped,
1230 groups: crate::adapters::ADAPTER_GROUPS,
1231 validate: &validate,
1232 title: "dev-prune configuration",
1233 uninvited: why.as_deref(),
1234 })?;
1235
1236 match outcome {
1237 Outcome::Cancelled => {
1241 output::print_info("Cancelled — nothing was changed.");
1242 Ok(())
1243 }
1244 Outcome::KeepAll => {
1245 mark_reviewed();
1246 output::print_success(
1247 "Keeping the current values. `devp config set <key> <value>` changes any.",
1248 );
1249 Ok(())
1250 }
1251 Outcome::Save(changed) => {
1252 save_settings_edits(changed.iter().map(|row| (row.key, row.value.as_str())))?;
1253 mark_reviewed();
1254
1255 output::print_header("Saved");
1259 let width = changed.iter().map(|r| r.key.len()).max().unwrap_or(0);
1260 for row in &changed {
1261 println!(
1262 " {:<width$} = {} (was {})",
1263 row.key, row.value, row.original
1264 );
1265 }
1266 println!();
1267 output::print_success(&format!(
1268 "{} {} saved. `devp config show` lists every setting.",
1269 changed.len(),
1270 output::plural(changed.len(), "change", "changes")
1271 ));
1272 Ok(())
1273 }
1274 }
1275}
1276
1277fn recommended_value(key: &str) -> Option<&'static str> {
1294 recommendation(key).map(|r| r.value)
1295}
1296
1297fn recommendation(key: &str) -> Option<&'static Recommendation> {
1299 RECOMMENDED.iter().find(|r| r.key == key)
1300}
1301
1302fn outstanding(settings: &Settings) -> Vec<&'static Recommendation> {
1304 RECOMMENDED
1305 .iter()
1306 .filter(|r| {
1307 find_setting(r.key)
1308 .map(|s| (s.get)(settings))
1309 .ok()
1310 .as_deref()
1311 != Some(r.value)
1312 })
1313 .collect()
1314}
1315
1316fn print_recommendation_summary(settings: &Settings) {
1323 let outstanding = outstanding(settings);
1324 if outstanding.is_empty() {
1325 return;
1326 }
1327 let width = key_column_width();
1328
1329 let safe: Vec<_> = outstanding.iter().filter(|r| !r.cautious).collect();
1330 if !safe.is_empty() {
1331 output::print_section(SAFE_TIER);
1332 for r in &safe {
1333 println!(" {:<width$} = {} {}", r.key, r.value, r.label);
1334 }
1335 println!();
1336 output::print_info(&format!(
1337 "`devp config recommended` sets {} {} in one command.",
1338 safe.len(),
1339 output::plural(safe.len(), "setting", "settings")
1340 ));
1341 }
1342
1343 let cautious: Vec<_> = outstanding.iter().filter(|r| r.cautious).collect();
1344 if !cautious.is_empty() {
1345 output::print_section(CAUTIOUS_TIER);
1346 for r in &cautious {
1347 println!(" {:<width$} = {} {}", r.key, r.value, r.label);
1348 println!(" {:<width$} {}", "", r.why);
1349 }
1350 println!();
1351 output::print_info(
1352 "Not included above. `devp config recommended --with-cautious` includes it; \
1353 `devp config set <key> <value>` sets one on its own.",
1354 );
1355 }
1356}
1357
1358pub fn run_recommended(with_cautious: bool) -> Result<()> {
1372 let mut registry = Registry::load()?;
1373 let width = key_column_width();
1374
1375 output::print_header("dev-prune recommended settings");
1376
1377 let mut applied: Vec<(&'static str, String, &'static str)> = Vec::new();
1378 let mut already: Vec<&'static Recommendation> = Vec::new();
1379 let mut held_back: Vec<&'static Recommendation> = Vec::new();
1380
1381 for rec in RECOMMENDED {
1382 let setting = find_setting(rec.key)?;
1383 let current = (setting.get)(®istry.settings);
1384 if current == rec.value {
1385 already.push(rec);
1386 } else if rec.cautious && !with_cautious {
1387 held_back.push(rec);
1388 } else {
1389 (setting.set)(&mut registry.settings, rec.value)?;
1390 applied.push((rec.key, current, rec.value));
1391 }
1392 }
1393
1394 if !applied.is_empty() {
1395 registry.save()?;
1396 output::print_section("Turned on");
1397 for (key, from, to) in &applied {
1398 println!(" {:<width$} {from} → {to}", key);
1399 }
1400 }
1401 if !already.is_empty() {
1402 output::print_section("Already set");
1403 for rec in &already {
1404 println!(" {:<width$} {}", rec.key, rec.label);
1405 }
1406 }
1407 if !held_back.is_empty() {
1408 output::print_section(CAUTIOUS_TIER);
1409 for rec in &held_back {
1410 println!(" {:<width$} = {} {}", rec.key, rec.value, rec.label);
1411 println!(" {:<width$} {}", "", rec.why);
1412 }
1413 println!();
1414 output::print_info(
1415 "Left alone. `devp config recommended --with-cautious` includes it; \
1416 `devp config set <key> <value>` sets one on its own.",
1417 );
1418 }
1419
1420 println!();
1421 if applied.is_empty() {
1422 output::print_success(
1423 "Nothing changed — everything recommended without a caveat is already set.",
1424 );
1425 } else {
1426 output::print_success(&format!(
1427 "{} {} changed. `devp config show` lists them all.",
1428 applied.len(),
1429 output::plural(applied.len(), "setting", "settings")
1430 ));
1431 }
1432 Ok(())
1433}
1434
1435fn first_run_suggestions() -> Vec<crate::tui::config_view::Suggestion> {
1436 use crate::tui::config_view::Suggestion;
1437
1438 if reviewed_version().is_some() {
1439 return Vec::new();
1440 }
1441 RECOMMENDED
1442 .iter()
1443 .filter_map(|r| {
1444 let setting = find_setting(r.key).ok()?;
1445 Some(Suggestion {
1446 key: r.key,
1447 label: r.label,
1448 help: setting.help,
1449 plain: setting.plain,
1450 why: r.why,
1451 value: r.value,
1452 cautious: r.cautious,
1453 })
1454 })
1455 .collect()
1456}
1457
1458const NOTHING_DELETED_YET: &str =
1460 "Nothing has been deleted, and nothing will be until a lockfile proves it comes back.";
1461
1462fn provenance_rows() -> Vec<(&'static str, String)> {
1479 let url = |u: &str| u.trim_start_matches("https://").to_string();
1482 vec![
1483 (
1484 "What you are running",
1485 format!(
1486 "{} v{}",
1487 crate::constants::APP_NAME,
1488 crate::constants::VERSION
1489 ),
1490 ),
1491 (
1492 "Written by",
1493 format!("{}, under Apache-2.0", crate::constants::AUTHOR),
1494 ),
1495 ("Source code", url(crate::constants::REPO_URL)),
1496 (
1497 "Official downloads",
1498 format!("{} · GitHub releases", url(crate::constants::HOMEPAGE_URL)),
1499 ),
1500 (
1501 "Package registries",
1502 "crates.io · PyPI · npm, all named dev-prune".to_string(),
1503 ),
1504 (
1505 "Editor extension",
1506 "VS Code Marketplace · Open VSX".to_string(),
1507 ),
1508 (
1509 "Any other source",
1510 "is not a copy the author published".to_string(),
1511 ),
1512 ]
1513}
1514
1515fn declaration_lines(
1521 report: &crate::commands::trust::TrustReport,
1522) -> Vec<crate::tui::config_view::DeclarationLine> {
1523 use crate::commands::trust::{TrustRow, Verdict};
1524 use crate::tui::config_view::DeclarationLine;
1525
1526 let heading = |text: &str| DeclarationLine {
1527 mark: '#',
1528 subject: text.to_string(),
1529 state: String::new(),
1530 };
1531 let row = |r: &TrustRow| DeclarationLine {
1532 mark: match r.verdict {
1533 Verdict::Guaranteed | Verdict::Safe => '+',
1534 Verdict::Widened => '!',
1535 Verdict::Neutral => ' ',
1536 },
1537 subject: r.subject.to_string(),
1538 state: r.state.clone(),
1539 };
1540
1541 let mut lines = vec![heading("What this is, and where it came from")];
1542 lines.extend(
1543 provenance_rows()
1544 .into_iter()
1545 .map(|(subject, state)| DeclarationLine {
1546 mark: ' ',
1547 subject: subject.to_string(),
1548 state,
1549 }),
1550 );
1551 lines.push(heading(""));
1552 lines.push(heading("Guaranteed by the code"));
1553 lines.extend(report.guarantees.iter().map(&row));
1554 lines.push(heading(""));
1555 lines.push(heading("On this machine"));
1556 lines.extend(report.machine.iter().map(&row));
1557 lines
1558}
1559
1560fn run_wizard_prompts(opened: Opened) -> Result<()> {
1564 use std::io::{self, IsTerminal, Write};
1565
1566 if !io::stdin().is_terminal() {
1567 bail!(
1568 "`devp config wizard` needs a terminal to ask questions on.\n\
1569 Use `devp config show` to read the settings and `devp config set <key> <value>` \
1570 to change one."
1571 );
1572 }
1573
1574 let mut registry = Registry::load()?;
1575 let width = key_column_width();
1576 let new_keys = settings_added_since_review();
1577 let fresh = Settings::default();
1578
1579 output::print_header("dev-prune configuration");
1580 if let Some(why) = why_this_opened(opened) {
1583 output::print_warning(&why);
1584 println!();
1585 }
1586 output::print_section("What this is, and where it came from");
1587 for (subject, state) in provenance_rows() {
1588 println!(" {} {state}", output::pad_display(subject, 22));
1589 }
1590 println!(" {}", crate::constants::LICENCE_NOTICE);
1591 println!();
1592
1593 output::print_info("These are the defaults every run will use. Nothing has been changed yet.");
1594 println!();
1595 for (category, settings) in settings_by_category() {
1596 output::print_section(category.title());
1597 for setting in settings {
1598 let badge = if new_keys.contains(&setting.key) {
1601 " (new in this version)"
1602 } else {
1603 ""
1604 };
1605 println!(
1606 " {:<width$} = {}{badge}",
1607 setting.key,
1608 (setting.get)(®istry.settings)
1609 );
1610 println!(" {:<width$} {}", "", setting.help);
1611 println!(" {:<width$} {}", "", setting.plain);
1614 let mut facts = format!("default {}", (setting.get)(&fresh));
1618 if let Some(rec) = recommendation(setting.key) {
1622 facts.push_str(&format!(
1623 " · recommended {} ({}, not required)",
1624 rec.value,
1625 if rec.cautious {
1626 "read the note below first"
1627 } else {
1628 "suggested"
1629 }
1630 ));
1631 }
1632 println!(" {:<width$} {facts}", "");
1633 if let Some(rec) = recommendation(setting.key).filter(|r| r.cautious) {
1634 println!(" {:<width$} {}", "", rec.why);
1635 }
1636 }
1637 }
1638 println!();
1639
1640 print_recommendation_summary(®istry.settings);
1641 println!();
1642
1643 if confirmed_twice("Press Enter twice to keep all of these, or type anything to change them: ")?
1648 {
1649 mark_reviewed();
1650 output::print_success("Keeping the defaults. `devp config set <key> <value>` changes any.");
1651 return Ok(());
1652 }
1653
1654 println!();
1655 output::print_info("Enter a new value, or press Enter to keep the one shown.");
1656 println!();
1657
1658 let mut edits: Vec<(&'static str, String, String)> = Vec::new();
1659 let mut input_ended = false;
1660 for setting in SETTINGS {
1661 if input_ended {
1662 break;
1663 }
1664 let current = (setting.get)(®istry.settings);
1665 loop {
1666 print!(" {} [{current}]: ", setting.key);
1667 io::stdout().flush()?;
1668 let mut line = String::new();
1669 if io::stdin().read_line(&mut line)? == 0 {
1672 println!();
1673 input_ended = true;
1674 break;
1675 }
1676 let typed = line.trim();
1677 if typed.is_empty() {
1678 break;
1679 }
1680 match (setting.set)(&mut registry.settings, typed) {
1681 Ok(()) => {
1682 let now = (setting.get)(®istry.settings);
1686 if now != current {
1687 edits.push((setting.key, current.clone(), now));
1688 }
1689 break;
1690 }
1691 Err(e) => output::print_error(&format!("{e}")),
1694 }
1695 }
1696 }
1697
1698 println!();
1699 if edits.is_empty() {
1700 if input_ended {
1704 output::print_info("Input ended — nothing was changed.");
1705 return Ok(());
1706 }
1707 mark_reviewed();
1708 output::print_success("Nothing changed — the defaults are in place.");
1709 return Ok(());
1710 }
1711
1712 output::print_section("About to be saved");
1715 for (key, from, to) in &edits {
1716 println!(" {:<width$} {from} → {to}", key);
1717 }
1718 println!();
1719 if !confirmed_twice("Press Enter twice to save, or type anything to abandon: ")? {
1720 output::print_info("Nothing was written.");
1721 return Ok(());
1722 }
1723
1724 save_settings_edits(edits.iter().map(|(key, _, to)| (*key, to.as_str())))?;
1725 mark_reviewed();
1726 let changed = edits.len();
1727 println!();
1728 output::print_success(&format!(
1729 "Saved {changed} {}. `devp config show` lists them all.",
1730 output::plural(changed, "change", "changes")
1731 ));
1732 Ok(())
1733}
1734
1735fn confirmed_twice(prompt: &str) -> Result<bool> {
1744 use std::io::{self, Write};
1745
1746 for pass in 0..2 {
1747 print!(
1748 "{}",
1749 if pass == 0 {
1750 prompt
1751 } else {
1752 "Press Enter once more to confirm: "
1753 }
1754 );
1755 io::stdout().flush()?;
1756 let mut line = String::new();
1757 if io::stdin().read_line(&mut line)? == 0 {
1758 println!();
1759 return Ok(false);
1760 }
1761 if !line.trim().is_empty() {
1762 return Ok(false);
1763 }
1764 }
1765 Ok(true)
1766}
1767
1768const REVIEW_MARKER: &str = "config-reviewed";
1770
1771pub fn config_review_is_due() -> bool {
1780 let Ok(dir) = Registry::config_dir() else {
1781 return false;
1782 };
1783 if !dir.join(REVIEW_MARKER).exists() {
1784 return true;
1785 }
1786 !settings_added_since_review().is_empty()
1787}
1788
1789fn reviewed_version() -> Option<String> {
1791 let dir = Registry::config_dir().ok()?;
1792 let recorded = std::fs::read_to_string(dir.join(REVIEW_MARKER)).ok()?;
1793 let recorded = recorded.trim().to_string();
1794 (!recorded.is_empty()).then_some(recorded)
1795}
1796
1797pub fn settings_added_since_review() -> Vec<&'static str> {
1806 let Some(reviewed) = reviewed_version() else {
1807 return Vec::new();
1808 };
1809 SETTINGS
1810 .iter()
1811 .filter(|s| {
1812 crate::commands::update::compare_versions(s.since, &reviewed)
1813 == Some(std::cmp::Ordering::Greater)
1814 })
1815 .map(|s| s.key)
1816 .collect()
1817}
1818
1819fn mark_reviewed() {
1820 if let Ok(dir) = Registry::config_dir() {
1821 let _ = std::fs::create_dir_all(&dir);
1822 let _ = std::fs::write(dir.join(REVIEW_MARKER), crate::constants::VERSION);
1823 }
1824}
1825
1826pub fn skip_config_review() {
1831 mark_reviewed();
1832}
1833
1834pub fn run_global_update() -> Result<()> {
1836 output::print_header("dev-prune Global Configuration Audit & Sync");
1837
1838 let registry = Registry::load()?;
1839 let mut total_audited = 0;
1840 let mut errors_found = 0;
1841
1842 for repo_path in registry.repositories.keys() {
1843 let clean = output::clean_path(repo_path);
1844
1845 if !repo_path.exists() {
1849 output::print_warning(&format!(
1850 "Skipped {clean} — the path no longer exists. `devp unlink --missing` \
1851 clears such entries."
1852 ));
1853 continue;
1854 }
1855 total_audited += 1;
1856
1857 match PerRepoConfig::load_personal_for_write(repo_path) {
1858 Ok(Some(cfg)) => {
1859 if let Err(e) = cfg.save_to_repo(repo_path) {
1860 output::print_error(&format!("Failed to write config for {clean}: {e}"));
1861 errors_found += 1;
1862 } else {
1863 output::print_success(&format!("Audited & synced config for {clean}"));
1864 }
1865 }
1866 Ok(None) => {
1867 output::print_info(&format!(
1871 "{clean} has no .devprune.json — global defaults apply."
1872 ));
1873 }
1874 Err(err_msg) => {
1875 errors_found += 1;
1876 output::print_error(&format!("Syntax/Schema Error in {clean}:"));
1877 for line in err_msg.lines() {
1878 eprintln!(" {line}");
1879 }
1880 output::print_info(&format!(
1881 "Hint: fix the syntax by hand, or run `devp config {clean} --update` to \
1882 replace the file with a valid default."
1883 ));
1884 }
1885 }
1886 }
1887
1888 if errors_found > 0 {
1889 anyhow::bail!(
1892 "Audit complete: {total_audited} repos checked, {errors_found} could not be read \
1893 or written."
1894 );
1895 }
1896 output::print_success(&format!(
1897 "Audit complete: All {total_audited} registered repositories are healthy & synced!"
1898 ));
1899
1900 Ok(())
1901}
1902
1903pub fn run_path_config(path_str: &str, force_update: bool, team: bool) -> Result<()> {
1908 let raw_path = Path::new(path_str);
1909
1910 let path = if raw_path.exists() {
1911 raw_path
1912 .canonicalize()
1913 .unwrap_or_else(|_| raw_path.to_path_buf())
1914 } else {
1915 raw_path.to_path_buf()
1916 };
1917
1918 let clean = output::clean_path(&path);
1919
1920 if !path.exists() {
1921 bail!("Path does not exist: {clean}");
1922 }
1923
1924 if !crate::scanner::is_git_repo(&path) {
1925 bail!(
1927 "`{clean}` is not a Git repository.\n \
1928 Run `git init` there first, then `devp config {clean}` again."
1929 );
1930 }
1931
1932 let mut registry = Registry::load()?;
1933 if !registry.repositories.contains_key(&path) {
1934 output::print_info(&format!(
1935 "{clean} is not yet registered with dev-prune. Registering now..."
1936 ));
1937 registry.add_repo(path.clone());
1938 registry.save()?;
1939 }
1940
1941 let name = if team {
1942 crate::constants::PROJECT_REPO_CONFIG_FILE
1943 } else {
1944 crate::constants::PER_REPO_CONFIG_FILE
1945 };
1946 let cfg_file = path.join(name);
1947
1948 if cfg_file.exists() && !force_update {
1949 output::print_header(&format!("dev-prune Per-Repo Config for {clean}"));
1950 match crate::config::RepoConfigLayers::load(&path) {
1951 Ok(layers) => {
1952 let addressed = if team {
1953 layers.project_config()
1954 } else {
1955 layers.personal_config()
1956 };
1957 println!("{}", serde_json::to_string_pretty(&addressed)?);
1958 output::print_info(&format!("File location: {name}"));
1959 print_layer_provenance(&layers);
1960 }
1961 Err(err_msg) => {
1962 output::print_error(&format!("Invalid configuration in {clean}:"));
1963 for line in err_msg.lines() {
1964 eprintln!(" {line}");
1965 }
1966 anyhow::bail!(
1969 "Run `devp config {clean} --update` to reset this file back to defaults \
1970 (your current overrides in it are discarded)."
1971 );
1972 }
1973 }
1974 } else {
1975 output::print_info(&format!("Initializing {name} for {clean}..."));
1976 if team {
1977 crate::config::write_project_starter(&path)?;
1978 } else {
1979 PerRepoConfig::default().save_to_repo(&path)?;
1980 }
1981 output::print_success(&format!("Created {name} in {clean}"));
1982 if team {
1983 output::print_info(
1984 "It starts empty on purpose: every key it names overrules \
1985 `.devprune.json`, so it should only name the ones your team decides.",
1986 );
1987 output::print_info(
1988 "`prunable.directories` is the exception — the two files' lists add \
1989 up, so naming one here never discards somebody's own.",
1990 );
1991 output::print_info(
1992 "Commit it. Unlike `.devprune.json`, this file is not added to \
1993 `.git/info/exclude` — being shared is the whole reason it exists.",
1994 );
1995 }
1996 }
1997
1998 Ok(())
1999}
2000
2001fn print_layer_provenance(layers: &crate::config::RepoConfigLayers) {
2009 if layers.project_config().is_none() || layers.personal_config().is_none() {
2010 return;
2011 }
2012 output::print_section("Effective values");
2013 for (key, value, source) in layers.rows() {
2014 println!(
2015 " {} {} {}",
2016 output::pad_display(key, 20),
2017 output::pad_display(&value, 14),
2018 source.label()
2019 );
2020 }
2021}
2022
2023fn load_workspace_config_for_write(repo_path: &Path) -> Result<PerRepoConfig> {
2029 match PerRepoConfig::load_personal_for_write(repo_path) {
2030 Ok(Some(cfg)) => Ok(cfg),
2031 Ok(None) => Ok(PerRepoConfig::default()),
2032 Err(e) => bail!(
2033 "{e}\n \
2034 Fix that file, or run `devp config {} --update` to reset it back to defaults \
2035 (your current overrides in it are discarded).",
2036 output::clean_path(repo_path)
2037 ),
2038 }
2039}
2040
2041pub fn run_daemon_toggle(path: Option<&str>, action: &str) -> Result<()> {
2043 if let Some(p) = path {
2044 let repo_path = resolve_workspace(p)?;
2045 let mut cfg = load_workspace_config_for_write(&repo_path)?;
2046 match parse_toggle(action)? {
2047 Toggle::Enable => {
2048 cfg.disable_daemon = false;
2049 cfg.save_to_repo(&repo_path)?;
2050 output::print_success(&format!(
2051 "Enabled background daemon for workspace: {}",
2052 output::clean_path(&repo_path)
2053 ));
2054 }
2055 Toggle::Disable => {
2056 cfg.disable_daemon = true;
2057 cfg.save_to_repo(&repo_path)?;
2058 output::print_success(&format!(
2059 "Disabled background daemon for workspace: {}",
2060 output::clean_path(&repo_path)
2061 ));
2062 }
2063 Toggle::Status => {
2064 let st = if cfg.disable_daemon {
2065 "Disabled for workspace"
2066 } else {
2067 "Enabled for workspace"
2068 };
2069 output::print_info(&format!(
2070 "Daemon Status ({}): {}",
2071 output::clean_path(&repo_path),
2072 st
2073 ));
2074 }
2075 }
2076 } else {
2077 match parse_toggle(action)? {
2078 Toggle::Enable => crate::commands::daemon::run_install()?,
2079 Toggle::Disable => crate::commands::daemon::run_uninstall()?,
2080 Toggle::Status => crate::commands::daemon::run_status()?,
2081 }
2082 }
2083 Ok(())
2084}
2085
2086pub fn run_hook_toggle(path: Option<&str>, action: &str, chain: bool) -> Result<()> {
2088 if let Some(p) = path {
2089 if chain {
2090 bail!(
2091 "`--chain` changes the single global `core.hooksPath`, so it has no \
2092 per-workspace form. Drop the path: `devp hook install --chain`."
2093 );
2094 }
2095 let repo_path = resolve_workspace(p)?;
2096 let mut cfg = load_workspace_config_for_write(&repo_path)?;
2097 match parse_toggle(action)? {
2098 Toggle::Enable => {
2099 cfg.disable_hooks = false;
2100 cfg.save_to_repo(&repo_path)?;
2101 output::print_success(&format!(
2102 "Enabled background Git hooks for workspace: {}",
2103 output::clean_path(&repo_path)
2104 ));
2105 }
2106 Toggle::Disable => {
2107 cfg.disable_hooks = true;
2108 cfg.save_to_repo(&repo_path)?;
2109 output::print_success(&format!(
2110 "Disabled background Git hooks for workspace: {}",
2111 output::clean_path(&repo_path)
2112 ));
2113 }
2114 Toggle::Status => {
2115 let st = if cfg.disable_hooks {
2116 "Disabled for workspace"
2117 } else {
2118 "Enabled for workspace"
2119 };
2120 output::print_info(&format!(
2121 "Git Hook Status ({}): {}",
2122 output::clean_path(&repo_path),
2123 st
2124 ));
2125 }
2126 }
2127 } else {
2128 match parse_toggle(action)? {
2129 Toggle::Enable => crate::commands::hook::run_install(chain)?,
2130 Toggle::Disable => crate::commands::hook::run_uninstall()?,
2131 Toggle::Status => crate::commands::hook::run_status()?,
2132 }
2133 }
2134 Ok(())
2135}
2136
2137#[cfg(test)]
2138mod tests {
2139 use super::*;
2140
2141 #[test]
2142 fn enable_synonyms_all_resolve_to_enable() {
2143 for word in ["enable", "install", "on", "INSTALL", "On"] {
2144 assert_eq!(parse_toggle(word).unwrap(), Toggle::Enable, "{word}");
2145 }
2146 }
2147
2148 #[test]
2149 fn disable_synonyms_all_resolve_to_disable() {
2150 for word in ["disable", "uninstall", "remove", "off", "Uninstall"] {
2151 assert_eq!(parse_toggle(word).unwrap(), Toggle::Disable, "{word}");
2152 }
2153 }
2154
2155 #[test]
2156 fn status_is_the_default_and_is_also_spellable() {
2157 for word in ["", "status", "show"] {
2158 assert_eq!(parse_toggle(word).unwrap(), Toggle::Status, "{word}");
2159 }
2160 }
2161
2162 #[test]
2163 fn a_typo_is_an_error_rather_than_a_silent_status_report() {
2164 let err = parse_toggle("enabel").unwrap_err().to_string();
2167 assert!(err.contains("enabel"), "{err}");
2168 assert!(err.contains("enable"), "{err}");
2169 }
2170
2171 #[test]
2172 fn a_workspace_toggle_refuses_to_write_over_a_broken_config() {
2173 let tmp = tempfile::TempDir::new().unwrap();
2176 let broken = r#"{ "project_name": "api", "override_idle_days": 90, }"#;
2177 std::fs::write(
2178 tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE),
2179 broken,
2180 )
2181 .unwrap();
2182
2183 let err = load_workspace_config_for_write(tmp.path())
2184 .unwrap_err()
2185 .to_string();
2186 assert!(err.contains("Syntax error"), "{err}");
2187 assert!(err.contains("--update"), "{err}");
2188
2189 let on_disk =
2191 std::fs::read_to_string(tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE))
2192 .unwrap();
2193 assert_eq!(on_disk, broken);
2194 }
2195
2196 #[test]
2197 fn a_workspace_with_no_config_yet_starts_from_the_defaults() {
2198 let tmp = tempfile::TempDir::new().unwrap();
2199 assert_eq!(
2200 load_workspace_config_for_write(tmp.path()).unwrap(),
2201 PerRepoConfig::default()
2202 );
2203 }
2204
2205 #[test]
2206 fn a_cache_cap_is_written_the_way_it_is_read_back() {
2207 let caps = parse_cache_caps("uv=10,npm=4").unwrap();
2208 assert_eq!(caps.get("uv"), Some(&10));
2209 assert_eq!(caps.get("npm"), Some(&4));
2210 let settings = Settings {
2213 cache_max_gb: parse_cache_caps("UV = 10 , npm=4").unwrap(),
2214 ..Settings::default()
2215 };
2216 let printed = SETTINGS
2217 .iter()
2218 .find(|s| s.key == "cache_max_gb")
2219 .map(|s| (s.get)(&settings))
2220 .unwrap();
2221 assert_eq!(printed, "npm=4,uv=10");
2222 assert_eq!(parse_cache_caps(&printed).unwrap(), settings.cache_max_gb);
2223 }
2224
2225 #[test]
2226 fn clearing_the_caps_is_spelled_the_way_the_getter_prints_an_empty_map() {
2227 for blank in ["", "-", "none", "(none)", "NONE"] {
2228 assert!(
2229 parse_cache_caps(blank).unwrap().is_empty(),
2230 "`{blank}` should clear every cap"
2231 );
2232 }
2233 }
2234
2235 #[test]
2236 fn a_cap_on_something_that_is_not_a_cache_is_refused_with_the_list() {
2237 let err = parse_cache_caps("venv=10").unwrap_err().to_string();
2240 assert!(err.contains("venv"), "{err}");
2241 assert!(err.contains("npm"), "the error lists what is valid: {err}");
2242 }
2243
2244 #[test]
2245 fn a_cap_has_to_be_a_whole_number_of_gibibytes() {
2246 for bad in ["uv=10.5", "uv=ten", "uv=-1", "uv="] {
2247 assert!(parse_cache_caps(bad).is_err(), "`{bad}` was accepted");
2248 }
2249 assert!(parse_cache_caps("uv").is_err());
2252 }
2253
2254 #[test]
2255 fn a_cap_of_zero_is_refused_rather_than_stored() {
2256 let err = parse_cache_caps("uv=0").unwrap_err().to_string();
2259 assert!(
2260 err.contains("`-`"),
2261 "the error names the way to clear it: {err}"
2262 );
2263 }
2264
2265 #[test]
2266 fn every_setting_round_trips_through_its_own_getter() {
2267 let mut settings = Settings::default();
2271 for setting in SETTINGS {
2272 let before = (setting.get)(&settings);
2273 let probe = match setting.kind {
2274 Kind::Toggle => if before == "true" { "false" } else { "true" }.to_string(),
2275 Kind::Number => "7".to_string(),
2278 Kind::Adapters => "cargo".to_string(),
2281 Kind::AdapterDays => "cargo=45".to_string(),
2284 Kind::CacheCaps => "cargo=10".to_string(),
2287 Kind::Choice => "hi".to_string(),
2290 };
2291 (setting.set)(&mut settings, &probe)
2292 .unwrap_or_else(|e| panic!("{} rejected `{probe}`: {e}", setting.key));
2293 assert_eq!(
2294 (setting.get)(&settings),
2295 probe,
2296 "{} reads back a different field than it writes",
2297 setting.key
2298 );
2299 }
2300 }
2301
2302 #[test]
2303 fn every_setting_is_documented_and_uniquely_named() {
2304 let mut seen = std::collections::HashSet::new();
2305 for setting in SETTINGS {
2306 assert!(seen.insert(setting.key), "duplicate key {}", setting.key);
2307 assert!(!setting.help.is_empty(), "{} has no help", setting.key);
2308 assert!(
2309 !setting.plain.is_empty(),
2310 "{} has no plain text",
2311 setting.key
2312 );
2313 assert!(
2315 setting.help.ends_with('.'),
2316 "{} help should read as a sentence",
2317 setting.key
2318 );
2319 assert!(
2320 setting.plain.ends_with('.'),
2321 "{} plain text should read as a sentence",
2322 setting.key
2323 );
2324 assert_ne!(
2327 setting.plain, setting.help,
2328 "{} says the same thing twice",
2329 setting.key
2330 );
2331 }
2332 }
2333
2334 #[test]
2335 fn the_settings_table_covers_every_field_of_settings() {
2336 let json = serde_json::to_value(Settings::default()).unwrap();
2340 let fields: Vec<String> = json.as_object().unwrap().keys().cloned().collect();
2341 for field in fields {
2342 assert!(
2343 SETTINGS.iter().any(|s| s.key == field),
2344 "`{field}` is a setting with no entry in SETTINGS, so `devp config set \
2345 {field}` cannot reach it"
2346 );
2347 }
2348 }
2349
2350 #[test]
2351 fn a_rejected_value_leaves_the_previous_one_in_place() {
2352 let mut settings = Settings::default();
2353 assert!((find_setting("scan_depth").unwrap().set)(&mut settings, "0").is_err());
2354 assert_eq!(settings.scan_depth, Settings::default().scan_depth);
2355
2356 assert!((find_setting("command_timeout_secs").unwrap().set)(&mut settings, "0").is_err());
2357 assert!((find_setting("check_interval_days").unwrap().set)(&mut settings, "0").is_err());
2358 assert!(
2359 (find_setting("update_check_interval_days").unwrap().set)(&mut settings, "0").is_err()
2360 );
2361 }
2362
2363 #[test]
2364 fn booleans_accept_the_words_people_actually_type() {
2365 assert!(parse_bool("k", "yes").unwrap());
2366 assert!(parse_bool("k", "ON").unwrap());
2367 assert!(!parse_bool("k", "0").unwrap());
2368 assert!(parse_bool("k", "maybe").is_err());
2369 }
2370
2371 #[test]
2372 fn an_unknown_key_lists_the_ones_that_exist() {
2373 let err = match find_setting("idel_days") {
2374 Ok(_) => panic!("`idel_days` is not a setting"),
2375 Err(e) => e.to_string(),
2376 };
2377 assert!(err.contains("idle_days"), "{err}");
2378 }
2379
2380 #[test]
2381 fn a_path_is_never_mistaken_for_an_action() {
2382 assert!(!is_toggle_word("~/Code/my-repo"));
2384 assert!(!is_toggle_word("."));
2385 assert!(!is_toggle_word(""));
2386 assert!(is_toggle_word("install"));
2387 }
2388}