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: "enable_dotnet_build",
291 label: ".NET bin/ and obj/ output",
292 why: "NuGet's restore writes `obj/project.assets.json` naming the project it \
293 restored, so only output `dotnet build` wrote is claimed — a committed \
294 `bin/` holding anything of yours is left alone.",
295 value: "true",
296 cautious: false,
297 taken: None,
298 },
299 Recommendation {
300 key: "enable_godot",
301 label: "Godot imported-resource caches",
302 why: "`.godot/` (and `.import/` on Godot 3) holds the editor's import cache for \
303 every asset, often larger than the assets themselves, and the editor \
304 rebuilds it the next time it opens the project.",
305 value: "true",
306 cautious: false,
307 taken: None,
308 },
309 Recommendation {
310 key: "enable_unity",
311 label: "Unity imported-asset database",
312 why: "`Library/` holds the editor's imported copy of every asset and is routinely \
313 larger than the assets themselves; the editor rebuilds it on the next open, \
314 and a project the editor has open right now is refused outright.",
315 value: "true",
316 cautious: false,
317 taken: None,
318 },
319 Recommendation {
320 key: "enable_unreal",
321 label: "Unreal derived data and intermediates",
322 why: "`DerivedDataCache/` and `Intermediate/` are compiled shaders and build \
323 output the editor and UnrealBuildTool regenerate; `Saved/` and `Binaries/` \
324 are never touched.",
325 value: "true",
326 cautious: false,
327 taken: None,
328 },
329 Recommendation {
330 key: "enable_defold",
331 label: "Defold build output",
332 why: "`build/` beside `game.project` is the compiled project, and the editor or \
333 bob rebuilds it in full on the next build.",
334 value: "true",
335 cautious: false,
336 taken: None,
337 },
338 Recommendation {
339 key: "enable_cocos",
340 label: "Cocos Creator imported-asset caches",
341 why: "`library/` and `temp/` are the editor's import caches, rebuilt from the \
342 committed sources on the next open; a plain Node `package.json` without \
343 Creator's own keys is never claimed.",
344 value: "true",
345 cautious: false,
346 taken: None,
347 },
348 Recommendation {
349 key: "enable_zig",
350 label: "Zig build caches and install output",
351 why: "`.zig-cache/` (or the older `zig-cache/`) and `zig-out/` are written only \
352 by `zig build` and come back in full on the next one; `build.zig` is \
353 checked for its `pub fn build` entry point first.",
354 value: "true",
355 cautious: false,
356 taken: None,
357 },
358 Recommendation {
359 key: "enable_stack",
360 label: "Haskell Stack build tree",
361 why: "`.stack-work/` is compiled output that `stack build` regenerates against \
362 the snapshot `stack.yaml` pins; a project file naming no snapshot is \
363 refused.",
364 value: "true",
365 cautious: false,
366 taken: None,
367 },
368 Recommendation {
369 key: "enable_cabal",
370 label: "Haskell Cabal build tree",
371 why: "`dist-newstyle/` is compiled output that `cabal build` regenerates from \
372 the packages `cabal.project` declares.",
373 value: "true",
374 cautious: false,
375 taken: None,
376 },
377 Recommendation {
378 key: "enable_sbt",
379 label: "sbt build trees",
380 why: "`target/` and `project/target/` beside `build.sbt` are compiled output \
381 that `sbt compile` regenerates; sub-project targets deeper in a \
382 multi-module build are left alone.",
383 value: "true",
384 cautious: false,
385 taken: None,
386 },
387 Recommendation {
388 key: "cache_max_gb",
389 label: "Cache size ceilings",
390 why: "Every other suggestion here is about one project's folders. This one is about the \
391 download caches all of them share, which only ever grow — npm's on the machine this \
392 was written on had passed 10 GiB while every project it served fit in a fraction of \
393 that. `default=10` is one ceiling for every manager at once. Crossing it deletes \
394 nothing: `devp caches` says which manager is over, and `devp caches clear \
395 --over-cap all` is still typed by hand. Name a manager to give it a figure of its \
396 own — `devp config set cache_max_gb default=10,npm=4`.",
397 value: crate::constants::RECOMMENDED_CACHE_CAP,
398 cautious: false,
399 taken: Some(|current| parse_cache_caps(current).is_ok_and(|caps| !caps.is_empty())),
401 },
402 Recommendation {
403 key: "allow_manifest_rewrite",
404 label: "Let cargo and go tidy up",
405 why: "Cautious, not risky. The commands that restore a Rust or Go project can also update \
406 `Cargo.lock` or `go.mod` — files Git tracks. Nothing is lost and nothing is deleted, \
407 but the next `git status` may show a change you did not make by hand. Turn it on if \
408 that is fine; leave it off if a clean working tree matters more than a fully \
409 automatic restore.",
410 value: "true",
411 cautious: true,
412 taken: None,
413 },
414];
415
416const SETTINGS: &[Setting] = &[
418 Setting {
419 key: "language",
420 category: Category::Presentation,
421 since: "1.10.0",
422 kind: Kind::Choice,
423 help: "Language for dev-prune's own headings and summary lines. `--json`, exit codes, flag names and config keys stay English in every language.",
424 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.",
425 get: |s| s.language.clone(),
426 set: |s, v| {
427 let code = v.trim();
428 let Some(meta) = i18n::language(code) else {
429 bail!(
430 "unknown language `{code}` — available: {}",
431 i18n::catalogue_line()
432 );
433 };
434 s.language = meta.code.clone();
435 Ok(())
436 },
437 },
438 Setting {
439 key: "recommendations",
440 category: Category::Presentation,
441 since: "1.22.0",
442 kind: Kind::Toggle,
443 help: "End a dry run with the switched-off opt-in adapters that would have found something. Changes nothing about what is scanned or deleted.",
444 plain: "Whether a dry run points out build-tool and game-engine directories it noticed but is not allowed to touch, with the command that would let it. Turn it off once you have made your choices and the reminder has become noise.",
445 get: |s| s.recommendations.to_string(),
446 set: |s, v| {
447 s.recommendations = parse_bool("recommendations", v)?;
448 Ok(())
449 },
450 },
451 Setting {
452 key: "idle_days",
453 category: Category::Scope,
454 since: "1.0.0",
455 kind: Kind::Number,
456 help: "Days a repository must sit untouched before it is eligible for pruning.",
457 plain: "How long a project has to sit untouched before dev-prune will clean it. Something you worked on yesterday is never touched.",
458 get: |s| s.idle_days.to_string(),
459 set: |s, v| {
460 s.idle_days = v
461 .parse()
462 .map_err(|_| anyhow::anyhow!("idle_days must be a whole number of days"))?;
463 Ok(())
464 },
465 },
466 Setting {
467 key: "min_size_mb",
468 category: Category::Scope,
469 since: "1.0.0",
470 kind: Kind::Number,
471 help: "Smallest bloat directory worth deleting, in MiB. 0 removes the floor.",
472 plain: "Ignore small folders. Deleting a 2 MB folder is not worth the download to get it back.",
473 get: |s| s.min_size_mb.to_string(),
474 set: |s, v| {
475 s.min_size_mb = v.parse().map_err(|_| {
476 anyhow::anyhow!("min_size_mb must be a whole number of MiB (0 disables the floor)")
477 })?;
478 Ok(())
479 },
480 },
481 Setting {
482 key: "scan_depth",
483 category: Category::Scope,
484 since: "1.0.0",
485 kind: Kind::Number,
486 help: "How many directory levels below a repo root project discovery descends.",
487 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.",
488 get: |s| s.scan_depth.to_string(),
489 set: |s, v| {
490 let depth: usize = v
491 .parse()
492 .map_err(|_| anyhow::anyhow!("scan_depth must be a positive integer"))?;
493 if depth == 0 {
497 bail!("scan_depth must be at least 1 — 0 would find no projects at all.");
498 }
499 if depth > crate::constants::MAX_SCAN_DEPTH_LIMIT {
500 bail!(
501 "scan_depth must be at most {} — deeper walks stall on generated trees.",
502 crate::constants::MAX_SCAN_DEPTH_LIMIT
503 );
504 }
505 s.scan_depth = depth;
506 Ok(())
507 },
508 },
509 Setting {
510 key: "require_confirmation",
511 category: Category::Safety,
512 since: "1.0.0",
513 kind: Kind::Toggle,
514 help: "Ask before deleting anything. Turning this off makes every run unattended.",
515 plain: "Whether dev-prune asks \"delete these?\" before it deletes. Leave this on unless you want it to run silently while you are away.",
516 get: |s| s.require_confirmation.to_string(),
517 set: |s, v| {
518 s.require_confirmation = parse_bool("require_confirmation", v)?;
519 Ok(())
520 },
521 },
522 Setting {
523 key: "allow_manifest_rewrite",
524 category: Category::Safety,
525 since: "1.0.0",
526 kind: Kind::Toggle,
527 help: "Let cargo and go run the sync command that rewrites tracked manifests.",
528 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`.",
529 get: |s| s.allow_manifest_rewrite.to_string(),
530 set: |s, v| {
531 s.allow_manifest_rewrite = parse_bool("allow_manifest_rewrite", v)?;
532 Ok(())
533 },
534 },
535 Setting {
536 key: "command_timeout_secs",
537 category: Category::Safety,
538 since: "1.0.0",
539 kind: Kind::Number,
540 help: "How long one package-manager command may run before it is killed — the lockfile check and `devp restore`, never a recompile.",
541 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.",
542 get: |s| s.command_timeout_secs.to_string(),
543 set: |s, v| {
544 let secs: u64 = v
545 .parse()
546 .map_err(|_| anyhow::anyhow!("command_timeout_secs must be a positive integer"))?;
547 if secs == 0 {
551 bail!(
552 "command_timeout_secs must be at least 1 — 0 would kill every command \
553 the instant it starts."
554 );
555 }
556 s.command_timeout_secs = secs;
557 Ok(())
558 },
559 },
560 Setting {
561 key: "auto_setup",
562 category: Category::Unattended,
563 since: "1.0.0",
564 kind: Kind::Toggle,
565 help: "Install missing integrations by itself, once per installed version.",
566 plain: "Whether dev-prune finishes setting itself up on its own instead of making you run `devp setup`.",
567 get: |s| s.auto_setup.to_string(),
568 set: |s, v| {
569 s.auto_setup = parse_bool("auto_setup", v)?;
570 Ok(())
571 },
572 },
573 Setting {
574 key: "auto_config",
575 category: Category::Unattended,
576 since: "1.3.0",
577 kind: Kind::Toggle,
578 help: "Write a default .devprune.json into repositories that link/init register.",
579 plain: "Drops a small settings file into each repository you register, so you can give that one project different rules later.",
580 get: |s| s.auto_config.to_string(),
581 set: |s, v| {
582 s.auto_config = parse_bool("auto_config", v)?;
583 Ok(())
584 },
585 },
586 Setting {
587 key: "auto_daemon",
588 category: Category::Unattended,
589 since: "1.0.0",
590 kind: Kind::Toggle,
591 help: "Register the OS scheduler so passes run without being remembered.",
592 plain: "Lets your operating system run dev-prune on a schedule, so you never have to remember to.",
593 get: |s| s.auto_daemon.to_string(),
594 set: |s, v| {
595 s.auto_daemon = parse_bool("auto_daemon", v)?;
596 Ok(())
597 },
598 },
599 Setting {
600 key: "check_interval_days",
601 category: Category::Unattended,
602 since: "1.0.0",
603 kind: Kind::Number,
604 help: "Days between scheduled background passes.",
605 plain: "How often that scheduled cleanup runs.",
606 get: |s| s.check_interval_days.to_string(),
607 set: |s, v| {
608 let days: u64 = v
609 .parse()
610 .map_err(|_| anyhow::anyhow!("check_interval_days must be a positive integer"))?;
611 if days == 0 {
613 bail!("check_interval_days must be at least 1.");
614 }
615 s.check_interval_days = days;
616 Ok(())
617 },
618 },
619 Setting {
620 key: "auto_discover",
621 category: Category::Unattended,
622 since: "1.14.0",
623 kind: Kind::Toggle,
624 help: "Let the scheduled pass register repositories no Git hook could see — unzipped, copied or restored ones.",
625 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.",
626 get: |s| s.auto_discover.to_string(),
627 set: |s, v| {
628 s.auto_discover = parse_bool("auto_discover", v)?;
629 Ok(())
630 },
631 },
632 Setting {
633 key: "auto_hooks",
634 category: Category::Unattended,
635 since: "1.0.0",
636 kind: Kind::Toggle,
637 help: "Install the Git hooks that register a repository when you clone, commit or merge in it.",
638 plain: "Adds repositories as Git creates them. Anything Git did not create — a copied or unzipped project — is left to `auto_discover`.",
639 get: |s| s.auto_hooks.to_string(),
640 set: |s, v| {
641 s.auto_hooks = parse_bool("auto_hooks", v)?;
642 Ok(())
643 },
644 },
645 Setting {
646 key: "auto_hooks_chain",
647 category: Category::Unattended,
648 since: "1.0.0",
649 kind: Kind::Toggle,
650 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.",
651 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.",
652 get: |s| s.auto_hooks_chain.to_string(),
653 set: |s, v| {
654 s.auto_hooks_chain = parse_bool("auto_hooks_chain", v)?;
655 Ok(())
656 },
657 },
658 Setting {
659 key: "update_check",
660 category: Category::Updates,
661 since: "1.0.0",
662 kind: Kind::Toggle,
663 help: "Ask GitHub for the latest release from time to time. Sends nothing but the request.",
664 plain: "Whether dev-prune checks GitHub now and then to see if there is a newer version. It sends no information about you.",
665 get: |s| s.update_check.to_string(),
666 set: |s, v| {
667 s.update_check = parse_bool("update_check", v)?;
668 Ok(())
669 },
670 },
671 Setting {
672 key: "update_check_interval_days",
673 category: Category::Updates,
674 since: "1.0.0",
675 kind: Kind::Number,
676 help: "Days between automatic release checks.",
677 plain: "How often that version check happens.",
678 get: |s| s.update_check_interval_days.to_string(),
679 set: |s, v| {
680 let days: i64 = v.parse().map_err(|_| {
681 anyhow::anyhow!("update_check_interval_days must be a positive integer")
682 })?;
683 if days < 1 {
684 bail!("update_check_interval_days must be at least 1.");
685 }
686 s.update_check_interval_days = days;
687 Ok(())
688 },
689 },
690 Setting {
691 key: "update_check_timeout_secs",
692 category: Category::Updates,
693 since: "1.0.0",
694 kind: Kind::Number,
695 help: "Seconds the release check waits for GitHub. Raise it behind a slow proxy.",
696 plain: "How long the version check waits before giving up. Raise it if you are behind a slow proxy.",
697 get: |s| s.update_check_timeout_secs.to_string(),
698 set: |s, v| {
699 let secs: u64 = v.parse().map_err(|_| {
700 anyhow::anyhow!("update_check_timeout_secs must be a positive integer")
701 })?;
702 if secs == 0 {
703 bail!("update_check_timeout_secs must be at least 1.");
704 }
705 s.update_check_timeout_secs = secs;
706 Ok(())
707 },
708 },
709 Setting {
710 key: "enable_cargo",
711 category: Category::BuildTrees,
712 since: "1.5.0",
713 kind: Kind::Toggle,
714 help: "Turn on the opt-in Cargo adapter (target/ comes back by recompiling, not downloading).",
715 plain: "Clean Rust build folders too. These come back by recompiling, which takes minutes rather than a download — so it is off by default.",
716 get: |s| s.enable_cargo.to_string(),
717 set: |s, v| {
718 s.enable_cargo = parse_bool("enable_cargo", v)?;
719 Ok(())
720 },
721 },
722 Setting {
723 key: "enable_gradle",
724 category: Category::BuildTrees,
725 since: "1.3.0",
726 kind: Kind::Toggle,
727 help: "Turn on the opt-in Gradle adapter (build/ and .gradle/ come back by recompiling).",
728 plain: "Clean Android and Java build folders too. Same trade: they come back by recompiling, not downloading.",
729 get: |s| s.enable_gradle.to_string(),
730 set: |s, v| {
731 s.enable_gradle = parse_bool("enable_gradle", v)?;
732 Ok(())
733 },
734 },
735 Setting {
736 key: "enable_maven",
737 category: Category::BuildTrees,
738 since: "1.3.0",
739 kind: Kind::Toggle,
740 help: "Turn on the opt-in Maven adapter (target/ comes back by recompiling).",
741 plain: "Clean Maven build folders too. They come back by recompiling.",
742 get: |s| s.enable_maven.to_string(),
743 set: |s, v| {
744 s.enable_maven = parse_bool("enable_maven", v)?;
745 Ok(())
746 },
747 },
748 Setting {
749 key: "enable_swift",
750 category: Category::BuildTrees,
751 since: "1.4.0",
752 kind: Kind::Toggle,
753 help: "Turn on the opt-in SwiftPM adapter (.build/ comes back by recompiling).",
754 plain: "Clean Swift build folders too. They come back by recompiling.",
755 get: |s| s.enable_swift.to_string(),
756 set: |s, v| {
757 s.enable_swift = parse_bool("enable_swift", v)?;
758 Ok(())
759 },
760 },
761 Setting {
762 key: "enable_dart",
763 category: Category::BuildTrees,
764 since: "1.6.0",
765 kind: Kind::Toggle,
766 help: "Turn on the opt-in Dart/Flutter adapter (.dart_tool/ holds build caches).",
767 plain: "Clean Dart and Flutter caches too. Part comes back instantly, part by recompiling.",
768 get: |s| s.enable_dart.to_string(),
769 set: |s, v| {
770 s.enable_dart = parse_bool("enable_dart", v)?;
771 Ok(())
772 },
773 },
774 Setting {
775 key: "enable_mix_build",
776 category: Category::BuildTrees,
777 since: "1.7.0",
778 kind: Kind::Toggle,
779 help: "Turn on the opt-in Elixir Mix build-tree adapter (_build/ comes back by recompiling).",
780 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.",
781 get: |s| s.enable_mix_build.to_string(),
782 set: |s, v| {
783 s.enable_mix_build = parse_bool("enable_mix_build", v)?;
784 Ok(())
785 },
786 },
787 Setting {
788 key: "enable_vcpkg",
789 category: Category::BuildTrees,
790 since: "1.8.0",
791 kind: Kind::Toggle,
792 help: "Turn on the opt-in vcpkg adapter (vcpkg_installed/ comes back by recompiling).",
793 plain: "Clean C and C++ vcpkg_installed/ folders too. They come back by recompiling.",
794 get: |s| s.enable_vcpkg.to_string(),
795 set: |s, v| {
796 s.enable_vcpkg = parse_bool("enable_vcpkg", v)?;
797 Ok(())
798 },
799 },
800 Setting {
801 key: "enable_cmake_build",
802 category: Category::BuildTrees,
803 since: "1.8.0",
804 kind: Kind::Toggle,
805 help: "Turn on the opt-in CMake adapter (build trees proven by their CMakeCache.txt).",
806 plain: "Clean C and C++ build folders CMake configured. A `build/` you made by hand is \
807 never touched.",
808 get: |s| s.enable_cmake_build.to_string(),
809 set: |s, v| {
810 s.enable_cmake_build = parse_bool("enable_cmake_build", v)?;
811 Ok(())
812 },
813 },
814 Setting {
815 key: "enable_dotnet_build",
816 category: Category::BuildTrees,
817 since: "1.18.0",
818 kind: Kind::Toggle,
819 help: "Turn on the opt-in .NET adapter (bin/ and obj/ proven by NuGet's project.assets.json).",
820 plain: "Clean .NET bin/ and obj/ folders MSBuild wrote. A `bin/` holding anything of \
821 yours is never touched.",
822 get: |s| s.enable_dotnet_build.to_string(),
823 set: |s, v| {
824 s.enable_dotnet_build = parse_bool("enable_dotnet_build", v)?;
825 Ok(())
826 },
827 },
828 Setting {
829 key: "enable_godot",
830 category: Category::BuildTrees,
831 since: "1.22.0",
832 kind: Kind::Toggle,
833 help: "Turn on the opt-in Godot adapter (.godot/ and .import/ come back by re-importing).",
834 plain: "Clean Godot's imported-resource caches too. The editor rebuilds them from \
835 your assets the next time it opens the project.",
836 get: |s| s.enable_godot.to_string(),
837 set: |s, v| {
838 s.enable_godot = parse_bool("enable_godot", v)?;
839 Ok(())
840 },
841 },
842 Setting {
843 key: "enable_unity",
844 category: Category::BuildTrees,
845 since: "1.22.0",
846 kind: Kind::Toggle,
847 help: "Turn on the opt-in Unity adapter (Library/ and Temp/ come back by re-importing).",
848 plain: "Clean Unity's imported-asset database too. The editor rebuilds it from your \
849 assets the next time it opens the project, and an open editor is refused.",
850 get: |s| s.enable_unity.to_string(),
851 set: |s, v| {
852 s.enable_unity = parse_bool("enable_unity", v)?;
853 Ok(())
854 },
855 },
856 Setting {
857 key: "enable_unreal",
858 category: Category::BuildTrees,
859 since: "1.22.0",
860 kind: Kind::Toggle,
861 help: "Turn on the opt-in Unreal adapter (DerivedDataCache/ and Intermediate/ come back by recompiling).",
862 plain: "Clean Unreal's derived data and intermediates too. Saved/ and Binaries/ are \
863 never touched.",
864 get: |s| s.enable_unreal.to_string(),
865 set: |s, v| {
866 s.enable_unreal = parse_bool("enable_unreal", v)?;
867 Ok(())
868 },
869 },
870 Setting {
871 key: "enable_defold",
872 category: Category::BuildTrees,
873 since: "1.22.0",
874 kind: Kind::Toggle,
875 help: "Turn on the opt-in Defold adapter (build/ comes back on the next build).",
876 plain: "Clean Defold's build output too. The editor or bob rebuilds it in full on \
877 the next build.",
878 get: |s| s.enable_defold.to_string(),
879 set: |s, v| {
880 s.enable_defold = parse_bool("enable_defold", v)?;
881 Ok(())
882 },
883 },
884 Setting {
885 key: "enable_cocos",
886 category: Category::BuildTrees,
887 since: "1.22.0",
888 kind: Kind::Toggle,
889 help: "Turn on the opt-in Cocos Creator adapter (library/ and temp/ come back by re-importing).",
890 plain: "Clean Cocos Creator's imported-asset caches too. The editor rebuilds them \
891 from your assets the next time it opens the project.",
892 get: |s| s.enable_cocos.to_string(),
893 set: |s, v| {
894 s.enable_cocos = parse_bool("enable_cocos", v)?;
895 Ok(())
896 },
897 },
898 Setting {
899 key: "enable_zig",
900 category: Category::BuildTrees,
901 since: "1.22.0",
902 kind: Kind::Toggle,
903 help: "Turn on the opt-in Zig adapter (.zig-cache/, zig-cache/ and zig-out/ come back on the next build).",
904 plain: "Clean Zig's build caches and install output too. `zig build` writes all \
905 three directories back the next time it runs.",
906 get: |s| s.enable_zig.to_string(),
907 set: |s, v| {
908 s.enable_zig = parse_bool("enable_zig", v)?;
909 Ok(())
910 },
911 },
912 Setting {
913 key: "enable_stack",
914 category: Category::BuildTrees,
915 since: "1.22.0",
916 kind: Kind::Toggle,
917 help: "Turn on the opt-in Haskell Stack adapter (.stack-work/ comes back by recompiling).",
918 plain: "Clean Stack's build tree too. `stack build` recompiles it against the \
919 snapshot `stack.yaml` pins.",
920 get: |s| s.enable_stack.to_string(),
921 set: |s, v| {
922 s.enable_stack = parse_bool("enable_stack", v)?;
923 Ok(())
924 },
925 },
926 Setting {
927 key: "enable_cabal",
928 category: Category::BuildTrees,
929 since: "1.22.0",
930 kind: Kind::Toggle,
931 help: "Turn on the opt-in Haskell Cabal adapter (dist-newstyle/ comes back by recompiling).",
932 plain: "Clean Cabal's build tree too. `cabal build` recompiles it from the \
933 packages `cabal.project` declares.",
934 get: |s| s.enable_cabal.to_string(),
935 set: |s, v| {
936 s.enable_cabal = parse_bool("enable_cabal", v)?;
937 Ok(())
938 },
939 },
940 Setting {
941 key: "enable_sbt",
942 category: Category::BuildTrees,
943 since: "1.22.0",
944 kind: Kind::Toggle,
945 help: "Turn on the opt-in sbt adapter (target/ and project/target/ come back by recompiling).",
946 plain: "Clean sbt's build trees too. `sbt compile` regenerates both the project's \
947 target/ and the build definition's project/target/.",
948 get: |s| s.enable_sbt.to_string(),
949 set: |s, v| {
950 s.enable_sbt = parse_bool("enable_sbt", v)?;
951 Ok(())
952 },
953 },
954 Setting {
955 key: "build_idle_days",
956 category: Category::BuildTrees,
957 since: "1.3.0",
958 kind: Kind::Number,
959 help: "Idle days before the opt-in adapters' build trees are pruned. Applied as max(this, idle_days).",
960 plain: "A longer wait, used only for the build folders above, because getting those back costs a recompile rather than a download.",
961 get: |s| s.build_idle_days.to_string(),
962 set: |s, v| {
963 let days: u64 = v
964 .parse()
965 .map_err(|_| anyhow::anyhow!("build_idle_days must be a non-negative integer"))?;
966 s.build_idle_days = days;
967 Ok(())
968 },
969 },
970 Setting {
971 key: "auto_update",
972 category: Category::Updates,
973 since: "1.3.0",
974 kind: Kind::Toggle,
975 help: "Install a newer release by itself at the end of a prune pass. On by default.",
976 plain: "Whether dev-prune installs its own updates after a cleanup. The download is checked against its published fingerprint first.",
977 get: |s| s.auto_update.to_string(),
978 set: |s, v| {
979 s.auto_update = parse_bool("auto_update", v)?;
980 Ok(())
981 },
982 },
983 Setting {
984 key: "version_lock",
985 category: Category::Updates,
986 since: "1.8.0",
987 kind: Kind::Toggle,
988 help: "Pin this copy to the version it is. Overrides auto_update, `devp update \
989 --install`, `devp install --channel` and the install scripts.",
990 plain: "Stay on exactly this version. Nothing dev-prune does replaces the binary \
991 while this is on -- not the automatic update, not a re-run of the install \
992 one-liner.",
993 get: |s| s.version_lock.to_string(),
994 set: |s, v| {
995 s.version_lock = parse_bool("version_lock", v)?;
996 Ok(())
997 },
998 },
999 Setting {
1000 key: "disabled_adapters",
1001 category: Category::Scope,
1002 since: "1.4.0",
1003 kind: Kind::Adapters,
1004 help: "Adapters to leave alone entirely, by name. Empty means every one of them is active.",
1005 plain: "Ecosystems to ignore completely — as if you did not have them installed at all.",
1006 get: |s| {
1007 if s.disabled_adapters.is_empty() {
1008 "(none)".to_string()
1009 } else {
1010 s.disabled_adapters.join(",")
1011 }
1012 },
1013 set: |s, v| {
1014 s.disabled_adapters = parse_adapter_list(v)?;
1015 Ok(())
1016 },
1017 },
1018 Setting {
1019 key: "adapter_idle_days",
1020 category: Category::Scope,
1021 since: "1.5.0",
1022 kind: Kind::AdapterDays,
1023 help: "Per-adapter idle windows, as `cargo=60,npm=30`. Each one can only raise its own wait.",
1024 plain: "A different waiting period for one ecosystem. Useful when your Rust projects should wait longer than your Node ones.",
1025 get: |s| {
1026 if s.adapter_idle_days.is_empty() {
1027 "(none)".to_string()
1028 } else {
1029 s.adapter_idle_days
1030 .iter()
1031 .map(|(name, days)| format!("{name}={days}"))
1032 .collect::<Vec<_>>()
1033 .join(",")
1034 }
1035 },
1036 set: |s, v| {
1037 s.adapter_idle_days = parse_adapter_days(v)?;
1038 Ok(())
1039 },
1040 },
1041 Setting {
1042 key: "cache_max_gb",
1043 category: Category::Caches,
1044 since: "1.8.0",
1045 kind: Kind::CacheCaps,
1046 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`.",
1047 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.",
1048 get: |s| {
1049 if s.cache_max_gb.is_empty() {
1050 "(none)".to_string()
1051 } else {
1052 s.cache_max_gb
1053 .iter()
1054 .map(|(name, gb)| format!("{name}={gb}"))
1055 .collect::<Vec<_>>()
1056 .join(",")
1057 }
1058 },
1059 set: |s, v| {
1060 s.cache_max_gb = parse_cache_caps(v)?;
1061 Ok(())
1062 },
1063 },
1064];
1065
1066fn parse_cache_caps(value: &str) -> Result<std::collections::BTreeMap<String, u64>> {
1087 let trimmed = value.trim();
1088 if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
1089 return Ok(std::collections::BTreeMap::new());
1090 }
1091
1092 let mut caps = std::collections::BTreeMap::new();
1093 for raw in trimmed.split(',') {
1094 let entry = raw.trim();
1095 if entry.is_empty() {
1096 continue;
1097 }
1098 let Some((name, value)) = entry.split_once('=') else {
1099 bail!("`{entry}` must be written as `<manager>=<gib>`, for example `uv=10`.");
1100 };
1101 let name = name.trim().to_lowercase();
1102 if name != crate::constants::CACHE_CAP_DEFAULT_KEY
1103 && !crate::commands::caches::is_cache_manager(&name)
1104 {
1105 bail!(
1106 "`{name}` is not a manager dev-prune knows a cache for. Valid names: {}. \
1107 `{}` caps every manager that is not named separately.",
1108 crate::commands::caches::known_managers().join(", "),
1109 crate::constants::CACHE_CAP_DEFAULT_KEY
1110 );
1111 }
1112 let parsed: u64 = value.trim().parse().map_err(|_| {
1113 anyhow::anyhow!(
1114 "`{name}` needs a whole number of gibibytes, not `{}`.",
1115 value.trim()
1116 )
1117 })?;
1118 if parsed == 0 {
1119 bail!(
1120 "`{name}=0` would call the cache too big the moment it exists. Use `-` to clear the caps instead."
1121 );
1122 }
1123 caps.insert(name, parsed);
1124 }
1125 Ok(caps)
1126}
1127
1128fn parse_adapter_days(value: &str) -> Result<std::collections::BTreeMap<String, u64>> {
1133 let trimmed = value.trim();
1134 if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
1135 return Ok(std::collections::BTreeMap::new());
1136 }
1137
1138 let mut days = std::collections::BTreeMap::new();
1139 for raw in trimmed.split(',') {
1140 let entry = raw.trim();
1141 if entry.is_empty() {
1142 continue;
1143 }
1144 let Some((name, value)) = entry.split_once('=') else {
1145 bail!("`{entry}` must be written as `<adapter>=<days>`, for example `cargo=60`.");
1146 };
1147 let name = name.trim().to_lowercase();
1148 if !crate::adapters::is_adapter_name(&name) {
1149 bail!(
1150 "`{name}` is not an adapter. Valid names: {}",
1151 crate::adapters::all_adapter_names().join(", ")
1152 );
1153 }
1154 let parsed: u64 = value.trim().parse().map_err(|_| {
1155 anyhow::anyhow!(
1156 "`{name}` needs a whole number of days, not `{}`.",
1157 value.trim()
1158 )
1159 })?;
1160 days.insert(name, parsed);
1161 }
1162 Ok(days)
1163}
1164
1165fn parse_adapter_list(value: &str) -> Result<Vec<String>> {
1166 let trimmed = value.trim();
1167 if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
1170 return Ok(Vec::new());
1171 }
1172
1173 let mut names: Vec<String> = Vec::new();
1174 for raw in trimmed.split(',') {
1175 let name = raw.trim().to_lowercase();
1176 if name.is_empty() {
1177 continue;
1178 }
1179 if !crate::adapters::is_adapter_name(&name) {
1180 bail!(
1181 "`{name}` is not an adapter. Valid names: {}",
1182 crate::adapters::all_adapter_names().join(", ")
1183 );
1184 }
1185 if !names.contains(&name) {
1186 names.push(name);
1187 }
1188 }
1189 Ok(names)
1190}
1191
1192fn parse_bool(key: &str, value: &str) -> Result<bool> {
1193 match value.trim().to_lowercase().as_str() {
1194 "true" | "yes" | "y" | "on" | "1" => Ok(true),
1195 "false" | "no" | "n" | "off" | "0" => Ok(false),
1196 _ => bail!("{key} must be true or false"),
1197 }
1198}
1199
1200pub fn invalid_settings(settings: &Settings) -> Vec<(&'static str, String)> {
1211 SETTINGS
1212 .iter()
1213 .filter_map(|setting| {
1214 let mut probe = settings.clone();
1215 (setting.set)(&mut probe, &(setting.get)(settings))
1216 .err()
1217 .map(|e| (setting.key, e.to_string()))
1218 })
1219 .collect()
1220}
1221
1222pub fn setting_count() -> usize {
1224 SETTINGS.len()
1225}
1226
1227fn save_settings_edits<'a>(edits: impl Iterator<Item = (&'a str, &'a str)>) -> Result<()> {
1236 let mut fresh = Registry::load()?;
1237 for (key, value) in edits {
1238 (find_setting(key)?.set)(&mut fresh.settings, value)?;
1239 }
1240 fresh.save()
1241}
1242
1243fn find_setting(key: &str) -> Result<&'static Setting> {
1244 SETTINGS
1245 .iter()
1246 .find(|s| s.key == key)
1247 .ok_or_else(|| anyhow::anyhow!("Unknown config key: {key}. Valid keys: {}", valid_keys()))
1248}
1249
1250fn valid_keys() -> String {
1251 SETTINGS
1252 .iter()
1253 .map(|s| s.key)
1254 .collect::<Vec<_>>()
1255 .join(", ")
1256}
1257
1258#[derive(Debug, PartialEq, Eq)]
1260pub enum Toggle {
1261 Enable,
1262 Disable,
1263 Status,
1264}
1265
1266pub fn parse_toggle(action: &str) -> Result<Toggle> {
1276 match action.to_lowercase().as_str() {
1277 "enable" | "install" | "on" => Ok(Toggle::Enable),
1278 "disable" | "uninstall" | "remove" | "off" => Ok(Toggle::Disable),
1279 "" | "status" | "show" => Ok(Toggle::Status),
1280 other => bail!(
1281 "Unknown action `{other}`. Expected `enable`, `disable` or `status` \
1282 (`install` / `uninstall` / `on` / `off` also work)."
1283 ),
1284 }
1285}
1286
1287pub fn is_toggle_word(word: &str) -> bool {
1293 parse_toggle(word).is_ok() && !word.is_empty()
1294}
1295
1296fn resolve_workspace(path: &str) -> Result<std::path::PathBuf> {
1303 let raw = Path::new(path);
1304 if !raw.is_dir() {
1305 bail!(
1306 "`{path}` is neither an action nor an existing directory.\n\
1307 Expected `enable`, `disable` or `status`, or a path to a repository."
1308 );
1309 }
1310 Ok(raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf()))
1311}
1312
1313pub fn run_get(key: &str) -> Result<()> {
1315 let registry = Registry::load()?;
1316 let setting = find_setting(key)?;
1317 println!("{key} = {}", (setting.get)(®istry.settings));
1318 Ok(())
1319}
1320
1321pub fn run_set(key: &str, value: &str) -> Result<()> {
1323 let mut registry = Registry::load()?;
1324 let setting = find_setting(key)?;
1325 (setting.set)(&mut registry.settings, value)?;
1326 registry.save()?;
1327
1328 output::print_success(&format!("{key} = {}", (setting.get)(®istry.settings)));
1331
1332 if key == "language"
1336 && let Some(meta) = i18n::language(®istry.settings.language)
1337 && !meta.reviewed
1338 {
1339 output::print_info(&format!(
1340 "No native speaker has reviewed the {} translation yet. Corrections are welcome — see docs/TRANSLATIONS.md.",
1341 meta.english_name
1342 ));
1343 }
1344 Ok(())
1345}
1346
1347fn key_column_width() -> usize {
1349 SETTINGS.iter().map(|s| s.key.len()).max().unwrap_or(0)
1350}
1351
1352pub fn run_show() -> Result<()> {
1354 let registry = Registry::load()?;
1355 let width = key_column_width();
1356
1357 output::print_header("dev-prune Global Configuration");
1358 for (category, settings) in settings_by_category() {
1359 output::print_section(category.title());
1360 for setting in settings {
1361 println!(
1362 " {:<width$} = {}",
1363 setting.key,
1364 (setting.get)(®istry.settings)
1365 );
1366 }
1367 }
1368
1369 output::print_section("This machine");
1372 println!(
1373 " {:<width$} = {}",
1374 "tracked_repos",
1375 registry.repo_count()
1376 );
1377 let reg_path = Registry::registry_path()
1378 .map(|p| output::clean_path(&p))
1379 .unwrap_or_else(|_| "unknown".to_string());
1380 println!(" {:<width$} = {reg_path}", "registry_file");
1381
1382 print_recommendation_summary(®istry.settings);
1386
1387 println!();
1388 output::print_info("Change any of these with `devp config set <key> <value>`.");
1389 output::print_info("Walk through them one at a time with `devp config wizard`.");
1390
1391 Ok(())
1392}
1393
1394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1396pub enum Opened {
1397 ByRequest,
1399 OnItsOwn,
1403 FirstRun,
1406}
1407
1408fn why_this_opened(opened: Opened) -> Option<String> {
1415 if opened == Opened::ByRequest {
1416 return None;
1417 }
1418 if opened == Opened::FirstRun {
1419 return Some(
1420 "You did not ask for this screen. dev-prune opens it once, before it \
1421 installs anything, so that saying yes is something you do rather than \
1422 something that happens to you. Finish the walkthrough and the items under \
1423 \"What finishing this walkthrough installs\" are set up, honouring \
1424 whatever you switch off on the way; quit (q) and nothing is installed. \
1425 Whatever you typed runs as soon as you leave."
1426 .to_string(),
1427 );
1428 }
1429 let new = settings_added_since_review().len();
1430 Some(if reviewed_version().is_none() || new == 0 {
1431 "You did not ask for this screen. dev-prune opens it once, on the first command \
1432 after it is installed, so that you see what its defaults do before they start \
1433 doing it. Whatever you typed runs as soon as you leave. It will not open by \
1434 itself again unless an upgrade adds a setting."
1435 .to_string()
1436 } else {
1437 format!(
1438 "You did not ask for this screen. This upgrade added {new} {}, and dev-prune \
1439 shows a new one once before its default goes on applying. Nothing else about \
1440 your configuration changed. Whatever you typed runs as soon as you leave.",
1441 output::plural(new, "setting", "settings"),
1442 )
1443 })
1444}
1445
1446pub fn run_wizard(no_tui: bool, opened: Opened) -> Result<WizardEnd> {
1457 if !no_tui && crate::tui::full_screen_is_usable() {
1458 return run_wizard_tui(opened);
1459 }
1460 run_wizard_prompts(opened)
1461}
1462
1463#[derive(Clone, Copy, PartialEq, Eq)]
1466pub enum WizardEnd {
1467 Completed,
1469 Cancelled,
1471}
1472
1473fn run_wizard_tui(opened: Opened) -> Result<WizardEnd> {
1475 use crate::tui::config_view::{ConfigRow, ConfigSession, Control, Outcome};
1476
1477 let registry = Registry::load()?;
1478 let new_keys = settings_added_since_review();
1479 let fresh = Settings::default();
1483
1484 let rows: Vec<ConfigRow> = settings_by_category()
1487 .into_iter()
1488 .flat_map(|(category, settings)| {
1489 settings.into_iter().map(move |setting| (category, setting))
1490 })
1491 .map(|(category, setting)| {
1492 let value = (setting.get)(®istry.settings);
1493 ConfigRow {
1494 key: setting.key,
1495 category: category.title(),
1496 help: setting.help,
1497 plain: setting.plain,
1498 control: match setting.kind {
1499 Kind::Toggle => Control::Toggle,
1500 Kind::Choice => Control::Choice(i18n::choices()),
1501 Kind::Number => Control::Number,
1502 Kind::Adapters => Control::Adapters,
1503 Kind::AdapterDays => Control::AdapterDays,
1504 Kind::CacheCaps => Control::CacheCaps,
1505 },
1506 original: value.clone(),
1507 default: (setting.get)(&fresh),
1508 recommended: recommended_value(setting.key),
1509 cautious: recommendation(setting.key).is_some_and(|r| r.cautious),
1510 value,
1511 is_new: new_keys.contains(&setting.key),
1512 }
1513 })
1514 .collect();
1515
1516 let base = registry.settings.clone();
1519 let validate = move |key: &str, value: &str| -> std::result::Result<(), String> {
1520 let setting = find_setting(key).map_err(|e| e.to_string())?;
1521 let mut probe = base.clone();
1522 (setting.set)(&mut probe, value).map_err(|e| format!("{e}"))
1523 };
1524
1525 let report = crate::commands::trust::build(®istry);
1526 let adapters = crate::adapters::all_adapter_names();
1527 let opt_in = crate::adapters::opt_in_adapter_names();
1528 let capped: Vec<&'static str> = adapters
1531 .iter()
1532 .copied()
1533 .filter(|name| crate::commands::caches::is_cache_manager(name))
1534 .collect();
1535
1536 let why = why_this_opened(opened);
1537 let mut declaration = declaration_lines(&report);
1538 if opened == Opened::FirstRun {
1539 use crate::tui::config_view::DeclarationLine;
1540 let line = |mark: char, subject: &str, state: String| DeclarationLine {
1541 mark,
1542 subject: subject.to_string(),
1543 state,
1544 };
1545 declaration.push(line('#', "", String::new()));
1546 declaration.push(line(
1547 '#',
1548 "What finishing this walkthrough installs",
1549 String::new(),
1550 ));
1551 for (subject, state) in setup_preview_lines() {
1552 declaration.push(line('!', subject, state));
1553 }
1554 declaration.push(line(
1555 ' ',
1556 "Quit instead (q)",
1557 "and none of it is installed — `devp setup` installs it later".to_string(),
1558 ));
1559 }
1560 let outcome = crate::tui::config_view::run(ConfigSession {
1561 declaration,
1562 standing: NOTHING_DELETED_YET.to_string(),
1563 suggestions: first_run_suggestions(),
1564 rows,
1565 adapters: &adapters,
1566 opt_in_adapters: &opt_in,
1567 capped_adapters: &capped,
1568 groups: crate::adapters::ADAPTER_GROUPS,
1569 validate: &validate,
1570 title: "dev-prune configuration",
1571 uninvited: why.as_deref(),
1572 })?;
1573
1574 match outcome {
1575 Outcome::Cancelled => {
1579 output::print_info("Cancelled — nothing was changed.");
1580 Ok(WizardEnd::Cancelled)
1581 }
1582 Outcome::KeepAll => {
1583 mark_reviewed();
1584 output::print_success(
1585 "Keeping the current values. `devp config set <key> <value>` changes any.",
1586 );
1587 Ok(WizardEnd::Completed)
1588 }
1589 Outcome::Save(changed) => {
1590 save_settings_edits(changed.iter().map(|row| (row.key, row.value.as_str())))?;
1591 mark_reviewed();
1592
1593 output::print_header("Saved");
1597 let width = changed.iter().map(|r| r.key.len()).max().unwrap_or(0);
1598 for row in &changed {
1599 println!(
1600 " {:<width$} = {} (was {})",
1601 row.key, row.value, row.original
1602 );
1603 }
1604 println!();
1605 output::print_success(&format!(
1606 "{} {} saved. `devp config show` lists every setting.",
1607 changed.len(),
1608 output::plural(changed.len(), "change", "changes")
1609 ));
1610 Ok(WizardEnd::Completed)
1611 }
1612 }
1613}
1614
1615fn setup_preview_lines() -> Vec<(&'static str, String)> {
1622 vec![
1623 (
1624 "Command on PATH",
1625 "a managed copy of the binary, as both `dev-prune` and `devp`".to_string(),
1626 ),
1627 (
1628 "AI agent skills",
1629 "SKILL.md instructions and file icons, in dev-prune's own config directory".to_string(),
1630 ),
1631 (
1632 "Git hooks",
1633 "a hook that registers repositories as you work in them (while `auto_hooks` is on)"
1634 .to_string(),
1635 ),
1636 (
1637 "Background schedule",
1638 format!(
1639 "runs `devp run --yes` every {} days — pruning without asking (while \
1640 `auto_daemon` is on)",
1641 Settings::default().check_interval_days
1642 ),
1643 ),
1644 (
1645 "Removed again by",
1646 "`devp uninstall`, which takes all of it back out".to_string(),
1647 ),
1648 ]
1649}
1650
1651pub enum FirstRunDecision {
1653 Accepted,
1655 Declined,
1657 NoAnswer,
1659}
1660
1661pub fn first_run_wizard() -> Result<FirstRunDecision> {
1669 if crate::tui::full_screen_is_usable() {
1670 return Ok(match run_wizard_tui(Opened::FirstRun)? {
1671 WizardEnd::Completed => FirstRunDecision::Accepted,
1672 WizardEnd::Cancelled => FirstRunDecision::Declined,
1673 });
1674 }
1675
1676 use std::io::Write;
1677 output::print_header("dev-prune setup");
1678 output::print_info("dev-prune is installed but not set up. Saying yes installs:");
1679 for (subject, state) in setup_preview_lines() {
1680 println!(" {} {state}", output::pad_display(subject, 22));
1681 }
1682 println!();
1683 print!("Set up now, starting with the settings walkthrough? [y/N] ");
1684 std::io::stdout().flush()?;
1685 let mut line = String::new();
1686 if std::io::stdin().read_line(&mut line)? == 0 {
1687 println!();
1688 return Ok(FirstRunDecision::NoAnswer);
1689 }
1690 let answer = line.trim();
1691 if answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
1692 run_wizard_prompts(Opened::FirstRun)?;
1693 return Ok(FirstRunDecision::Accepted);
1694 }
1695 Ok(FirstRunDecision::Declined)
1696}
1697
1698fn recommended_value(key: &str) -> Option<&'static str> {
1715 recommendation(key).map(|r| r.value)
1716}
1717
1718fn recommendation(key: &str) -> Option<&'static Recommendation> {
1720 RECOMMENDED.iter().find(|r| r.key == key)
1721}
1722
1723fn already_taken(rec: &Recommendation, current: &str) -> bool {
1729 match rec.taken {
1730 Some(is_taken) => is_taken(current),
1731 None => current == rec.value,
1732 }
1733}
1734
1735fn outstanding(settings: &Settings) -> Vec<&'static Recommendation> {
1737 RECOMMENDED
1738 .iter()
1739 .filter(|r| {
1740 find_setting(r.key)
1741 .map(|s| (s.get)(settings))
1742 .is_ok_and(|current| !already_taken(r, ¤t))
1743 })
1744 .collect()
1745}
1746
1747fn print_recommendation_summary(settings: &Settings) {
1754 let outstanding = outstanding(settings);
1755 if outstanding.is_empty() {
1756 return;
1757 }
1758 let width = key_column_width();
1759
1760 let safe: Vec<_> = outstanding.iter().filter(|r| !r.cautious).collect();
1761 if !safe.is_empty() {
1762 output::print_section(SAFE_TIER);
1763 for r in &safe {
1764 println!(" {:<width$} = {} {}", r.key, r.value, r.label);
1765 }
1766 println!();
1767 output::print_info(&format!(
1768 "`devp config recommended` sets {} {} in one command.",
1769 safe.len(),
1770 output::plural(safe.len(), "setting", "settings")
1771 ));
1772 }
1773
1774 let cautious: Vec<_> = outstanding.iter().filter(|r| r.cautious).collect();
1775 if !cautious.is_empty() {
1776 output::print_section(CAUTIOUS_TIER);
1777 for r in &cautious {
1778 println!(" {:<width$} = {} {}", r.key, r.value, r.label);
1779 println!(" {:<width$} {}", "", r.why);
1780 }
1781 println!();
1782 output::print_info(
1783 "Not included above. `devp config recommended --with-cautious` includes it; \
1784 `devp config set <key> <value>` sets one on its own.",
1785 );
1786 }
1787}
1788
1789pub fn run_recommended(with_cautious: bool) -> Result<()> {
1803 let mut registry = Registry::load()?;
1804 let width = key_column_width();
1805
1806 output::print_header("dev-prune recommended settings");
1807
1808 let mut applied: Vec<(&'static str, String, &'static str)> = Vec::new();
1809 let mut already: Vec<&'static Recommendation> = Vec::new();
1810 let mut held_back: Vec<&'static Recommendation> = Vec::new();
1811
1812 for rec in RECOMMENDED {
1813 let setting = find_setting(rec.key)?;
1814 let current = (setting.get)(®istry.settings);
1815 if already_taken(rec, ¤t) {
1816 already.push(rec);
1817 } else if rec.cautious && !with_cautious {
1818 held_back.push(rec);
1819 } else {
1820 (setting.set)(&mut registry.settings, rec.value)?;
1821 applied.push((rec.key, current, rec.value));
1822 }
1823 }
1824
1825 if !applied.is_empty() {
1826 registry.save()?;
1827 output::print_section("Turned on");
1828 for (key, from, to) in &applied {
1829 println!(" {:<width$} {from} → {to}", key);
1830 }
1831 }
1832 if !already.is_empty() {
1833 output::print_section("Already set");
1834 for rec in &already {
1835 println!(" {:<width$} {}", rec.key, rec.label);
1836 }
1837 }
1838 if !held_back.is_empty() {
1839 output::print_section(CAUTIOUS_TIER);
1840 for rec in &held_back {
1841 println!(" {:<width$} = {} {}", rec.key, rec.value, rec.label);
1842 println!(" {:<width$} {}", "", rec.why);
1843 }
1844 println!();
1845 output::print_info(
1846 "Left alone. `devp config recommended --with-cautious` includes it; \
1847 `devp config set <key> <value>` sets one on its own.",
1848 );
1849 }
1850
1851 println!();
1852 if applied.is_empty() {
1853 output::print_success(
1854 "Nothing changed — everything recommended without a caveat is already set.",
1855 );
1856 } else {
1857 output::print_success(&format!(
1858 "{} {} changed. `devp config show` lists them all.",
1859 applied.len(),
1860 output::plural(applied.len(), "setting", "settings")
1861 ));
1862 }
1863 Ok(())
1864}
1865
1866fn first_run_suggestions() -> Vec<crate::tui::config_view::Suggestion> {
1867 use crate::tui::config_view::Suggestion;
1868
1869 if reviewed_version().is_some() {
1870 return Vec::new();
1871 }
1872 RECOMMENDED
1873 .iter()
1874 .filter_map(|r| {
1875 let setting = find_setting(r.key).ok()?;
1876 Some(Suggestion {
1877 key: r.key,
1878 label: r.label,
1879 help: setting.help,
1880 plain: setting.plain,
1881 why: r.why,
1882 value: r.value,
1883 cautious: r.cautious,
1884 })
1885 })
1886 .collect()
1887}
1888
1889const NOTHING_DELETED_YET: &str =
1891 "Nothing has been deleted, and nothing will be until a lockfile proves it comes back.";
1892
1893fn provenance_rows() -> Vec<(&'static str, String)> {
1910 let url = |u: &str| u.trim_start_matches("https://").to_string();
1913 vec![
1914 (
1915 "What you are running",
1916 format!(
1917 "{} v{}",
1918 crate::constants::APP_NAME,
1919 crate::constants::VERSION
1920 ),
1921 ),
1922 (
1923 "Written by",
1924 format!("{}, under Apache-2.0", crate::constants::AUTHOR),
1925 ),
1926 ("Source code", url(crate::constants::REPO_URL)),
1927 (
1928 "Official downloads",
1929 format!("{} · GitHub releases", url(crate::constants::HOMEPAGE_URL)),
1930 ),
1931 (
1932 "Package registries",
1933 "crates.io · PyPI · npm, all named dev-prune".to_string(),
1934 ),
1935 (
1936 "Editor extension",
1937 "VS Code Marketplace · Open VSX".to_string(),
1938 ),
1939 (
1940 "Any other source",
1941 "is not a copy the author published".to_string(),
1942 ),
1943 ]
1944}
1945
1946fn declaration_lines(
1952 report: &crate::commands::trust::TrustReport,
1953) -> Vec<crate::tui::config_view::DeclarationLine> {
1954 use crate::commands::trust::{TrustRow, Verdict};
1955 use crate::tui::config_view::DeclarationLine;
1956
1957 let heading = |text: &str| DeclarationLine {
1958 mark: '#',
1959 subject: text.to_string(),
1960 state: String::new(),
1961 };
1962 let row = |r: &TrustRow| DeclarationLine {
1963 mark: match r.verdict {
1964 Verdict::Guaranteed | Verdict::Safe => '+',
1965 Verdict::Widened => '!',
1966 Verdict::Neutral => ' ',
1967 },
1968 subject: r.subject.to_string(),
1969 state: r.state.clone(),
1970 };
1971
1972 let mut lines = vec![heading("What this is, and where it came from")];
1973 lines.extend(
1974 provenance_rows()
1975 .into_iter()
1976 .map(|(subject, state)| DeclarationLine {
1977 mark: ' ',
1978 subject: subject.to_string(),
1979 state,
1980 }),
1981 );
1982 lines.push(heading(""));
1983 lines.push(heading("Guaranteed by the code"));
1984 lines.extend(report.guarantees.iter().map(&row));
1985 lines.push(heading(""));
1986 lines.push(heading("On this machine"));
1987 lines.extend(report.machine.iter().map(&row));
1988 lines
1989}
1990
1991fn run_wizard_prompts(opened: Opened) -> Result<WizardEnd> {
1995 use std::io::{self, IsTerminal, Write};
1996
1997 if !io::stdin().is_terminal() {
1998 bail!(
1999 "`devp config wizard` needs a terminal to ask questions on.\n\
2000 Use `devp config show` to read the settings and `devp config set <key> <value>` \
2001 to change one."
2002 );
2003 }
2004
2005 let mut registry = Registry::load()?;
2006 let width = key_column_width();
2007 let new_keys = settings_added_since_review();
2008 let fresh = Settings::default();
2009
2010 output::print_header("dev-prune configuration");
2011 if let Some(why) = why_this_opened(opened) {
2014 output::print_warning(&why);
2015 println!();
2016 }
2017 output::print_section("What this is, and where it came from");
2018 for (subject, state) in provenance_rows() {
2019 println!(" {} {state}", output::pad_display(subject, 22));
2020 }
2021 println!(" {}", crate::constants::LICENCE_NOTICE);
2022 println!();
2023
2024 output::print_info("These are the defaults every run will use. Nothing has been changed yet.");
2025 println!();
2026 for (category, settings) in settings_by_category() {
2027 output::print_section(category.title());
2028 for setting in settings {
2029 let badge = if new_keys.contains(&setting.key) {
2032 " (new in this version)"
2033 } else {
2034 ""
2035 };
2036 println!(
2037 " {:<width$} = {}{badge}",
2038 setting.key,
2039 (setting.get)(®istry.settings)
2040 );
2041 println!(" {:<width$} {}", "", setting.help);
2042 println!(" {:<width$} {}", "", setting.plain);
2045 let mut facts = format!("default {}", (setting.get)(&fresh));
2049 if let Some(rec) = recommendation(setting.key) {
2053 facts.push_str(&format!(
2054 " · recommended {} ({}, not required)",
2055 rec.value,
2056 if rec.cautious {
2057 "read the note below first"
2058 } else {
2059 "suggested"
2060 }
2061 ));
2062 }
2063 println!(" {:<width$} {facts}", "");
2064 if let Some(rec) = recommendation(setting.key).filter(|r| r.cautious) {
2065 println!(" {:<width$} {}", "", rec.why);
2066 }
2067 }
2068 }
2069 println!();
2070
2071 print_recommendation_summary(®istry.settings);
2072 println!();
2073
2074 if confirmed_twice("Press Enter twice to keep all of these, or type anything to change them: ")?
2079 {
2080 mark_reviewed();
2081 output::print_success("Keeping the defaults. `devp config set <key> <value>` changes any.");
2082 return Ok(WizardEnd::Completed);
2083 }
2084
2085 println!();
2086 output::print_info("Enter a new value, or press Enter to keep the one shown.");
2087 println!();
2088
2089 let mut edits: Vec<(&'static str, String, String)> = Vec::new();
2090 let mut input_ended = false;
2091 for setting in SETTINGS {
2092 if input_ended {
2093 break;
2094 }
2095 let current = (setting.get)(®istry.settings);
2096 loop {
2097 print!(" {} [{current}]: ", setting.key);
2098 io::stdout().flush()?;
2099 let mut line = String::new();
2100 if io::stdin().read_line(&mut line)? == 0 {
2103 println!();
2104 input_ended = true;
2105 break;
2106 }
2107 let typed = line.trim();
2108 if typed.is_empty() {
2109 break;
2110 }
2111 match (setting.set)(&mut registry.settings, typed) {
2112 Ok(()) => {
2113 let now = (setting.get)(®istry.settings);
2117 if now != current {
2118 edits.push((setting.key, current.clone(), now));
2119 }
2120 break;
2121 }
2122 Err(e) => output::print_error(&format!("{e}")),
2125 }
2126 }
2127 }
2128
2129 println!();
2130 if edits.is_empty() {
2131 if input_ended {
2135 output::print_info("Input ended — nothing was changed.");
2136 return Ok(WizardEnd::Cancelled);
2137 }
2138 mark_reviewed();
2139 output::print_success("Nothing changed — the defaults are in place.");
2140 return Ok(WizardEnd::Completed);
2141 }
2142
2143 output::print_section("About to be saved");
2146 for (key, from, to) in &edits {
2147 println!(" {:<width$} {from} → {to}", key);
2148 }
2149 println!();
2150 if !confirmed_twice("Press Enter twice to save, or type anything to abandon: ")? {
2151 output::print_info("Nothing was written.");
2152 return Ok(WizardEnd::Cancelled);
2153 }
2154
2155 save_settings_edits(edits.iter().map(|(key, _, to)| (*key, to.as_str())))?;
2156 mark_reviewed();
2157 let changed = edits.len();
2158 println!();
2159 output::print_success(&format!(
2160 "Saved {changed} {}. `devp config show` lists them all.",
2161 output::plural(changed, "change", "changes")
2162 ));
2163 Ok(WizardEnd::Completed)
2164}
2165
2166fn confirmed_twice(prompt: &str) -> Result<bool> {
2175 use std::io::{self, Write};
2176
2177 for pass in 0..2 {
2178 print!(
2179 "{}",
2180 if pass == 0 {
2181 prompt
2182 } else {
2183 "Press Enter once more to confirm: "
2184 }
2185 );
2186 io::stdout().flush()?;
2187 let mut line = String::new();
2188 if io::stdin().read_line(&mut line)? == 0 {
2189 println!();
2190 return Ok(false);
2191 }
2192 if !line.trim().is_empty() {
2193 return Ok(false);
2194 }
2195 }
2196 Ok(true)
2197}
2198
2199const REVIEW_MARKER: &str = "config-reviewed";
2201
2202pub fn config_review_is_due() -> bool {
2211 let Ok(dir) = Registry::config_dir() else {
2212 return false;
2213 };
2214 if !dir.join(REVIEW_MARKER).exists() {
2215 return true;
2216 }
2217 !settings_added_since_review().is_empty()
2218}
2219
2220fn reviewed_version() -> Option<String> {
2222 let dir = Registry::config_dir().ok()?;
2223 let recorded = std::fs::read_to_string(dir.join(REVIEW_MARKER)).ok()?;
2224 let recorded = recorded.trim().to_string();
2225 (!recorded.is_empty()).then_some(recorded)
2226}
2227
2228pub fn settings_added_since_review() -> Vec<&'static str> {
2237 let Some(reviewed) = reviewed_version() else {
2238 return Vec::new();
2239 };
2240 SETTINGS
2241 .iter()
2242 .filter(|s| {
2243 crate::commands::update::compare_versions(s.since, &reviewed)
2244 == Some(std::cmp::Ordering::Greater)
2245 })
2246 .map(|s| s.key)
2247 .collect()
2248}
2249
2250fn mark_reviewed() {
2251 if let Ok(dir) = Registry::config_dir() {
2252 let _ = std::fs::create_dir_all(&dir);
2253 let _ = std::fs::write(dir.join(REVIEW_MARKER), crate::constants::VERSION);
2254 }
2255}
2256
2257pub fn skip_config_review() {
2262 mark_reviewed();
2263}
2264
2265pub fn run_global_update() -> Result<()> {
2267 output::print_header("dev-prune Global Configuration Audit & Sync");
2268
2269 let registry = Registry::load()?;
2270 let mut total_audited = 0;
2271 let mut errors_found = 0;
2272
2273 for repo_path in registry.repositories.keys() {
2274 let clean = output::clean_path(repo_path);
2275
2276 if !repo_path.exists() {
2280 output::print_warning(&format!(
2281 "Skipped {clean} — the path no longer exists. `devp unlink --missing` \
2282 clears such entries."
2283 ));
2284 continue;
2285 }
2286 total_audited += 1;
2287
2288 match PerRepoConfig::load_personal_for_write(repo_path) {
2289 Ok(Some(cfg)) => {
2290 if let Err(e) = cfg.save_to_repo(repo_path) {
2291 output::print_error(&format!("Failed to write config for {clean}: {e}"));
2292 errors_found += 1;
2293 } else {
2294 output::print_success(&format!("Audited & synced config for {clean}"));
2295 }
2296 }
2297 Ok(None) => {
2298 output::print_info(&format!(
2302 "{clean} has no .devprune.json — global defaults apply."
2303 ));
2304 }
2305 Err(err_msg) => {
2306 errors_found += 1;
2307 output::print_error(&format!("Syntax/Schema Error in {clean}:"));
2308 for line in err_msg.lines() {
2309 eprintln!(" {line}");
2310 }
2311 output::print_info(&format!(
2312 "Hint: fix the syntax by hand, or run `devp config {clean} --update` to \
2313 replace the file with a valid default."
2314 ));
2315 }
2316 }
2317 }
2318
2319 if errors_found > 0 {
2320 anyhow::bail!(
2323 "Audit complete: {total_audited} repos checked, {errors_found} could not be read \
2324 or written."
2325 );
2326 }
2327 output::print_success(&format!(
2328 "Audit complete: All {total_audited} registered repositories are healthy & synced!"
2329 ));
2330
2331 Ok(())
2332}
2333
2334pub fn run_path_config(path_str: &str, force_update: bool, team: bool) -> Result<()> {
2339 let raw_path = Path::new(path_str);
2340
2341 let path = if raw_path.exists() {
2342 raw_path
2343 .canonicalize()
2344 .unwrap_or_else(|_| raw_path.to_path_buf())
2345 } else {
2346 raw_path.to_path_buf()
2347 };
2348
2349 let clean = output::clean_path(&path);
2350
2351 if !path.exists() {
2352 bail!("Path does not exist: {clean}");
2353 }
2354
2355 if !crate::scanner::is_git_repo(&path) {
2356 bail!(
2358 "`{clean}` is not a Git repository.\n \
2359 Run `git init` there first, then `devp config {clean}` again."
2360 );
2361 }
2362
2363 let mut registry = Registry::load()?;
2364 if !registry.repositories.contains_key(&path) {
2365 output::print_info(&format!(
2366 "{clean} is not yet registered with dev-prune. Registering now..."
2367 ));
2368 registry.add_repo(path.clone());
2369 registry.save()?;
2370 }
2371
2372 let name = if team {
2373 crate::constants::PROJECT_REPO_CONFIG_FILE
2374 } else {
2375 crate::constants::PER_REPO_CONFIG_FILE
2376 };
2377 let cfg_file = path.join(name);
2378
2379 if cfg_file.exists() && !force_update {
2380 output::print_header(&format!("dev-prune Per-Repo Config for {clean}"));
2381 match crate::config::RepoConfigLayers::load(&path) {
2382 Ok(layers) => {
2383 let addressed = if team {
2384 layers.project_config()
2385 } else {
2386 layers.personal_config()
2387 };
2388 println!("{}", serde_json::to_string_pretty(&addressed)?);
2389 output::print_info(&format!("File location: {name}"));
2390 print_layer_provenance(&layers);
2391 }
2392 Err(err_msg) => {
2393 output::print_error(&format!("Invalid configuration in {clean}:"));
2394 for line in err_msg.lines() {
2395 eprintln!(" {line}");
2396 }
2397 anyhow::bail!(
2400 "Run `devp config {clean} --update` to reset this file back to defaults \
2401 (your current overrides in it are discarded)."
2402 );
2403 }
2404 }
2405 } else {
2406 output::print_info(&format!("Initializing {name} for {clean}..."));
2407 if team {
2408 crate::config::write_project_starter(&path)?;
2409 } else {
2410 PerRepoConfig::default().save_to_repo(&path)?;
2411 }
2412 output::print_success(&format!("Created {name} in {clean}"));
2413 if team {
2414 output::print_info(
2415 "It starts empty on purpose: every key it names overrules \
2416 `.devprune.json`, so it should only name the ones your team decides.",
2417 );
2418 output::print_info(
2419 "`prunable.directories` is the exception — the two files' lists add \
2420 up, so naming one here never discards somebody's own.",
2421 );
2422 output::print_info(
2423 "Commit it. Unlike `.devprune.json`, this file is not added to \
2424 `.git/info/exclude` — being shared is the whole reason it exists.",
2425 );
2426 }
2427 }
2428
2429 Ok(())
2430}
2431
2432fn print_layer_provenance(layers: &crate::config::RepoConfigLayers) {
2440 if layers.project_config().is_none() || layers.personal_config().is_none() {
2441 return;
2442 }
2443 output::print_section("Effective values");
2444 for (key, value, source) in layers.rows() {
2445 println!(
2446 " {} {} {}",
2447 output::pad_display(key, 20),
2448 output::pad_display(&value, 14),
2449 source.label()
2450 );
2451 }
2452}
2453
2454fn load_workspace_config_for_write(repo_path: &Path) -> Result<PerRepoConfig> {
2460 match PerRepoConfig::load_personal_for_write(repo_path) {
2461 Ok(Some(cfg)) => Ok(cfg),
2462 Ok(None) => Ok(PerRepoConfig::default()),
2463 Err(e) => bail!(
2464 "{e}\n \
2465 Fix that file, or run `devp config {} --update` to reset it back to defaults \
2466 (your current overrides in it are discarded).",
2467 output::clean_path(repo_path)
2468 ),
2469 }
2470}
2471
2472pub fn run_daemon_toggle(path: Option<&str>, action: &str) -> Result<()> {
2474 if let Some(p) = path {
2475 let repo_path = resolve_workspace(p)?;
2476 let mut cfg = load_workspace_config_for_write(&repo_path)?;
2477 match parse_toggle(action)? {
2478 Toggle::Enable => {
2479 cfg.disable_daemon = false;
2480 cfg.save_to_repo(&repo_path)?;
2481 output::print_success(&format!(
2482 "Enabled background daemon for workspace: {}",
2483 output::clean_path(&repo_path)
2484 ));
2485 }
2486 Toggle::Disable => {
2487 cfg.disable_daemon = true;
2488 cfg.save_to_repo(&repo_path)?;
2489 output::print_success(&format!(
2490 "Disabled background daemon for workspace: {}",
2491 output::clean_path(&repo_path)
2492 ));
2493 }
2494 Toggle::Status => {
2495 let st = if cfg.disable_daemon {
2496 "Disabled for workspace"
2497 } else {
2498 "Enabled for workspace"
2499 };
2500 output::print_info(&format!(
2501 "Daemon Status ({}): {}",
2502 output::clean_path(&repo_path),
2503 st
2504 ));
2505 }
2506 }
2507 } else {
2508 match parse_toggle(action)? {
2509 Toggle::Enable => crate::commands::daemon::run_install()?,
2510 Toggle::Disable => crate::commands::daemon::run_uninstall()?,
2511 Toggle::Status => crate::commands::daemon::run_status()?,
2512 }
2513 }
2514 Ok(())
2515}
2516
2517pub fn run_hook_toggle(path: Option<&str>, action: &str, chain: bool) -> Result<()> {
2519 if let Some(p) = path {
2520 if chain {
2521 bail!(
2522 "`--chain` changes the single global `core.hooksPath`, so it has no \
2523 per-workspace form. Drop the path: `devp hook install --chain`."
2524 );
2525 }
2526 let repo_path = resolve_workspace(p)?;
2527 let mut cfg = load_workspace_config_for_write(&repo_path)?;
2528 match parse_toggle(action)? {
2529 Toggle::Enable => {
2530 cfg.disable_hooks = false;
2531 cfg.save_to_repo(&repo_path)?;
2532 output::print_success(&format!(
2533 "Enabled background Git hooks for workspace: {}",
2534 output::clean_path(&repo_path)
2535 ));
2536 }
2537 Toggle::Disable => {
2538 cfg.disable_hooks = true;
2539 cfg.save_to_repo(&repo_path)?;
2540 output::print_success(&format!(
2541 "Disabled background Git hooks for workspace: {}",
2542 output::clean_path(&repo_path)
2543 ));
2544 }
2545 Toggle::Status => {
2546 let st = if cfg.disable_hooks {
2547 "Disabled for workspace"
2548 } else {
2549 "Enabled for workspace"
2550 };
2551 output::print_info(&format!(
2552 "Git Hook Status ({}): {}",
2553 output::clean_path(&repo_path),
2554 st
2555 ));
2556 }
2557 }
2558 } else {
2559 match parse_toggle(action)? {
2560 Toggle::Enable => crate::commands::hook::run_install(chain)?,
2561 Toggle::Disable => crate::commands::hook::run_uninstall()?,
2562 Toggle::Status => crate::commands::hook::run_status()?,
2563 }
2564 }
2565 Ok(())
2566}
2567
2568#[cfg(test)]
2569mod tests {
2570 use super::*;
2571
2572 #[test]
2573 fn enable_synonyms_all_resolve_to_enable() {
2574 for word in ["enable", "install", "on", "INSTALL", "On"] {
2575 assert_eq!(parse_toggle(word).unwrap(), Toggle::Enable, "{word}");
2576 }
2577 }
2578
2579 #[test]
2580 fn disable_synonyms_all_resolve_to_disable() {
2581 for word in ["disable", "uninstall", "remove", "off", "Uninstall"] {
2582 assert_eq!(parse_toggle(word).unwrap(), Toggle::Disable, "{word}");
2583 }
2584 }
2585
2586 #[test]
2587 fn status_is_the_default_and_is_also_spellable() {
2588 for word in ["", "status", "show"] {
2589 assert_eq!(parse_toggle(word).unwrap(), Toggle::Status, "{word}");
2590 }
2591 }
2592
2593 #[test]
2594 fn a_typo_is_an_error_rather_than_a_silent_status_report() {
2595 let err = parse_toggle("enabel").unwrap_err().to_string();
2598 assert!(err.contains("enabel"), "{err}");
2599 assert!(err.contains("enable"), "{err}");
2600 }
2601
2602 #[test]
2603 fn a_workspace_toggle_refuses_to_write_over_a_broken_config() {
2604 let tmp = tempfile::TempDir::new().unwrap();
2607 let broken = r#"{ "project_name": "api", "override_idle_days": 90, }"#;
2608 std::fs::write(
2609 tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE),
2610 broken,
2611 )
2612 .unwrap();
2613
2614 let err = load_workspace_config_for_write(tmp.path())
2615 .unwrap_err()
2616 .to_string();
2617 assert!(err.contains("Syntax error"), "{err}");
2618 assert!(err.contains("--update"), "{err}");
2619
2620 let on_disk =
2622 std::fs::read_to_string(tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE))
2623 .unwrap();
2624 assert_eq!(on_disk, broken);
2625 }
2626
2627 #[test]
2628 fn the_suggested_cap_is_the_constant_it_claims_to_be() {
2629 let caps = parse_cache_caps(crate::constants::RECOMMENDED_CACHE_CAP).unwrap();
2634 assert_eq!(
2635 caps,
2636 std::collections::BTreeMap::from([(
2637 crate::constants::CACHE_CAP_DEFAULT_KEY.to_string(),
2638 crate::constants::RECOMMENDED_CACHE_MAX_GB,
2639 )])
2640 );
2641 }
2642
2643 #[test]
2644 fn a_ceiling_of_your_own_counts_as_having_taken_the_advice() {
2645 let rec = recommendation("cache_max_gb").expect("the cap is recommended");
2650 assert!(already_taken(rec, "npm=4"));
2651 assert!(already_taken(rec, crate::constants::RECOMMENDED_CACHE_CAP));
2652 assert!(!already_taken(rec, "(none)"));
2653
2654 let toggle = recommendation("enable_cargo").expect("cargo is recommended");
2656 assert!(already_taken(toggle, "true"));
2657 assert!(!already_taken(toggle, "false"));
2658 }
2659
2660 #[test]
2661 fn a_workspace_with_no_config_yet_starts_from_the_defaults() {
2662 let tmp = tempfile::TempDir::new().unwrap();
2663 assert_eq!(
2664 load_workspace_config_for_write(tmp.path()).unwrap(),
2665 PerRepoConfig::default()
2666 );
2667 }
2668
2669 #[test]
2670 fn a_cache_cap_is_written_the_way_it_is_read_back() {
2671 let caps = parse_cache_caps("uv=10,npm=4").unwrap();
2672 assert_eq!(caps.get("uv"), Some(&10));
2673 assert_eq!(caps.get("npm"), Some(&4));
2674 let settings = Settings {
2677 cache_max_gb: parse_cache_caps("UV = 10 , npm=4").unwrap(),
2678 ..Settings::default()
2679 };
2680 let printed = SETTINGS
2681 .iter()
2682 .find(|s| s.key == "cache_max_gb")
2683 .map(|s| (s.get)(&settings))
2684 .unwrap();
2685 assert_eq!(printed, "npm=4,uv=10");
2686 assert_eq!(parse_cache_caps(&printed).unwrap(), settings.cache_max_gb);
2687 }
2688
2689 #[test]
2690 fn clearing_the_caps_is_spelled_the_way_the_getter_prints_an_empty_map() {
2691 for blank in ["", "-", "none", "(none)", "NONE"] {
2692 assert!(
2693 parse_cache_caps(blank).unwrap().is_empty(),
2694 "`{blank}` should clear every cap"
2695 );
2696 }
2697 }
2698
2699 #[test]
2700 fn a_cap_on_something_that_is_not_a_cache_is_refused_with_the_list() {
2701 let err = parse_cache_caps("venv=10").unwrap_err().to_string();
2704 assert!(err.contains("venv"), "{err}");
2705 assert!(err.contains("npm"), "the error lists what is valid: {err}");
2706 }
2707
2708 #[test]
2709 fn a_cap_has_to_be_a_whole_number_of_gibibytes() {
2710 for bad in ["uv=10.5", "uv=ten", "uv=-1", "uv="] {
2711 assert!(parse_cache_caps(bad).is_err(), "`{bad}` was accepted");
2712 }
2713 assert!(parse_cache_caps("uv").is_err());
2716 }
2717
2718 #[test]
2719 fn a_cap_of_zero_is_refused_rather_than_stored() {
2720 let err = parse_cache_caps("uv=0").unwrap_err().to_string();
2723 assert!(
2724 err.contains("`-`"),
2725 "the error names the way to clear it: {err}"
2726 );
2727 }
2728
2729 #[test]
2730 fn every_setting_round_trips_through_its_own_getter() {
2731 let mut settings = Settings::default();
2735 for setting in SETTINGS {
2736 let before = (setting.get)(&settings);
2737 let probe = match setting.kind {
2738 Kind::Toggle => if before == "true" { "false" } else { "true" }.to_string(),
2739 Kind::Number => "7".to_string(),
2742 Kind::Adapters => "cargo".to_string(),
2745 Kind::AdapterDays => "cargo=45".to_string(),
2748 Kind::CacheCaps => "cargo=10".to_string(),
2751 Kind::Choice => "hi".to_string(),
2754 };
2755 (setting.set)(&mut settings, &probe)
2756 .unwrap_or_else(|e| panic!("{} rejected `{probe}`: {e}", setting.key));
2757 assert_eq!(
2758 (setting.get)(&settings),
2759 probe,
2760 "{} reads back a different field than it writes",
2761 setting.key
2762 );
2763 }
2764 }
2765
2766 #[test]
2767 fn every_setting_is_documented_and_uniquely_named() {
2768 let mut seen = std::collections::HashSet::new();
2769 for setting in SETTINGS {
2770 assert!(seen.insert(setting.key), "duplicate key {}", setting.key);
2771 assert!(!setting.help.is_empty(), "{} has no help", setting.key);
2772 assert!(
2773 !setting.plain.is_empty(),
2774 "{} has no plain text",
2775 setting.key
2776 );
2777 assert!(
2779 setting.help.ends_with('.'),
2780 "{} help should read as a sentence",
2781 setting.key
2782 );
2783 assert!(
2784 setting.plain.ends_with('.'),
2785 "{} plain text should read as a sentence",
2786 setting.key
2787 );
2788 assert_ne!(
2791 setting.plain, setting.help,
2792 "{} says the same thing twice",
2793 setting.key
2794 );
2795 }
2796 }
2797
2798 #[test]
2799 fn the_settings_table_covers_every_field_of_settings() {
2800 let json = serde_json::to_value(Settings::default()).unwrap();
2804 let fields: Vec<String> = json.as_object().unwrap().keys().cloned().collect();
2805 for field in fields {
2806 assert!(
2807 SETTINGS.iter().any(|s| s.key == field),
2808 "`{field}` is a setting with no entry in SETTINGS, so `devp config set \
2809 {field}` cannot reach it"
2810 );
2811 }
2812 }
2813
2814 #[test]
2815 fn a_rejected_value_leaves_the_previous_one_in_place() {
2816 let mut settings = Settings::default();
2817 assert!((find_setting("scan_depth").unwrap().set)(&mut settings, "0").is_err());
2818 assert_eq!(settings.scan_depth, Settings::default().scan_depth);
2819
2820 assert!((find_setting("command_timeout_secs").unwrap().set)(&mut settings, "0").is_err());
2821 assert!((find_setting("check_interval_days").unwrap().set)(&mut settings, "0").is_err());
2822 assert!(
2823 (find_setting("update_check_interval_days").unwrap().set)(&mut settings, "0").is_err()
2824 );
2825 }
2826
2827 #[test]
2828 fn booleans_accept_the_words_people_actually_type() {
2829 assert!(parse_bool("k", "yes").unwrap());
2830 assert!(parse_bool("k", "ON").unwrap());
2831 assert!(!parse_bool("k", "0").unwrap());
2832 assert!(parse_bool("k", "maybe").is_err());
2833 }
2834
2835 #[test]
2836 fn an_unknown_key_lists_the_ones_that_exist() {
2837 let err = match find_setting("idel_days") {
2838 Ok(_) => panic!("`idel_days` is not a setting"),
2839 Err(e) => e.to_string(),
2840 };
2841 assert!(err.contains("idle_days"), "{err}");
2842 }
2843
2844 #[test]
2845 fn a_path_is_never_mistaken_for_an_action() {
2846 assert!(!is_toggle_word("~/Code/my-repo"));
2848 assert!(!is_toggle_word("."));
2849 assert!(!is_toggle_word(""));
2850 assert!(is_toggle_word("install"));
2851 }
2852}