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: "cache_max_gb",
301 label: "Cache size ceilings",
302 why: "Every other suggestion here is about one project's folders. This one is about the \
303 download caches all of them share, which only ever grow — npm's on the machine this \
304 was written on had passed 10 GiB while every project it served fit in a fraction of \
305 that. `default=10` is one ceiling for every manager at once. Crossing it deletes \
306 nothing: `devp caches` says which manager is over, and `devp caches clear \
307 --over-cap all` is still typed by hand. Name a manager to give it a figure of its \
308 own — `devp config set cache_max_gb default=10,npm=4`.",
309 value: crate::constants::RECOMMENDED_CACHE_CAP,
310 cautious: false,
311 taken: Some(|current| parse_cache_caps(current).is_ok_and(|caps| !caps.is_empty())),
313 },
314 Recommendation {
315 key: "allow_manifest_rewrite",
316 label: "Let cargo and go tidy up",
317 why: "Cautious, not risky. The commands that restore a Rust or Go project can also update \
318 `Cargo.lock` or `go.mod` — files Git tracks. Nothing is lost and nothing is deleted, \
319 but the next `git status` may show a change you did not make by hand. Turn it on if \
320 that is fine; leave it off if a clean working tree matters more than a fully \
321 automatic restore.",
322 value: "true",
323 cautious: true,
324 taken: None,
325 },
326];
327
328const SETTINGS: &[Setting] = &[
330 Setting {
331 key: "language",
332 category: Category::Presentation,
333 since: "1.10.0",
334 kind: Kind::Choice,
335 help: "Language for dev-prune's own headings and summary lines. `--json`, exit codes, flag names and config keys stay English in every language.",
336 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.",
337 get: |s| s.language.clone(),
338 set: |s, v| {
339 let code = v.trim();
340 let Some(meta) = i18n::language(code) else {
341 bail!(
342 "unknown language `{code}` — available: {}",
343 i18n::catalogue_line()
344 );
345 };
346 s.language = meta.code.clone();
347 Ok(())
348 },
349 },
350 Setting {
351 key: "idle_days",
352 category: Category::Scope,
353 since: "1.0.0",
354 kind: Kind::Number,
355 help: "Days a repository must sit untouched before it is eligible for pruning.",
356 plain: "How long a project has to sit untouched before dev-prune will clean it. Something you worked on yesterday is never touched.",
357 get: |s| s.idle_days.to_string(),
358 set: |s, v| {
359 s.idle_days = v
360 .parse()
361 .map_err(|_| anyhow::anyhow!("idle_days must be a whole number of days"))?;
362 Ok(())
363 },
364 },
365 Setting {
366 key: "min_size_mb",
367 category: Category::Scope,
368 since: "1.0.0",
369 kind: Kind::Number,
370 help: "Smallest bloat directory worth deleting, in MiB. 0 removes the floor.",
371 plain: "Ignore small folders. Deleting a 2 MB folder is not worth the download to get it back.",
372 get: |s| s.min_size_mb.to_string(),
373 set: |s, v| {
374 s.min_size_mb = v.parse().map_err(|_| {
375 anyhow::anyhow!("min_size_mb must be a whole number of MiB (0 disables the floor)")
376 })?;
377 Ok(())
378 },
379 },
380 Setting {
381 key: "scan_depth",
382 category: Category::Scope,
383 since: "1.0.0",
384 kind: Kind::Number,
385 help: "How many directory levels below a repo root project discovery descends.",
386 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.",
387 get: |s| s.scan_depth.to_string(),
388 set: |s, v| {
389 let depth: usize = v
390 .parse()
391 .map_err(|_| anyhow::anyhow!("scan_depth must be a positive integer"))?;
392 if depth == 0 {
396 bail!("scan_depth must be at least 1 — 0 would find no projects at all.");
397 }
398 if depth > crate::constants::MAX_SCAN_DEPTH_LIMIT {
399 bail!(
400 "scan_depth must be at most {} — deeper walks stall on generated trees.",
401 crate::constants::MAX_SCAN_DEPTH_LIMIT
402 );
403 }
404 s.scan_depth = depth;
405 Ok(())
406 },
407 },
408 Setting {
409 key: "require_confirmation",
410 category: Category::Safety,
411 since: "1.0.0",
412 kind: Kind::Toggle,
413 help: "Ask before deleting anything. Turning this off makes every run unattended.",
414 plain: "Whether dev-prune asks \"delete these?\" before it deletes. Leave this on unless you want it to run silently while you are away.",
415 get: |s| s.require_confirmation.to_string(),
416 set: |s, v| {
417 s.require_confirmation = parse_bool("require_confirmation", v)?;
418 Ok(())
419 },
420 },
421 Setting {
422 key: "allow_manifest_rewrite",
423 category: Category::Safety,
424 since: "1.0.0",
425 kind: Kind::Toggle,
426 help: "Let cargo and go run the sync command that rewrites tracked manifests.",
427 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`.",
428 get: |s| s.allow_manifest_rewrite.to_string(),
429 set: |s, v| {
430 s.allow_manifest_rewrite = parse_bool("allow_manifest_rewrite", v)?;
431 Ok(())
432 },
433 },
434 Setting {
435 key: "command_timeout_secs",
436 category: Category::Safety,
437 since: "1.0.0",
438 kind: Kind::Number,
439 help: "How long one package-manager command may run before it is killed — the lockfile check and `devp restore`, never a recompile.",
440 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.",
441 get: |s| s.command_timeout_secs.to_string(),
442 set: |s, v| {
443 let secs: u64 = v
444 .parse()
445 .map_err(|_| anyhow::anyhow!("command_timeout_secs must be a positive integer"))?;
446 if secs == 0 {
450 bail!(
451 "command_timeout_secs must be at least 1 — 0 would kill every command \
452 the instant it starts."
453 );
454 }
455 s.command_timeout_secs = secs;
456 Ok(())
457 },
458 },
459 Setting {
460 key: "auto_setup",
461 category: Category::Unattended,
462 since: "1.0.0",
463 kind: Kind::Toggle,
464 help: "Install missing integrations by itself, once per installed version.",
465 plain: "Whether dev-prune finishes setting itself up on its own instead of making you run `devp setup`.",
466 get: |s| s.auto_setup.to_string(),
467 set: |s, v| {
468 s.auto_setup = parse_bool("auto_setup", v)?;
469 Ok(())
470 },
471 },
472 Setting {
473 key: "auto_config",
474 category: Category::Unattended,
475 since: "1.3.0",
476 kind: Kind::Toggle,
477 help: "Write a default .devprune.json into repositories that link/init register.",
478 plain: "Drops a small settings file into each repository you register, so you can give that one project different rules later.",
479 get: |s| s.auto_config.to_string(),
480 set: |s, v| {
481 s.auto_config = parse_bool("auto_config", v)?;
482 Ok(())
483 },
484 },
485 Setting {
486 key: "auto_daemon",
487 category: Category::Unattended,
488 since: "1.0.0",
489 kind: Kind::Toggle,
490 help: "Register the OS scheduler so passes run without being remembered.",
491 plain: "Lets your operating system run dev-prune on a schedule, so you never have to remember to.",
492 get: |s| s.auto_daemon.to_string(),
493 set: |s, v| {
494 s.auto_daemon = parse_bool("auto_daemon", v)?;
495 Ok(())
496 },
497 },
498 Setting {
499 key: "check_interval_days",
500 category: Category::Unattended,
501 since: "1.0.0",
502 kind: Kind::Number,
503 help: "Days between scheduled background passes.",
504 plain: "How often that scheduled cleanup runs.",
505 get: |s| s.check_interval_days.to_string(),
506 set: |s, v| {
507 let days: u64 = v
508 .parse()
509 .map_err(|_| anyhow::anyhow!("check_interval_days must be a positive integer"))?;
510 if days == 0 {
512 bail!("check_interval_days must be at least 1.");
513 }
514 s.check_interval_days = days;
515 Ok(())
516 },
517 },
518 Setting {
519 key: "auto_discover",
520 category: Category::Unattended,
521 since: "1.14.0",
522 kind: Kind::Toggle,
523 help: "Let the scheduled pass register repositories no Git hook could see — unzipped, copied or restored ones.",
524 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.",
525 get: |s| s.auto_discover.to_string(),
526 set: |s, v| {
527 s.auto_discover = parse_bool("auto_discover", v)?;
528 Ok(())
529 },
530 },
531 Setting {
532 key: "auto_hooks",
533 category: Category::Unattended,
534 since: "1.0.0",
535 kind: Kind::Toggle,
536 help: "Install the Git hooks that register a repository when you clone, commit or merge in it.",
537 plain: "Adds repositories as Git creates them. Anything Git did not create — a copied or unzipped project — is left to `auto_discover`.",
538 get: |s| s.auto_hooks.to_string(),
539 set: |s, v| {
540 s.auto_hooks = parse_bool("auto_hooks", v)?;
541 Ok(())
542 },
543 },
544 Setting {
545 key: "auto_hooks_chain",
546 category: Category::Unattended,
547 since: "1.0.0",
548 kind: Kind::Toggle,
549 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.",
550 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.",
551 get: |s| s.auto_hooks_chain.to_string(),
552 set: |s, v| {
553 s.auto_hooks_chain = parse_bool("auto_hooks_chain", v)?;
554 Ok(())
555 },
556 },
557 Setting {
558 key: "update_check",
559 category: Category::Updates,
560 since: "1.0.0",
561 kind: Kind::Toggle,
562 help: "Ask GitHub for the latest release from time to time. Sends nothing but the request.",
563 plain: "Whether dev-prune checks GitHub now and then to see if there is a newer version. It sends no information about you.",
564 get: |s| s.update_check.to_string(),
565 set: |s, v| {
566 s.update_check = parse_bool("update_check", v)?;
567 Ok(())
568 },
569 },
570 Setting {
571 key: "update_check_interval_days",
572 category: Category::Updates,
573 since: "1.0.0",
574 kind: Kind::Number,
575 help: "Days between automatic release checks.",
576 plain: "How often that version check happens.",
577 get: |s| s.update_check_interval_days.to_string(),
578 set: |s, v| {
579 let days: i64 = v.parse().map_err(|_| {
580 anyhow::anyhow!("update_check_interval_days must be a positive integer")
581 })?;
582 if days < 1 {
583 bail!("update_check_interval_days must be at least 1.");
584 }
585 s.update_check_interval_days = days;
586 Ok(())
587 },
588 },
589 Setting {
590 key: "update_check_timeout_secs",
591 category: Category::Updates,
592 since: "1.0.0",
593 kind: Kind::Number,
594 help: "Seconds the release check waits for GitHub. Raise it behind a slow proxy.",
595 plain: "How long the version check waits before giving up. Raise it if you are behind a slow proxy.",
596 get: |s| s.update_check_timeout_secs.to_string(),
597 set: |s, v| {
598 let secs: u64 = v.parse().map_err(|_| {
599 anyhow::anyhow!("update_check_timeout_secs must be a positive integer")
600 })?;
601 if secs == 0 {
602 bail!("update_check_timeout_secs must be at least 1.");
603 }
604 s.update_check_timeout_secs = secs;
605 Ok(())
606 },
607 },
608 Setting {
609 key: "enable_cargo",
610 category: Category::BuildTrees,
611 since: "1.5.0",
612 kind: Kind::Toggle,
613 help: "Turn on the opt-in Cargo adapter (target/ comes back by recompiling, not downloading).",
614 plain: "Clean Rust build folders too. These come back by recompiling, which takes minutes rather than a download — so it is off by default.",
615 get: |s| s.enable_cargo.to_string(),
616 set: |s, v| {
617 s.enable_cargo = parse_bool("enable_cargo", v)?;
618 Ok(())
619 },
620 },
621 Setting {
622 key: "enable_gradle",
623 category: Category::BuildTrees,
624 since: "1.3.0",
625 kind: Kind::Toggle,
626 help: "Turn on the opt-in Gradle adapter (build/ and .gradle/ come back by recompiling).",
627 plain: "Clean Android and Java build folders too. Same trade: they come back by recompiling, not downloading.",
628 get: |s| s.enable_gradle.to_string(),
629 set: |s, v| {
630 s.enable_gradle = parse_bool("enable_gradle", v)?;
631 Ok(())
632 },
633 },
634 Setting {
635 key: "enable_maven",
636 category: Category::BuildTrees,
637 since: "1.3.0",
638 kind: Kind::Toggle,
639 help: "Turn on the opt-in Maven adapter (target/ comes back by recompiling).",
640 plain: "Clean Maven build folders too. They come back by recompiling.",
641 get: |s| s.enable_maven.to_string(),
642 set: |s, v| {
643 s.enable_maven = parse_bool("enable_maven", v)?;
644 Ok(())
645 },
646 },
647 Setting {
648 key: "enable_swift",
649 category: Category::BuildTrees,
650 since: "1.4.0",
651 kind: Kind::Toggle,
652 help: "Turn on the opt-in SwiftPM adapter (.build/ comes back by recompiling).",
653 plain: "Clean Swift build folders too. They come back by recompiling.",
654 get: |s| s.enable_swift.to_string(),
655 set: |s, v| {
656 s.enable_swift = parse_bool("enable_swift", v)?;
657 Ok(())
658 },
659 },
660 Setting {
661 key: "enable_dart",
662 category: Category::BuildTrees,
663 since: "1.6.0",
664 kind: Kind::Toggle,
665 help: "Turn on the opt-in Dart/Flutter adapter (.dart_tool/ holds build caches).",
666 plain: "Clean Dart and Flutter caches too. Part comes back instantly, part by recompiling.",
667 get: |s| s.enable_dart.to_string(),
668 set: |s, v| {
669 s.enable_dart = parse_bool("enable_dart", v)?;
670 Ok(())
671 },
672 },
673 Setting {
674 key: "enable_mix_build",
675 category: Category::BuildTrees,
676 since: "1.7.0",
677 kind: Kind::Toggle,
678 help: "Turn on the opt-in Elixir Mix build-tree adapter (_build/ comes back by recompiling).",
679 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.",
680 get: |s| s.enable_mix_build.to_string(),
681 set: |s, v| {
682 s.enable_mix_build = parse_bool("enable_mix_build", v)?;
683 Ok(())
684 },
685 },
686 Setting {
687 key: "enable_vcpkg",
688 category: Category::BuildTrees,
689 since: "1.8.0",
690 kind: Kind::Toggle,
691 help: "Turn on the opt-in vcpkg adapter (vcpkg_installed/ comes back by recompiling).",
692 plain: "Clean C and C++ vcpkg_installed/ folders too. They come back by recompiling.",
693 get: |s| s.enable_vcpkg.to_string(),
694 set: |s, v| {
695 s.enable_vcpkg = parse_bool("enable_vcpkg", v)?;
696 Ok(())
697 },
698 },
699 Setting {
700 key: "enable_cmake_build",
701 category: Category::BuildTrees,
702 since: "1.8.0",
703 kind: Kind::Toggle,
704 help: "Turn on the opt-in CMake adapter (build trees proven by their CMakeCache.txt).",
705 plain: "Clean C and C++ build folders CMake configured. A `build/` you made by hand is \
706 never touched.",
707 get: |s| s.enable_cmake_build.to_string(),
708 set: |s, v| {
709 s.enable_cmake_build = parse_bool("enable_cmake_build", v)?;
710 Ok(())
711 },
712 },
713 Setting {
714 key: "enable_dotnet_build",
715 category: Category::BuildTrees,
716 since: "1.18.0",
717 kind: Kind::Toggle,
718 help: "Turn on the opt-in .NET adapter (bin/ and obj/ proven by NuGet's project.assets.json).",
719 plain: "Clean .NET bin/ and obj/ folders MSBuild wrote. A `bin/` holding anything of \
720 yours is never touched.",
721 get: |s| s.enable_dotnet_build.to_string(),
722 set: |s, v| {
723 s.enable_dotnet_build = parse_bool("enable_dotnet_build", v)?;
724 Ok(())
725 },
726 },
727 Setting {
728 key: "build_idle_days",
729 category: Category::BuildTrees,
730 since: "1.3.0",
731 kind: Kind::Number,
732 help: "Idle days before the opt-in adapters' build trees are pruned. Applied as max(this, idle_days).",
733 plain: "A longer wait, used only for the build folders above, because getting those back costs a recompile rather than a download.",
734 get: |s| s.build_idle_days.to_string(),
735 set: |s, v| {
736 let days: u64 = v
737 .parse()
738 .map_err(|_| anyhow::anyhow!("build_idle_days must be a non-negative integer"))?;
739 s.build_idle_days = days;
740 Ok(())
741 },
742 },
743 Setting {
744 key: "auto_update",
745 category: Category::Updates,
746 since: "1.3.0",
747 kind: Kind::Toggle,
748 help: "Install a newer release by itself at the end of a prune pass. On by default.",
749 plain: "Whether dev-prune installs its own updates after a cleanup. The download is checked against its published fingerprint first.",
750 get: |s| s.auto_update.to_string(),
751 set: |s, v| {
752 s.auto_update = parse_bool("auto_update", v)?;
753 Ok(())
754 },
755 },
756 Setting {
757 key: "version_lock",
758 category: Category::Updates,
759 since: "1.8.0",
760 kind: Kind::Toggle,
761 help: "Pin this copy to the version it is. Overrides auto_update, `devp update \
762 --install`, `devp install --channel` and the install scripts.",
763 plain: "Stay on exactly this version. Nothing dev-prune does replaces the binary \
764 while this is on -- not the automatic update, not a re-run of the install \
765 one-liner.",
766 get: |s| s.version_lock.to_string(),
767 set: |s, v| {
768 s.version_lock = parse_bool("version_lock", v)?;
769 Ok(())
770 },
771 },
772 Setting {
773 key: "disabled_adapters",
774 category: Category::Scope,
775 since: "1.4.0",
776 kind: Kind::Adapters,
777 help: "Adapters to leave alone entirely, by name. Empty means every one of them is active.",
778 plain: "Ecosystems to ignore completely — as if you did not have them installed at all.",
779 get: |s| {
780 if s.disabled_adapters.is_empty() {
781 "(none)".to_string()
782 } else {
783 s.disabled_adapters.join(",")
784 }
785 },
786 set: |s, v| {
787 s.disabled_adapters = parse_adapter_list(v)?;
788 Ok(())
789 },
790 },
791 Setting {
792 key: "adapter_idle_days",
793 category: Category::Scope,
794 since: "1.5.0",
795 kind: Kind::AdapterDays,
796 help: "Per-adapter idle windows, as `cargo=60,npm=30`. Each one can only raise its own wait.",
797 plain: "A different waiting period for one ecosystem. Useful when your Rust projects should wait longer than your Node ones.",
798 get: |s| {
799 if s.adapter_idle_days.is_empty() {
800 "(none)".to_string()
801 } else {
802 s.adapter_idle_days
803 .iter()
804 .map(|(name, days)| format!("{name}={days}"))
805 .collect::<Vec<_>>()
806 .join(",")
807 }
808 },
809 set: |s, v| {
810 s.adapter_idle_days = parse_adapter_days(v)?;
811 Ok(())
812 },
813 },
814 Setting {
815 key: "cache_max_gb",
816 category: Category::Caches,
817 since: "1.8.0",
818 kind: Kind::CacheCaps,
819 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`.",
820 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.",
821 get: |s| {
822 if s.cache_max_gb.is_empty() {
823 "(none)".to_string()
824 } else {
825 s.cache_max_gb
826 .iter()
827 .map(|(name, gb)| format!("{name}={gb}"))
828 .collect::<Vec<_>>()
829 .join(",")
830 }
831 },
832 set: |s, v| {
833 s.cache_max_gb = parse_cache_caps(v)?;
834 Ok(())
835 },
836 },
837];
838
839fn parse_cache_caps(value: &str) -> Result<std::collections::BTreeMap<String, u64>> {
860 let trimmed = value.trim();
861 if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
862 return Ok(std::collections::BTreeMap::new());
863 }
864
865 let mut caps = std::collections::BTreeMap::new();
866 for raw in trimmed.split(',') {
867 let entry = raw.trim();
868 if entry.is_empty() {
869 continue;
870 }
871 let Some((name, value)) = entry.split_once('=') else {
872 bail!("`{entry}` must be written as `<manager>=<gib>`, for example `uv=10`.");
873 };
874 let name = name.trim().to_lowercase();
875 if name != crate::constants::CACHE_CAP_DEFAULT_KEY
876 && !crate::commands::caches::is_cache_manager(&name)
877 {
878 bail!(
879 "`{name}` is not a manager dev-prune knows a cache for. Valid names: {}. \
880 `{}` caps every manager that is not named separately.",
881 crate::commands::caches::known_managers().join(", "),
882 crate::constants::CACHE_CAP_DEFAULT_KEY
883 );
884 }
885 let parsed: u64 = value.trim().parse().map_err(|_| {
886 anyhow::anyhow!(
887 "`{name}` needs a whole number of gibibytes, not `{}`.",
888 value.trim()
889 )
890 })?;
891 if parsed == 0 {
892 bail!(
893 "`{name}=0` would call the cache too big the moment it exists. Use `-` to clear the caps instead."
894 );
895 }
896 caps.insert(name, parsed);
897 }
898 Ok(caps)
899}
900
901fn parse_adapter_days(value: &str) -> Result<std::collections::BTreeMap<String, u64>> {
906 let trimmed = value.trim();
907 if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
908 return Ok(std::collections::BTreeMap::new());
909 }
910
911 let mut days = std::collections::BTreeMap::new();
912 for raw in trimmed.split(',') {
913 let entry = raw.trim();
914 if entry.is_empty() {
915 continue;
916 }
917 let Some((name, value)) = entry.split_once('=') else {
918 bail!("`{entry}` must be written as `<adapter>=<days>`, for example `cargo=60`.");
919 };
920 let name = name.trim().to_lowercase();
921 if !crate::adapters::is_adapter_name(&name) {
922 bail!(
923 "`{name}` is not an adapter. Valid names: {}",
924 crate::adapters::all_adapter_names().join(", ")
925 );
926 }
927 let parsed: u64 = value.trim().parse().map_err(|_| {
928 anyhow::anyhow!(
929 "`{name}` needs a whole number of days, not `{}`.",
930 value.trim()
931 )
932 })?;
933 days.insert(name, parsed);
934 }
935 Ok(days)
936}
937
938fn parse_adapter_list(value: &str) -> Result<Vec<String>> {
939 let trimmed = value.trim();
940 if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
943 return Ok(Vec::new());
944 }
945
946 let mut names: Vec<String> = Vec::new();
947 for raw in trimmed.split(',') {
948 let name = raw.trim().to_lowercase();
949 if name.is_empty() {
950 continue;
951 }
952 if !crate::adapters::is_adapter_name(&name) {
953 bail!(
954 "`{name}` is not an adapter. Valid names: {}",
955 crate::adapters::all_adapter_names().join(", ")
956 );
957 }
958 if !names.contains(&name) {
959 names.push(name);
960 }
961 }
962 Ok(names)
963}
964
965fn parse_bool(key: &str, value: &str) -> Result<bool> {
966 match value.trim().to_lowercase().as_str() {
967 "true" | "yes" | "y" | "on" | "1" => Ok(true),
968 "false" | "no" | "n" | "off" | "0" => Ok(false),
969 _ => bail!("{key} must be true or false"),
970 }
971}
972
973pub fn invalid_settings(settings: &Settings) -> Vec<(&'static str, String)> {
984 SETTINGS
985 .iter()
986 .filter_map(|setting| {
987 let mut probe = settings.clone();
988 (setting.set)(&mut probe, &(setting.get)(settings))
989 .err()
990 .map(|e| (setting.key, e.to_string()))
991 })
992 .collect()
993}
994
995pub fn setting_count() -> usize {
997 SETTINGS.len()
998}
999
1000fn save_settings_edits<'a>(edits: impl Iterator<Item = (&'a str, &'a str)>) -> Result<()> {
1009 let mut fresh = Registry::load()?;
1010 for (key, value) in edits {
1011 (find_setting(key)?.set)(&mut fresh.settings, value)?;
1012 }
1013 fresh.save()
1014}
1015
1016fn find_setting(key: &str) -> Result<&'static Setting> {
1017 SETTINGS
1018 .iter()
1019 .find(|s| s.key == key)
1020 .ok_or_else(|| anyhow::anyhow!("Unknown config key: {key}. Valid keys: {}", valid_keys()))
1021}
1022
1023fn valid_keys() -> String {
1024 SETTINGS
1025 .iter()
1026 .map(|s| s.key)
1027 .collect::<Vec<_>>()
1028 .join(", ")
1029}
1030
1031#[derive(Debug, PartialEq, Eq)]
1033pub enum Toggle {
1034 Enable,
1035 Disable,
1036 Status,
1037}
1038
1039pub fn parse_toggle(action: &str) -> Result<Toggle> {
1049 match action.to_lowercase().as_str() {
1050 "enable" | "install" | "on" => Ok(Toggle::Enable),
1051 "disable" | "uninstall" | "remove" | "off" => Ok(Toggle::Disable),
1052 "" | "status" | "show" => Ok(Toggle::Status),
1053 other => bail!(
1054 "Unknown action `{other}`. Expected `enable`, `disable` or `status` \
1055 (`install` / `uninstall` / `on` / `off` also work)."
1056 ),
1057 }
1058}
1059
1060pub fn is_toggle_word(word: &str) -> bool {
1066 parse_toggle(word).is_ok() && !word.is_empty()
1067}
1068
1069fn resolve_workspace(path: &str) -> Result<std::path::PathBuf> {
1076 let raw = Path::new(path);
1077 if !raw.is_dir() {
1078 bail!(
1079 "`{path}` is neither an action nor an existing directory.\n\
1080 Expected `enable`, `disable` or `status`, or a path to a repository."
1081 );
1082 }
1083 Ok(raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf()))
1084}
1085
1086pub fn run_get(key: &str) -> Result<()> {
1088 let registry = Registry::load()?;
1089 let setting = find_setting(key)?;
1090 println!("{key} = {}", (setting.get)(®istry.settings));
1091 Ok(())
1092}
1093
1094pub fn run_set(key: &str, value: &str) -> Result<()> {
1096 let mut registry = Registry::load()?;
1097 let setting = find_setting(key)?;
1098 (setting.set)(&mut registry.settings, value)?;
1099 registry.save()?;
1100
1101 output::print_success(&format!("{key} = {}", (setting.get)(®istry.settings)));
1104
1105 if key == "language"
1109 && let Some(meta) = i18n::language(®istry.settings.language)
1110 && !meta.reviewed
1111 {
1112 output::print_info(&format!(
1113 "No native speaker has reviewed the {} translation yet. Corrections are welcome — see docs/TRANSLATIONS.md.",
1114 meta.english_name
1115 ));
1116 }
1117 Ok(())
1118}
1119
1120fn key_column_width() -> usize {
1122 SETTINGS.iter().map(|s| s.key.len()).max().unwrap_or(0)
1123}
1124
1125pub fn run_show() -> Result<()> {
1127 let registry = Registry::load()?;
1128 let width = key_column_width();
1129
1130 output::print_header("dev-prune Global Configuration");
1131 for (category, settings) in settings_by_category() {
1132 output::print_section(category.title());
1133 for setting in settings {
1134 println!(
1135 " {:<width$} = {}",
1136 setting.key,
1137 (setting.get)(®istry.settings)
1138 );
1139 }
1140 }
1141
1142 output::print_section("This machine");
1145 println!(
1146 " {:<width$} = {}",
1147 "tracked_repos",
1148 registry.repo_count()
1149 );
1150 let reg_path = Registry::registry_path()
1151 .map(|p| output::clean_path(&p))
1152 .unwrap_or_else(|_| "unknown".to_string());
1153 println!(" {:<width$} = {reg_path}", "registry_file");
1154
1155 print_recommendation_summary(®istry.settings);
1159
1160 println!();
1161 output::print_info("Change any of these with `devp config set <key> <value>`.");
1162 output::print_info("Walk through them one at a time with `devp config wizard`.");
1163
1164 Ok(())
1165}
1166
1167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1169pub enum Opened {
1170 ByRequest,
1172 OnItsOwn,
1176 FirstRun,
1179}
1180
1181fn why_this_opened(opened: Opened) -> Option<String> {
1188 if opened == Opened::ByRequest {
1189 return None;
1190 }
1191 if opened == Opened::FirstRun {
1192 return Some(
1193 "You did not ask for this screen. dev-prune opens it once, before it \
1194 installs anything, so that saying yes is something you do rather than \
1195 something that happens to you. Finish the walkthrough and the items under \
1196 \"What finishing this walkthrough installs\" are set up, honouring \
1197 whatever you switch off on the way; quit (q) and nothing is installed. \
1198 Whatever you typed runs as soon as you leave."
1199 .to_string(),
1200 );
1201 }
1202 let new = settings_added_since_review().len();
1203 Some(if reviewed_version().is_none() || new == 0 {
1204 "You did not ask for this screen. dev-prune opens it once, on the first command \
1205 after it is installed, so that you see what its defaults do before they start \
1206 doing it. Whatever you typed runs as soon as you leave. It will not open by \
1207 itself again unless an upgrade adds a setting."
1208 .to_string()
1209 } else {
1210 format!(
1211 "You did not ask for this screen. This upgrade added {new} {}, and dev-prune \
1212 shows a new one once before its default goes on applying. Nothing else about \
1213 your configuration changed. Whatever you typed runs as soon as you leave.",
1214 output::plural(new, "setting", "settings"),
1215 )
1216 })
1217}
1218
1219pub fn run_wizard(no_tui: bool, opened: Opened) -> Result<WizardEnd> {
1230 if !no_tui && full_screen_is_usable() {
1231 return run_wizard_tui(opened);
1232 }
1233 run_wizard_prompts(opened)
1234}
1235
1236fn full_screen_is_usable() -> bool {
1244 use std::io::IsTerminal;
1245 if std::env::var_os(crate::constants::ENV_NO_TUI).is_some() {
1246 return false;
1247 }
1248 std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
1249}
1250
1251#[derive(Clone, Copy, PartialEq, Eq)]
1254pub enum WizardEnd {
1255 Completed,
1257 Cancelled,
1259}
1260
1261fn run_wizard_tui(opened: Opened) -> Result<WizardEnd> {
1263 use crate::tui::config_view::{ConfigRow, ConfigSession, Control, Outcome};
1264
1265 let registry = Registry::load()?;
1266 let new_keys = settings_added_since_review();
1267 let fresh = Settings::default();
1271
1272 let rows: Vec<ConfigRow> = settings_by_category()
1275 .into_iter()
1276 .flat_map(|(category, settings)| {
1277 settings.into_iter().map(move |setting| (category, setting))
1278 })
1279 .map(|(category, setting)| {
1280 let value = (setting.get)(®istry.settings);
1281 ConfigRow {
1282 key: setting.key,
1283 category: category.title(),
1284 help: setting.help,
1285 plain: setting.plain,
1286 control: match setting.kind {
1287 Kind::Toggle => Control::Toggle,
1288 Kind::Choice => Control::Choice(i18n::choices()),
1289 Kind::Number => Control::Number,
1290 Kind::Adapters => Control::Adapters,
1291 Kind::AdapterDays => Control::AdapterDays,
1292 Kind::CacheCaps => Control::CacheCaps,
1293 },
1294 original: value.clone(),
1295 default: (setting.get)(&fresh),
1296 recommended: recommended_value(setting.key),
1297 cautious: recommendation(setting.key).is_some_and(|r| r.cautious),
1298 value,
1299 is_new: new_keys.contains(&setting.key),
1300 }
1301 })
1302 .collect();
1303
1304 let base = registry.settings.clone();
1307 let validate = move |key: &str, value: &str| -> std::result::Result<(), String> {
1308 let setting = find_setting(key).map_err(|e| e.to_string())?;
1309 let mut probe = base.clone();
1310 (setting.set)(&mut probe, value).map_err(|e| format!("{e}"))
1311 };
1312
1313 let report = crate::commands::trust::build(®istry);
1314 let adapters = crate::adapters::all_adapter_names();
1315 let opt_in = crate::adapters::opt_in_adapter_names();
1316 let capped: Vec<&'static str> = adapters
1319 .iter()
1320 .copied()
1321 .filter(|name| crate::commands::caches::is_cache_manager(name))
1322 .collect();
1323
1324 let why = why_this_opened(opened);
1325 let mut declaration = declaration_lines(&report);
1326 if opened == Opened::FirstRun {
1327 use crate::tui::config_view::DeclarationLine;
1328 let line = |mark: char, subject: &str, state: String| DeclarationLine {
1329 mark,
1330 subject: subject.to_string(),
1331 state,
1332 };
1333 declaration.push(line('#', "", String::new()));
1334 declaration.push(line(
1335 '#',
1336 "What finishing this walkthrough installs",
1337 String::new(),
1338 ));
1339 for (subject, state) in setup_preview_lines() {
1340 declaration.push(line('!', subject, state));
1341 }
1342 declaration.push(line(
1343 ' ',
1344 "Quit instead (q)",
1345 "and none of it is installed — `devp setup` installs it later".to_string(),
1346 ));
1347 }
1348 let outcome = crate::tui::config_view::run(ConfigSession {
1349 declaration,
1350 standing: NOTHING_DELETED_YET.to_string(),
1351 suggestions: first_run_suggestions(),
1352 rows,
1353 adapters: &adapters,
1354 opt_in_adapters: &opt_in,
1355 capped_adapters: &capped,
1356 groups: crate::adapters::ADAPTER_GROUPS,
1357 validate: &validate,
1358 title: "dev-prune configuration",
1359 uninvited: why.as_deref(),
1360 })?;
1361
1362 match outcome {
1363 Outcome::Cancelled => {
1367 output::print_info("Cancelled — nothing was changed.");
1368 Ok(WizardEnd::Cancelled)
1369 }
1370 Outcome::KeepAll => {
1371 mark_reviewed();
1372 output::print_success(
1373 "Keeping the current values. `devp config set <key> <value>` changes any.",
1374 );
1375 Ok(WizardEnd::Completed)
1376 }
1377 Outcome::Save(changed) => {
1378 save_settings_edits(changed.iter().map(|row| (row.key, row.value.as_str())))?;
1379 mark_reviewed();
1380
1381 output::print_header("Saved");
1385 let width = changed.iter().map(|r| r.key.len()).max().unwrap_or(0);
1386 for row in &changed {
1387 println!(
1388 " {:<width$} = {} (was {})",
1389 row.key, row.value, row.original
1390 );
1391 }
1392 println!();
1393 output::print_success(&format!(
1394 "{} {} saved. `devp config show` lists every setting.",
1395 changed.len(),
1396 output::plural(changed.len(), "change", "changes")
1397 ));
1398 Ok(WizardEnd::Completed)
1399 }
1400 }
1401}
1402
1403fn setup_preview_lines() -> Vec<(&'static str, String)> {
1410 vec![
1411 (
1412 "Command on PATH",
1413 "a managed copy of the binary, as both `dev-prune` and `devp`".to_string(),
1414 ),
1415 (
1416 "AI agent skills",
1417 "SKILL.md instructions and file icons, in dev-prune's own config directory".to_string(),
1418 ),
1419 (
1420 "Git hooks",
1421 "a hook that registers repositories as you work in them (while `auto_hooks` is on)"
1422 .to_string(),
1423 ),
1424 (
1425 "Background schedule",
1426 format!(
1427 "runs `devp run --yes` every {} days — pruning without asking (while \
1428 `auto_daemon` is on)",
1429 Settings::default().check_interval_days
1430 ),
1431 ),
1432 (
1433 "Removed again by",
1434 "`devp uninstall`, which takes all of it back out".to_string(),
1435 ),
1436 ]
1437}
1438
1439pub enum FirstRunDecision {
1441 Accepted,
1443 Declined,
1445 NoAnswer,
1447}
1448
1449pub fn first_run_wizard() -> Result<FirstRunDecision> {
1457 if full_screen_is_usable() {
1458 return Ok(match run_wizard_tui(Opened::FirstRun)? {
1459 WizardEnd::Completed => FirstRunDecision::Accepted,
1460 WizardEnd::Cancelled => FirstRunDecision::Declined,
1461 });
1462 }
1463
1464 use std::io::Write;
1465 output::print_header("dev-prune setup");
1466 output::print_info("dev-prune is installed but not set up. Saying yes installs:");
1467 for (subject, state) in setup_preview_lines() {
1468 println!(" {} {state}", output::pad_display(subject, 22));
1469 }
1470 println!();
1471 print!("Set up now, starting with the settings walkthrough? [y/N] ");
1472 std::io::stdout().flush()?;
1473 let mut line = String::new();
1474 if std::io::stdin().read_line(&mut line)? == 0 {
1475 println!();
1476 return Ok(FirstRunDecision::NoAnswer);
1477 }
1478 let answer = line.trim();
1479 if answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
1480 run_wizard_prompts(Opened::FirstRun)?;
1481 return Ok(FirstRunDecision::Accepted);
1482 }
1483 Ok(FirstRunDecision::Declined)
1484}
1485
1486fn recommended_value(key: &str) -> Option<&'static str> {
1503 recommendation(key).map(|r| r.value)
1504}
1505
1506fn recommendation(key: &str) -> Option<&'static Recommendation> {
1508 RECOMMENDED.iter().find(|r| r.key == key)
1509}
1510
1511fn already_taken(rec: &Recommendation, current: &str) -> bool {
1517 match rec.taken {
1518 Some(is_taken) => is_taken(current),
1519 None => current == rec.value,
1520 }
1521}
1522
1523fn outstanding(settings: &Settings) -> Vec<&'static Recommendation> {
1525 RECOMMENDED
1526 .iter()
1527 .filter(|r| {
1528 find_setting(r.key)
1529 .map(|s| (s.get)(settings))
1530 .is_ok_and(|current| !already_taken(r, ¤t))
1531 })
1532 .collect()
1533}
1534
1535fn print_recommendation_summary(settings: &Settings) {
1542 let outstanding = outstanding(settings);
1543 if outstanding.is_empty() {
1544 return;
1545 }
1546 let width = key_column_width();
1547
1548 let safe: Vec<_> = outstanding.iter().filter(|r| !r.cautious).collect();
1549 if !safe.is_empty() {
1550 output::print_section(SAFE_TIER);
1551 for r in &safe {
1552 println!(" {:<width$} = {} {}", r.key, r.value, r.label);
1553 }
1554 println!();
1555 output::print_info(&format!(
1556 "`devp config recommended` sets {} {} in one command.",
1557 safe.len(),
1558 output::plural(safe.len(), "setting", "settings")
1559 ));
1560 }
1561
1562 let cautious: Vec<_> = outstanding.iter().filter(|r| r.cautious).collect();
1563 if !cautious.is_empty() {
1564 output::print_section(CAUTIOUS_TIER);
1565 for r in &cautious {
1566 println!(" {:<width$} = {} {}", r.key, r.value, r.label);
1567 println!(" {:<width$} {}", "", r.why);
1568 }
1569 println!();
1570 output::print_info(
1571 "Not included above. `devp config recommended --with-cautious` includes it; \
1572 `devp config set <key> <value>` sets one on its own.",
1573 );
1574 }
1575}
1576
1577pub fn run_recommended(with_cautious: bool) -> Result<()> {
1591 let mut registry = Registry::load()?;
1592 let width = key_column_width();
1593
1594 output::print_header("dev-prune recommended settings");
1595
1596 let mut applied: Vec<(&'static str, String, &'static str)> = Vec::new();
1597 let mut already: Vec<&'static Recommendation> = Vec::new();
1598 let mut held_back: Vec<&'static Recommendation> = Vec::new();
1599
1600 for rec in RECOMMENDED {
1601 let setting = find_setting(rec.key)?;
1602 let current = (setting.get)(®istry.settings);
1603 if already_taken(rec, ¤t) {
1604 already.push(rec);
1605 } else if rec.cautious && !with_cautious {
1606 held_back.push(rec);
1607 } else {
1608 (setting.set)(&mut registry.settings, rec.value)?;
1609 applied.push((rec.key, current, rec.value));
1610 }
1611 }
1612
1613 if !applied.is_empty() {
1614 registry.save()?;
1615 output::print_section("Turned on");
1616 for (key, from, to) in &applied {
1617 println!(" {:<width$} {from} → {to}", key);
1618 }
1619 }
1620 if !already.is_empty() {
1621 output::print_section("Already set");
1622 for rec in &already {
1623 println!(" {:<width$} {}", rec.key, rec.label);
1624 }
1625 }
1626 if !held_back.is_empty() {
1627 output::print_section(CAUTIOUS_TIER);
1628 for rec in &held_back {
1629 println!(" {:<width$} = {} {}", rec.key, rec.value, rec.label);
1630 println!(" {:<width$} {}", "", rec.why);
1631 }
1632 println!();
1633 output::print_info(
1634 "Left alone. `devp config recommended --with-cautious` includes it; \
1635 `devp config set <key> <value>` sets one on its own.",
1636 );
1637 }
1638
1639 println!();
1640 if applied.is_empty() {
1641 output::print_success(
1642 "Nothing changed — everything recommended without a caveat is already set.",
1643 );
1644 } else {
1645 output::print_success(&format!(
1646 "{} {} changed. `devp config show` lists them all.",
1647 applied.len(),
1648 output::plural(applied.len(), "setting", "settings")
1649 ));
1650 }
1651 Ok(())
1652}
1653
1654fn first_run_suggestions() -> Vec<crate::tui::config_view::Suggestion> {
1655 use crate::tui::config_view::Suggestion;
1656
1657 if reviewed_version().is_some() {
1658 return Vec::new();
1659 }
1660 RECOMMENDED
1661 .iter()
1662 .filter_map(|r| {
1663 let setting = find_setting(r.key).ok()?;
1664 Some(Suggestion {
1665 key: r.key,
1666 label: r.label,
1667 help: setting.help,
1668 plain: setting.plain,
1669 why: r.why,
1670 value: r.value,
1671 cautious: r.cautious,
1672 })
1673 })
1674 .collect()
1675}
1676
1677const NOTHING_DELETED_YET: &str =
1679 "Nothing has been deleted, and nothing will be until a lockfile proves it comes back.";
1680
1681fn provenance_rows() -> Vec<(&'static str, String)> {
1698 let url = |u: &str| u.trim_start_matches("https://").to_string();
1701 vec![
1702 (
1703 "What you are running",
1704 format!(
1705 "{} v{}",
1706 crate::constants::APP_NAME,
1707 crate::constants::VERSION
1708 ),
1709 ),
1710 (
1711 "Written by",
1712 format!("{}, under Apache-2.0", crate::constants::AUTHOR),
1713 ),
1714 ("Source code", url(crate::constants::REPO_URL)),
1715 (
1716 "Official downloads",
1717 format!("{} · GitHub releases", url(crate::constants::HOMEPAGE_URL)),
1718 ),
1719 (
1720 "Package registries",
1721 "crates.io · PyPI · npm, all named dev-prune".to_string(),
1722 ),
1723 (
1724 "Editor extension",
1725 "VS Code Marketplace · Open VSX".to_string(),
1726 ),
1727 (
1728 "Any other source",
1729 "is not a copy the author published".to_string(),
1730 ),
1731 ]
1732}
1733
1734fn declaration_lines(
1740 report: &crate::commands::trust::TrustReport,
1741) -> Vec<crate::tui::config_view::DeclarationLine> {
1742 use crate::commands::trust::{TrustRow, Verdict};
1743 use crate::tui::config_view::DeclarationLine;
1744
1745 let heading = |text: &str| DeclarationLine {
1746 mark: '#',
1747 subject: text.to_string(),
1748 state: String::new(),
1749 };
1750 let row = |r: &TrustRow| DeclarationLine {
1751 mark: match r.verdict {
1752 Verdict::Guaranteed | Verdict::Safe => '+',
1753 Verdict::Widened => '!',
1754 Verdict::Neutral => ' ',
1755 },
1756 subject: r.subject.to_string(),
1757 state: r.state.clone(),
1758 };
1759
1760 let mut lines = vec![heading("What this is, and where it came from")];
1761 lines.extend(
1762 provenance_rows()
1763 .into_iter()
1764 .map(|(subject, state)| DeclarationLine {
1765 mark: ' ',
1766 subject: subject.to_string(),
1767 state,
1768 }),
1769 );
1770 lines.push(heading(""));
1771 lines.push(heading("Guaranteed by the code"));
1772 lines.extend(report.guarantees.iter().map(&row));
1773 lines.push(heading(""));
1774 lines.push(heading("On this machine"));
1775 lines.extend(report.machine.iter().map(&row));
1776 lines
1777}
1778
1779fn run_wizard_prompts(opened: Opened) -> Result<WizardEnd> {
1783 use std::io::{self, IsTerminal, Write};
1784
1785 if !io::stdin().is_terminal() {
1786 bail!(
1787 "`devp config wizard` needs a terminal to ask questions on.\n\
1788 Use `devp config show` to read the settings and `devp config set <key> <value>` \
1789 to change one."
1790 );
1791 }
1792
1793 let mut registry = Registry::load()?;
1794 let width = key_column_width();
1795 let new_keys = settings_added_since_review();
1796 let fresh = Settings::default();
1797
1798 output::print_header("dev-prune configuration");
1799 if let Some(why) = why_this_opened(opened) {
1802 output::print_warning(&why);
1803 println!();
1804 }
1805 output::print_section("What this is, and where it came from");
1806 for (subject, state) in provenance_rows() {
1807 println!(" {} {state}", output::pad_display(subject, 22));
1808 }
1809 println!(" {}", crate::constants::LICENCE_NOTICE);
1810 println!();
1811
1812 output::print_info("These are the defaults every run will use. Nothing has been changed yet.");
1813 println!();
1814 for (category, settings) in settings_by_category() {
1815 output::print_section(category.title());
1816 for setting in settings {
1817 let badge = if new_keys.contains(&setting.key) {
1820 " (new in this version)"
1821 } else {
1822 ""
1823 };
1824 println!(
1825 " {:<width$} = {}{badge}",
1826 setting.key,
1827 (setting.get)(®istry.settings)
1828 );
1829 println!(" {:<width$} {}", "", setting.help);
1830 println!(" {:<width$} {}", "", setting.plain);
1833 let mut facts = format!("default {}", (setting.get)(&fresh));
1837 if let Some(rec) = recommendation(setting.key) {
1841 facts.push_str(&format!(
1842 " · recommended {} ({}, not required)",
1843 rec.value,
1844 if rec.cautious {
1845 "read the note below first"
1846 } else {
1847 "suggested"
1848 }
1849 ));
1850 }
1851 println!(" {:<width$} {facts}", "");
1852 if let Some(rec) = recommendation(setting.key).filter(|r| r.cautious) {
1853 println!(" {:<width$} {}", "", rec.why);
1854 }
1855 }
1856 }
1857 println!();
1858
1859 print_recommendation_summary(®istry.settings);
1860 println!();
1861
1862 if confirmed_twice("Press Enter twice to keep all of these, or type anything to change them: ")?
1867 {
1868 mark_reviewed();
1869 output::print_success("Keeping the defaults. `devp config set <key> <value>` changes any.");
1870 return Ok(WizardEnd::Completed);
1871 }
1872
1873 println!();
1874 output::print_info("Enter a new value, or press Enter to keep the one shown.");
1875 println!();
1876
1877 let mut edits: Vec<(&'static str, String, String)> = Vec::new();
1878 let mut input_ended = false;
1879 for setting in SETTINGS {
1880 if input_ended {
1881 break;
1882 }
1883 let current = (setting.get)(®istry.settings);
1884 loop {
1885 print!(" {} [{current}]: ", setting.key);
1886 io::stdout().flush()?;
1887 let mut line = String::new();
1888 if io::stdin().read_line(&mut line)? == 0 {
1891 println!();
1892 input_ended = true;
1893 break;
1894 }
1895 let typed = line.trim();
1896 if typed.is_empty() {
1897 break;
1898 }
1899 match (setting.set)(&mut registry.settings, typed) {
1900 Ok(()) => {
1901 let now = (setting.get)(®istry.settings);
1905 if now != current {
1906 edits.push((setting.key, current.clone(), now));
1907 }
1908 break;
1909 }
1910 Err(e) => output::print_error(&format!("{e}")),
1913 }
1914 }
1915 }
1916
1917 println!();
1918 if edits.is_empty() {
1919 if input_ended {
1923 output::print_info("Input ended — nothing was changed.");
1924 return Ok(WizardEnd::Cancelled);
1925 }
1926 mark_reviewed();
1927 output::print_success("Nothing changed — the defaults are in place.");
1928 return Ok(WizardEnd::Completed);
1929 }
1930
1931 output::print_section("About to be saved");
1934 for (key, from, to) in &edits {
1935 println!(" {:<width$} {from} → {to}", key);
1936 }
1937 println!();
1938 if !confirmed_twice("Press Enter twice to save, or type anything to abandon: ")? {
1939 output::print_info("Nothing was written.");
1940 return Ok(WizardEnd::Cancelled);
1941 }
1942
1943 save_settings_edits(edits.iter().map(|(key, _, to)| (*key, to.as_str())))?;
1944 mark_reviewed();
1945 let changed = edits.len();
1946 println!();
1947 output::print_success(&format!(
1948 "Saved {changed} {}. `devp config show` lists them all.",
1949 output::plural(changed, "change", "changes")
1950 ));
1951 Ok(WizardEnd::Completed)
1952}
1953
1954fn confirmed_twice(prompt: &str) -> Result<bool> {
1963 use std::io::{self, Write};
1964
1965 for pass in 0..2 {
1966 print!(
1967 "{}",
1968 if pass == 0 {
1969 prompt
1970 } else {
1971 "Press Enter once more to confirm: "
1972 }
1973 );
1974 io::stdout().flush()?;
1975 let mut line = String::new();
1976 if io::stdin().read_line(&mut line)? == 0 {
1977 println!();
1978 return Ok(false);
1979 }
1980 if !line.trim().is_empty() {
1981 return Ok(false);
1982 }
1983 }
1984 Ok(true)
1985}
1986
1987const REVIEW_MARKER: &str = "config-reviewed";
1989
1990pub fn config_review_is_due() -> bool {
1999 let Ok(dir) = Registry::config_dir() else {
2000 return false;
2001 };
2002 if !dir.join(REVIEW_MARKER).exists() {
2003 return true;
2004 }
2005 !settings_added_since_review().is_empty()
2006}
2007
2008fn reviewed_version() -> Option<String> {
2010 let dir = Registry::config_dir().ok()?;
2011 let recorded = std::fs::read_to_string(dir.join(REVIEW_MARKER)).ok()?;
2012 let recorded = recorded.trim().to_string();
2013 (!recorded.is_empty()).then_some(recorded)
2014}
2015
2016pub fn settings_added_since_review() -> Vec<&'static str> {
2025 let Some(reviewed) = reviewed_version() else {
2026 return Vec::new();
2027 };
2028 SETTINGS
2029 .iter()
2030 .filter(|s| {
2031 crate::commands::update::compare_versions(s.since, &reviewed)
2032 == Some(std::cmp::Ordering::Greater)
2033 })
2034 .map(|s| s.key)
2035 .collect()
2036}
2037
2038fn mark_reviewed() {
2039 if let Ok(dir) = Registry::config_dir() {
2040 let _ = std::fs::create_dir_all(&dir);
2041 let _ = std::fs::write(dir.join(REVIEW_MARKER), crate::constants::VERSION);
2042 }
2043}
2044
2045pub fn skip_config_review() {
2050 mark_reviewed();
2051}
2052
2053pub fn run_global_update() -> Result<()> {
2055 output::print_header("dev-prune Global Configuration Audit & Sync");
2056
2057 let registry = Registry::load()?;
2058 let mut total_audited = 0;
2059 let mut errors_found = 0;
2060
2061 for repo_path in registry.repositories.keys() {
2062 let clean = output::clean_path(repo_path);
2063
2064 if !repo_path.exists() {
2068 output::print_warning(&format!(
2069 "Skipped {clean} — the path no longer exists. `devp unlink --missing` \
2070 clears such entries."
2071 ));
2072 continue;
2073 }
2074 total_audited += 1;
2075
2076 match PerRepoConfig::load_personal_for_write(repo_path) {
2077 Ok(Some(cfg)) => {
2078 if let Err(e) = cfg.save_to_repo(repo_path) {
2079 output::print_error(&format!("Failed to write config for {clean}: {e}"));
2080 errors_found += 1;
2081 } else {
2082 output::print_success(&format!("Audited & synced config for {clean}"));
2083 }
2084 }
2085 Ok(None) => {
2086 output::print_info(&format!(
2090 "{clean} has no .devprune.json — global defaults apply."
2091 ));
2092 }
2093 Err(err_msg) => {
2094 errors_found += 1;
2095 output::print_error(&format!("Syntax/Schema Error in {clean}:"));
2096 for line in err_msg.lines() {
2097 eprintln!(" {line}");
2098 }
2099 output::print_info(&format!(
2100 "Hint: fix the syntax by hand, or run `devp config {clean} --update` to \
2101 replace the file with a valid default."
2102 ));
2103 }
2104 }
2105 }
2106
2107 if errors_found > 0 {
2108 anyhow::bail!(
2111 "Audit complete: {total_audited} repos checked, {errors_found} could not be read \
2112 or written."
2113 );
2114 }
2115 output::print_success(&format!(
2116 "Audit complete: All {total_audited} registered repositories are healthy & synced!"
2117 ));
2118
2119 Ok(())
2120}
2121
2122pub fn run_path_config(path_str: &str, force_update: bool, team: bool) -> Result<()> {
2127 let raw_path = Path::new(path_str);
2128
2129 let path = if raw_path.exists() {
2130 raw_path
2131 .canonicalize()
2132 .unwrap_or_else(|_| raw_path.to_path_buf())
2133 } else {
2134 raw_path.to_path_buf()
2135 };
2136
2137 let clean = output::clean_path(&path);
2138
2139 if !path.exists() {
2140 bail!("Path does not exist: {clean}");
2141 }
2142
2143 if !crate::scanner::is_git_repo(&path) {
2144 bail!(
2146 "`{clean}` is not a Git repository.\n \
2147 Run `git init` there first, then `devp config {clean}` again."
2148 );
2149 }
2150
2151 let mut registry = Registry::load()?;
2152 if !registry.repositories.contains_key(&path) {
2153 output::print_info(&format!(
2154 "{clean} is not yet registered with dev-prune. Registering now..."
2155 ));
2156 registry.add_repo(path.clone());
2157 registry.save()?;
2158 }
2159
2160 let name = if team {
2161 crate::constants::PROJECT_REPO_CONFIG_FILE
2162 } else {
2163 crate::constants::PER_REPO_CONFIG_FILE
2164 };
2165 let cfg_file = path.join(name);
2166
2167 if cfg_file.exists() && !force_update {
2168 output::print_header(&format!("dev-prune Per-Repo Config for {clean}"));
2169 match crate::config::RepoConfigLayers::load(&path) {
2170 Ok(layers) => {
2171 let addressed = if team {
2172 layers.project_config()
2173 } else {
2174 layers.personal_config()
2175 };
2176 println!("{}", serde_json::to_string_pretty(&addressed)?);
2177 output::print_info(&format!("File location: {name}"));
2178 print_layer_provenance(&layers);
2179 }
2180 Err(err_msg) => {
2181 output::print_error(&format!("Invalid configuration in {clean}:"));
2182 for line in err_msg.lines() {
2183 eprintln!(" {line}");
2184 }
2185 anyhow::bail!(
2188 "Run `devp config {clean} --update` to reset this file back to defaults \
2189 (your current overrides in it are discarded)."
2190 );
2191 }
2192 }
2193 } else {
2194 output::print_info(&format!("Initializing {name} for {clean}..."));
2195 if team {
2196 crate::config::write_project_starter(&path)?;
2197 } else {
2198 PerRepoConfig::default().save_to_repo(&path)?;
2199 }
2200 output::print_success(&format!("Created {name} in {clean}"));
2201 if team {
2202 output::print_info(
2203 "It starts empty on purpose: every key it names overrules \
2204 `.devprune.json`, so it should only name the ones your team decides.",
2205 );
2206 output::print_info(
2207 "`prunable.directories` is the exception — the two files' lists add \
2208 up, so naming one here never discards somebody's own.",
2209 );
2210 output::print_info(
2211 "Commit it. Unlike `.devprune.json`, this file is not added to \
2212 `.git/info/exclude` — being shared is the whole reason it exists.",
2213 );
2214 }
2215 }
2216
2217 Ok(())
2218}
2219
2220fn print_layer_provenance(layers: &crate::config::RepoConfigLayers) {
2228 if layers.project_config().is_none() || layers.personal_config().is_none() {
2229 return;
2230 }
2231 output::print_section("Effective values");
2232 for (key, value, source) in layers.rows() {
2233 println!(
2234 " {} {} {}",
2235 output::pad_display(key, 20),
2236 output::pad_display(&value, 14),
2237 source.label()
2238 );
2239 }
2240}
2241
2242fn load_workspace_config_for_write(repo_path: &Path) -> Result<PerRepoConfig> {
2248 match PerRepoConfig::load_personal_for_write(repo_path) {
2249 Ok(Some(cfg)) => Ok(cfg),
2250 Ok(None) => Ok(PerRepoConfig::default()),
2251 Err(e) => bail!(
2252 "{e}\n \
2253 Fix that file, or run `devp config {} --update` to reset it back to defaults \
2254 (your current overrides in it are discarded).",
2255 output::clean_path(repo_path)
2256 ),
2257 }
2258}
2259
2260pub fn run_daemon_toggle(path: Option<&str>, action: &str) -> Result<()> {
2262 if let Some(p) = path {
2263 let repo_path = resolve_workspace(p)?;
2264 let mut cfg = load_workspace_config_for_write(&repo_path)?;
2265 match parse_toggle(action)? {
2266 Toggle::Enable => {
2267 cfg.disable_daemon = false;
2268 cfg.save_to_repo(&repo_path)?;
2269 output::print_success(&format!(
2270 "Enabled background daemon for workspace: {}",
2271 output::clean_path(&repo_path)
2272 ));
2273 }
2274 Toggle::Disable => {
2275 cfg.disable_daemon = true;
2276 cfg.save_to_repo(&repo_path)?;
2277 output::print_success(&format!(
2278 "Disabled background daemon for workspace: {}",
2279 output::clean_path(&repo_path)
2280 ));
2281 }
2282 Toggle::Status => {
2283 let st = if cfg.disable_daemon {
2284 "Disabled for workspace"
2285 } else {
2286 "Enabled for workspace"
2287 };
2288 output::print_info(&format!(
2289 "Daemon Status ({}): {}",
2290 output::clean_path(&repo_path),
2291 st
2292 ));
2293 }
2294 }
2295 } else {
2296 match parse_toggle(action)? {
2297 Toggle::Enable => crate::commands::daemon::run_install()?,
2298 Toggle::Disable => crate::commands::daemon::run_uninstall()?,
2299 Toggle::Status => crate::commands::daemon::run_status()?,
2300 }
2301 }
2302 Ok(())
2303}
2304
2305pub fn run_hook_toggle(path: Option<&str>, action: &str, chain: bool) -> Result<()> {
2307 if let Some(p) = path {
2308 if chain {
2309 bail!(
2310 "`--chain` changes the single global `core.hooksPath`, so it has no \
2311 per-workspace form. Drop the path: `devp hook install --chain`."
2312 );
2313 }
2314 let repo_path = resolve_workspace(p)?;
2315 let mut cfg = load_workspace_config_for_write(&repo_path)?;
2316 match parse_toggle(action)? {
2317 Toggle::Enable => {
2318 cfg.disable_hooks = false;
2319 cfg.save_to_repo(&repo_path)?;
2320 output::print_success(&format!(
2321 "Enabled background Git hooks for workspace: {}",
2322 output::clean_path(&repo_path)
2323 ));
2324 }
2325 Toggle::Disable => {
2326 cfg.disable_hooks = true;
2327 cfg.save_to_repo(&repo_path)?;
2328 output::print_success(&format!(
2329 "Disabled background Git hooks for workspace: {}",
2330 output::clean_path(&repo_path)
2331 ));
2332 }
2333 Toggle::Status => {
2334 let st = if cfg.disable_hooks {
2335 "Disabled for workspace"
2336 } else {
2337 "Enabled for workspace"
2338 };
2339 output::print_info(&format!(
2340 "Git Hook Status ({}): {}",
2341 output::clean_path(&repo_path),
2342 st
2343 ));
2344 }
2345 }
2346 } else {
2347 match parse_toggle(action)? {
2348 Toggle::Enable => crate::commands::hook::run_install(chain)?,
2349 Toggle::Disable => crate::commands::hook::run_uninstall()?,
2350 Toggle::Status => crate::commands::hook::run_status()?,
2351 }
2352 }
2353 Ok(())
2354}
2355
2356#[cfg(test)]
2357mod tests {
2358 use super::*;
2359
2360 #[test]
2361 fn enable_synonyms_all_resolve_to_enable() {
2362 for word in ["enable", "install", "on", "INSTALL", "On"] {
2363 assert_eq!(parse_toggle(word).unwrap(), Toggle::Enable, "{word}");
2364 }
2365 }
2366
2367 #[test]
2368 fn disable_synonyms_all_resolve_to_disable() {
2369 for word in ["disable", "uninstall", "remove", "off", "Uninstall"] {
2370 assert_eq!(parse_toggle(word).unwrap(), Toggle::Disable, "{word}");
2371 }
2372 }
2373
2374 #[test]
2375 fn status_is_the_default_and_is_also_spellable() {
2376 for word in ["", "status", "show"] {
2377 assert_eq!(parse_toggle(word).unwrap(), Toggle::Status, "{word}");
2378 }
2379 }
2380
2381 #[test]
2382 fn a_typo_is_an_error_rather_than_a_silent_status_report() {
2383 let err = parse_toggle("enabel").unwrap_err().to_string();
2386 assert!(err.contains("enabel"), "{err}");
2387 assert!(err.contains("enable"), "{err}");
2388 }
2389
2390 #[test]
2391 fn a_workspace_toggle_refuses_to_write_over_a_broken_config() {
2392 let tmp = tempfile::TempDir::new().unwrap();
2395 let broken = r#"{ "project_name": "api", "override_idle_days": 90, }"#;
2396 std::fs::write(
2397 tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE),
2398 broken,
2399 )
2400 .unwrap();
2401
2402 let err = load_workspace_config_for_write(tmp.path())
2403 .unwrap_err()
2404 .to_string();
2405 assert!(err.contains("Syntax error"), "{err}");
2406 assert!(err.contains("--update"), "{err}");
2407
2408 let on_disk =
2410 std::fs::read_to_string(tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE))
2411 .unwrap();
2412 assert_eq!(on_disk, broken);
2413 }
2414
2415 #[test]
2416 fn the_suggested_cap_is_the_constant_it_claims_to_be() {
2417 let caps = parse_cache_caps(crate::constants::RECOMMENDED_CACHE_CAP).unwrap();
2422 assert_eq!(
2423 caps,
2424 std::collections::BTreeMap::from([(
2425 crate::constants::CACHE_CAP_DEFAULT_KEY.to_string(),
2426 crate::constants::RECOMMENDED_CACHE_MAX_GB,
2427 )])
2428 );
2429 }
2430
2431 #[test]
2432 fn a_ceiling_of_your_own_counts_as_having_taken_the_advice() {
2433 let rec = recommendation("cache_max_gb").expect("the cap is recommended");
2438 assert!(already_taken(rec, "npm=4"));
2439 assert!(already_taken(rec, crate::constants::RECOMMENDED_CACHE_CAP));
2440 assert!(!already_taken(rec, "(none)"));
2441
2442 let toggle = recommendation("enable_cargo").expect("cargo is recommended");
2444 assert!(already_taken(toggle, "true"));
2445 assert!(!already_taken(toggle, "false"));
2446 }
2447
2448 #[test]
2449 fn a_workspace_with_no_config_yet_starts_from_the_defaults() {
2450 let tmp = tempfile::TempDir::new().unwrap();
2451 assert_eq!(
2452 load_workspace_config_for_write(tmp.path()).unwrap(),
2453 PerRepoConfig::default()
2454 );
2455 }
2456
2457 #[test]
2458 fn a_cache_cap_is_written_the_way_it_is_read_back() {
2459 let caps = parse_cache_caps("uv=10,npm=4").unwrap();
2460 assert_eq!(caps.get("uv"), Some(&10));
2461 assert_eq!(caps.get("npm"), Some(&4));
2462 let settings = Settings {
2465 cache_max_gb: parse_cache_caps("UV = 10 , npm=4").unwrap(),
2466 ..Settings::default()
2467 };
2468 let printed = SETTINGS
2469 .iter()
2470 .find(|s| s.key == "cache_max_gb")
2471 .map(|s| (s.get)(&settings))
2472 .unwrap();
2473 assert_eq!(printed, "npm=4,uv=10");
2474 assert_eq!(parse_cache_caps(&printed).unwrap(), settings.cache_max_gb);
2475 }
2476
2477 #[test]
2478 fn clearing_the_caps_is_spelled_the_way_the_getter_prints_an_empty_map() {
2479 for blank in ["", "-", "none", "(none)", "NONE"] {
2480 assert!(
2481 parse_cache_caps(blank).unwrap().is_empty(),
2482 "`{blank}` should clear every cap"
2483 );
2484 }
2485 }
2486
2487 #[test]
2488 fn a_cap_on_something_that_is_not_a_cache_is_refused_with_the_list() {
2489 let err = parse_cache_caps("venv=10").unwrap_err().to_string();
2492 assert!(err.contains("venv"), "{err}");
2493 assert!(err.contains("npm"), "the error lists what is valid: {err}");
2494 }
2495
2496 #[test]
2497 fn a_cap_has_to_be_a_whole_number_of_gibibytes() {
2498 for bad in ["uv=10.5", "uv=ten", "uv=-1", "uv="] {
2499 assert!(parse_cache_caps(bad).is_err(), "`{bad}` was accepted");
2500 }
2501 assert!(parse_cache_caps("uv").is_err());
2504 }
2505
2506 #[test]
2507 fn a_cap_of_zero_is_refused_rather_than_stored() {
2508 let err = parse_cache_caps("uv=0").unwrap_err().to_string();
2511 assert!(
2512 err.contains("`-`"),
2513 "the error names the way to clear it: {err}"
2514 );
2515 }
2516
2517 #[test]
2518 fn every_setting_round_trips_through_its_own_getter() {
2519 let mut settings = Settings::default();
2523 for setting in SETTINGS {
2524 let before = (setting.get)(&settings);
2525 let probe = match setting.kind {
2526 Kind::Toggle => if before == "true" { "false" } else { "true" }.to_string(),
2527 Kind::Number => "7".to_string(),
2530 Kind::Adapters => "cargo".to_string(),
2533 Kind::AdapterDays => "cargo=45".to_string(),
2536 Kind::CacheCaps => "cargo=10".to_string(),
2539 Kind::Choice => "hi".to_string(),
2542 };
2543 (setting.set)(&mut settings, &probe)
2544 .unwrap_or_else(|e| panic!("{} rejected `{probe}`: {e}", setting.key));
2545 assert_eq!(
2546 (setting.get)(&settings),
2547 probe,
2548 "{} reads back a different field than it writes",
2549 setting.key
2550 );
2551 }
2552 }
2553
2554 #[test]
2555 fn every_setting_is_documented_and_uniquely_named() {
2556 let mut seen = std::collections::HashSet::new();
2557 for setting in SETTINGS {
2558 assert!(seen.insert(setting.key), "duplicate key {}", setting.key);
2559 assert!(!setting.help.is_empty(), "{} has no help", setting.key);
2560 assert!(
2561 !setting.plain.is_empty(),
2562 "{} has no plain text",
2563 setting.key
2564 );
2565 assert!(
2567 setting.help.ends_with('.'),
2568 "{} help should read as a sentence",
2569 setting.key
2570 );
2571 assert!(
2572 setting.plain.ends_with('.'),
2573 "{} plain text should read as a sentence",
2574 setting.key
2575 );
2576 assert_ne!(
2579 setting.plain, setting.help,
2580 "{} says the same thing twice",
2581 setting.key
2582 );
2583 }
2584 }
2585
2586 #[test]
2587 fn the_settings_table_covers_every_field_of_settings() {
2588 let json = serde_json::to_value(Settings::default()).unwrap();
2592 let fields: Vec<String> = json.as_object().unwrap().keys().cloned().collect();
2593 for field in fields {
2594 assert!(
2595 SETTINGS.iter().any(|s| s.key == field),
2596 "`{field}` is a setting with no entry in SETTINGS, so `devp config set \
2597 {field}` cannot reach it"
2598 );
2599 }
2600 }
2601
2602 #[test]
2603 fn a_rejected_value_leaves_the_previous_one_in_place() {
2604 let mut settings = Settings::default();
2605 assert!((find_setting("scan_depth").unwrap().set)(&mut settings, "0").is_err());
2606 assert_eq!(settings.scan_depth, Settings::default().scan_depth);
2607
2608 assert!((find_setting("command_timeout_secs").unwrap().set)(&mut settings, "0").is_err());
2609 assert!((find_setting("check_interval_days").unwrap().set)(&mut settings, "0").is_err());
2610 assert!(
2611 (find_setting("update_check_interval_days").unwrap().set)(&mut settings, "0").is_err()
2612 );
2613 }
2614
2615 #[test]
2616 fn booleans_accept_the_words_people_actually_type() {
2617 assert!(parse_bool("k", "yes").unwrap());
2618 assert!(parse_bool("k", "ON").unwrap());
2619 assert!(!parse_bool("k", "0").unwrap());
2620 assert!(parse_bool("k", "maybe").is_err());
2621 }
2622
2623 #[test]
2624 fn an_unknown_key_lists_the_ones_that_exist() {
2625 let err = match find_setting("idel_days") {
2626 Ok(_) => panic!("`idel_days` is not a setting"),
2627 Err(e) => e.to_string(),
2628 };
2629 assert!(err.contains("idle_days"), "{err}");
2630 }
2631
2632 #[test]
2633 fn a_path_is_never_mistaken_for_an_action() {
2634 assert!(!is_toggle_word("~/Code/my-repo"));
2636 assert!(!is_toggle_word("."));
2637 assert!(!is_toggle_word(""));
2638 assert!(is_toggle_word("install"));
2639 }
2640}