1use anyhow::{Result, bail};
10use std::path::Path;
11
12use crate::config::{PerRepoConfig, Registry, Settings};
13use crate::output;
14
15struct Setting {
23 key: &'static str,
24 since: &'static str,
31 kind: Kind,
33 help: &'static str,
37 plain: &'static str,
44 get: fn(&Settings) -> String,
45 set: fn(&mut Settings, &str) -> Result<()>,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54enum Kind {
55 Toggle,
57 Number,
59 Adapters,
61 CacheCaps,
67 AdapterDays,
74}
75
76struct Recommendation {
82 key: &'static str,
83 label: &'static str,
85 why: &'static str,
87 value: &'static str,
90 cautious: bool,
92}
93
94const RECOMMENDED: &[Recommendation] = &[
101 Recommendation {
102 key: "enable_cargo",
103 label: "Rust build folders",
104 why: "Rust `target/` directories are usually the largest thing on a developer's disk — \
105 tens of gigabytes across a handful of old projects. Nothing is lost: `cargo build` \
106 rebuilds it, and a project has to sit untouched for 45 days before this one is even \
107 considered.",
108 value: "true",
109 cautious: false,
110 },
111 Recommendation {
112 key: "enable_gradle",
113 label: "Android / Gradle builds",
114 why: "`build/` and `.gradle/` grow with every Android build and are never cleaned up by \
115 anything else. They come back on the next build, under the same 45-day wait.",
116 value: "true",
117 cautious: false,
118 },
119 Recommendation {
120 key: "enable_maven",
121 label: "Maven builds",
122 why: "Maven `target/` directories accumulate quietly per module, so a multi-module project \
123 has several. `mvn package` brings them back.",
124 value: "true",
125 cautious: false,
126 },
127 Recommendation {
128 key: "enable_swift",
129 label: "Swift builds",
130 why: "`.build/` holds compiled modules for every configuration you have ever built, and \
131 `swift build` recreates the one you actually use.",
132 value: "true",
133 cautious: false,
134 },
135 Recommendation {
136 key: "enable_dart",
137 label: "Dart / Flutter caches",
138 why: "`.dart_tool/` carries the pub metadata — back in a second — alongside `build_runner` \
139 and `flutter_build` caches that are worth real disk space.",
140 value: "true",
141 cautious: false,
142 },
143 Recommendation {
144 key: "enable_mix_build",
145 label: "Elixir build trees",
146 why: "`_build/` holds compiled beam files for every Mix environment you have built, and \
147 `mix compile` recreates the one you are working in.",
148 value: "true",
149 cautious: false,
150 },
151 Recommendation {
152 key: "enable_vcpkg",
153 label: "C / C++ vcpkg trees",
154 why: "`vcpkg_installed/` holds libraries vcpkg compiled from source for one \
155 project, and `vcpkg install` builds them again from the manifest beside \
156 them.",
157 value: "true",
158 cautious: false,
159 },
160 Recommendation {
161 key: "enable_cmake_build",
162 label: "C / C++ CMake build trees",
163 why: "A configured CMake build tree is object files and linked binaries, and \
164 `cmake` writes a `CMakeCache.txt` at the top of it that says which sources \
165 build it again — so a `build/` you made by hand is left alone.",
166 value: "true",
167 cautious: false,
168 },
169 Recommendation {
170 key: "allow_manifest_rewrite",
171 label: "Let cargo and go tidy up",
172 why: "Cautious, not risky. The commands that restore a Rust or Go project can also update \
173 `Cargo.lock` or `go.mod` — files Git tracks. Nothing is lost and nothing is deleted, \
174 but the next `git status` may show a change you did not make by hand. Turn it on if \
175 that is fine; leave it off if a clean working tree matters more than a fully \
176 automatic restore.",
177 value: "true",
178 cautious: true,
179 },
180];
181
182const SETTINGS: &[Setting] = &[
184 Setting {
185 key: "idle_days",
186 since: "1.0.0",
187 kind: Kind::Number,
188 help: "Days a repository must sit untouched before it is eligible for pruning.",
189 plain: "How long a project has to sit untouched before dev-prune will clean it. Something you worked on yesterday is never touched.",
190 get: |s| s.idle_days.to_string(),
191 set: |s, v| {
192 s.idle_days = v
193 .parse()
194 .map_err(|_| anyhow::anyhow!("idle_days must be a whole number of days"))?;
195 Ok(())
196 },
197 },
198 Setting {
199 key: "min_size_mb",
200 since: "1.0.0",
201 kind: Kind::Number,
202 help: "Smallest bloat directory worth deleting, in MiB. 0 removes the floor.",
203 plain: "Ignore small folders. Deleting a 2 MB folder is not worth the download to get it back.",
204 get: |s| s.min_size_mb.to_string(),
205 set: |s, v| {
206 s.min_size_mb = v.parse().map_err(|_| {
207 anyhow::anyhow!("min_size_mb must be a whole number of MiB (0 disables the floor)")
208 })?;
209 Ok(())
210 },
211 },
212 Setting {
213 key: "scan_depth",
214 since: "1.0.0",
215 kind: Kind::Number,
216 help: "How many directory levels below a repo root project discovery descends.",
217 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.",
218 get: |s| s.scan_depth.to_string(),
219 set: |s, v| {
220 let depth: usize = v
221 .parse()
222 .map_err(|_| anyhow::anyhow!("scan_depth must be a positive integer"))?;
223 if depth == 0 {
227 bail!("scan_depth must be at least 1 — 0 would find no projects at all.");
228 }
229 if depth > crate::constants::MAX_SCAN_DEPTH_LIMIT {
230 bail!(
231 "scan_depth must be at most {} — deeper walks stall on generated trees.",
232 crate::constants::MAX_SCAN_DEPTH_LIMIT
233 );
234 }
235 s.scan_depth = depth;
236 Ok(())
237 },
238 },
239 Setting {
240 key: "require_confirmation",
241 since: "1.0.0",
242 kind: Kind::Toggle,
243 help: "Ask before deleting anything. Turning this off makes every run unattended.",
244 plain: "Whether dev-prune asks \"delete these?\" before it deletes. Leave this on unless you want it to run silently while you are away.",
245 get: |s| s.require_confirmation.to_string(),
246 set: |s, v| {
247 s.require_confirmation = parse_bool("require_confirmation", v)?;
248 Ok(())
249 },
250 },
251 Setting {
252 key: "allow_manifest_rewrite",
253 since: "1.0.0",
254 kind: Kind::Toggle,
255 help: "Let cargo and go run the sync command that rewrites tracked manifests.",
256 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`.",
257 get: |s| s.allow_manifest_rewrite.to_string(),
258 set: |s, v| {
259 s.allow_manifest_rewrite = parse_bool("allow_manifest_rewrite", v)?;
260 Ok(())
261 },
262 },
263 Setting {
264 key: "command_timeout_secs",
265 since: "1.0.0",
266 kind: Kind::Number,
267 help: "How long a lockfile command may run before it is killed.",
268 plain: "How long to wait for a rebuild command before giving up on it. Raise it on a slow connection.",
269 get: |s| s.command_timeout_secs.to_string(),
270 set: |s, v| {
271 let secs: u64 = v
272 .parse()
273 .map_err(|_| anyhow::anyhow!("command_timeout_secs must be a positive integer"))?;
274 if secs == 0 {
278 bail!(
279 "command_timeout_secs must be at least 1 — 0 would kill every command \
280 the instant it starts."
281 );
282 }
283 s.command_timeout_secs = secs;
284 Ok(())
285 },
286 },
287 Setting {
288 key: "auto_setup",
289 since: "1.0.0",
290 kind: Kind::Toggle,
291 help: "Install missing integrations by itself, once per installed version.",
292 plain: "Whether dev-prune finishes setting itself up on its own instead of making you run `devp setup`.",
293 get: |s| s.auto_setup.to_string(),
294 set: |s, v| {
295 s.auto_setup = parse_bool("auto_setup", v)?;
296 Ok(())
297 },
298 },
299 Setting {
300 key: "auto_config",
301 since: "1.3.0",
302 kind: Kind::Toggle,
303 help: "Write a default .devprune.json into repositories that link/init register.",
304 plain: "Drops a small settings file into each repository you register, so you can give that one project different rules later.",
305 get: |s| s.auto_config.to_string(),
306 set: |s, v| {
307 s.auto_config = parse_bool("auto_config", v)?;
308 Ok(())
309 },
310 },
311 Setting {
312 key: "auto_daemon",
313 since: "1.0.0",
314 kind: Kind::Toggle,
315 help: "Register the OS scheduler so passes run without being remembered.",
316 plain: "Lets your operating system run dev-prune on a schedule, so you never have to remember to.",
317 get: |s| s.auto_daemon.to_string(),
318 set: |s, v| {
319 s.auto_daemon = parse_bool("auto_daemon", v)?;
320 Ok(())
321 },
322 },
323 Setting {
324 key: "check_interval_days",
325 since: "1.0.0",
326 kind: Kind::Number,
327 help: "Days between scheduled background passes.",
328 plain: "How often that scheduled cleanup runs.",
329 get: |s| s.check_interval_days.to_string(),
330 set: |s, v| {
331 let days: u64 = v
332 .parse()
333 .map_err(|_| anyhow::anyhow!("check_interval_days must be a positive integer"))?;
334 if days == 0 {
336 bail!("check_interval_days must be at least 1.");
337 }
338 s.check_interval_days = days;
339 Ok(())
340 },
341 },
342 Setting {
343 key: "auto_hooks",
344 since: "1.0.0",
345 kind: Kind::Toggle,
346 help: "Install the Git hooks that register repositories as you clone them.",
347 plain: "Registers new repositories automatically as you clone them, so you never have to add them by hand.",
348 get: |s| s.auto_hooks.to_string(),
349 set: |s, v| {
350 s.auto_hooks = parse_bool("auto_hooks", v)?;
351 Ok(())
352 },
353 },
354 Setting {
355 key: "auto_hooks_chain",
356 since: "1.0.0",
357 kind: Kind::Toggle,
358 help: "If another tool owns core.hooksPath, install in front of it and forward.",
359 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.",
360 get: |s| s.auto_hooks_chain.to_string(),
361 set: |s, v| {
362 s.auto_hooks_chain = parse_bool("auto_hooks_chain", v)?;
363 Ok(())
364 },
365 },
366 Setting {
367 key: "update_check",
368 since: "1.0.0",
369 kind: Kind::Toggle,
370 help: "Ask GitHub for the latest release from time to time. Sends nothing but the request.",
371 plain: "Whether dev-prune checks GitHub now and then to see if there is a newer version. It sends no information about you.",
372 get: |s| s.update_check.to_string(),
373 set: |s, v| {
374 s.update_check = parse_bool("update_check", v)?;
375 Ok(())
376 },
377 },
378 Setting {
379 key: "update_check_interval_days",
380 since: "1.0.0",
381 kind: Kind::Number,
382 help: "Days between automatic release checks.",
383 plain: "How often that version check happens.",
384 get: |s| s.update_check_interval_days.to_string(),
385 set: |s, v| {
386 let days: i64 = v.parse().map_err(|_| {
387 anyhow::anyhow!("update_check_interval_days must be a positive integer")
388 })?;
389 if days < 1 {
390 bail!("update_check_interval_days must be at least 1.");
391 }
392 s.update_check_interval_days = days;
393 Ok(())
394 },
395 },
396 Setting {
397 key: "update_check_timeout_secs",
398 since: "1.0.0",
399 kind: Kind::Number,
400 help: "Seconds the release check waits for GitHub. Raise it behind a slow proxy.",
401 plain: "How long the version check waits before giving up. Raise it if you are behind a slow proxy.",
402 get: |s| s.update_check_timeout_secs.to_string(),
403 set: |s, v| {
404 let secs: u64 = v.parse().map_err(|_| {
405 anyhow::anyhow!("update_check_timeout_secs must be a positive integer")
406 })?;
407 if secs == 0 {
408 bail!("update_check_timeout_secs must be at least 1.");
409 }
410 s.update_check_timeout_secs = secs;
411 Ok(())
412 },
413 },
414 Setting {
415 key: "enable_cargo",
416 since: "1.5.0",
417 kind: Kind::Toggle,
418 help: "Turn on the opt-in Cargo adapter (target/ comes back by recompiling, not downloading).",
419 plain: "Clean Rust build folders too. These come back by recompiling, which takes minutes rather than a download — so this is off unless you say otherwise.",
420 get: |s| s.enable_cargo.to_string(),
421 set: |s, v| {
422 s.enable_cargo = parse_bool("enable_cargo", v)?;
423 Ok(())
424 },
425 },
426 Setting {
427 key: "enable_gradle",
428 since: "1.3.0",
429 kind: Kind::Toggle,
430 help: "Turn on the opt-in Gradle adapter (build/ and .gradle/ come back by recompiling).",
431 plain: "Clean Android and Java build folders too. Same trade: they come back by recompiling, not downloading.",
432 get: |s| s.enable_gradle.to_string(),
433 set: |s, v| {
434 s.enable_gradle = parse_bool("enable_gradle", v)?;
435 Ok(())
436 },
437 },
438 Setting {
439 key: "enable_maven",
440 since: "1.3.0",
441 kind: Kind::Toggle,
442 help: "Turn on the opt-in Maven adapter (target/ comes back by recompiling).",
443 plain: "Clean Maven build folders too. They come back by recompiling.",
444 get: |s| s.enable_maven.to_string(),
445 set: |s, v| {
446 s.enable_maven = parse_bool("enable_maven", v)?;
447 Ok(())
448 },
449 },
450 Setting {
451 key: "enable_swift",
452 since: "1.4.0",
453 kind: Kind::Toggle,
454 help: "Turn on the opt-in SwiftPM adapter (.build/ comes back by recompiling).",
455 plain: "Clean Swift build folders too. They come back by recompiling.",
456 get: |s| s.enable_swift.to_string(),
457 set: |s, v| {
458 s.enable_swift = parse_bool("enable_swift", v)?;
459 Ok(())
460 },
461 },
462 Setting {
463 key: "enable_dart",
464 since: "1.6.0",
465 kind: Kind::Toggle,
466 help: "Turn on the opt-in Dart/Flutter adapter (.dart_tool/ holds build caches).",
467 plain: "Clean Dart and Flutter caches too. Part comes back instantly, part by recompiling.",
468 get: |s| s.enable_dart.to_string(),
469 set: |s, v| {
470 s.enable_dart = parse_bool("enable_dart", v)?;
471 Ok(())
472 },
473 },
474 Setting {
475 key: "enable_mix_build",
476 since: "1.7.0",
477 kind: Kind::Toggle,
478 help: "Turn on the opt-in Mix build-tree adapter (_build/ comes back by recompiling).",
479 plain: "Clean Elixir _build/ folders too. They come back by recompiling.",
480 get: |s| s.enable_mix_build.to_string(),
481 set: |s, v| {
482 s.enable_mix_build = parse_bool("enable_mix_build", v)?;
483 Ok(())
484 },
485 },
486 Setting {
487 key: "enable_vcpkg",
488 since: "1.8.0",
489 kind: Kind::Toggle,
490 help: "Turn on the opt-in vcpkg adapter (vcpkg_installed/ comes back by recompiling).",
491 plain: "Clean C and C++ vcpkg_installed/ folders too. They come back by recompiling.",
492 get: |s| s.enable_vcpkg.to_string(),
493 set: |s, v| {
494 s.enable_vcpkg = parse_bool("enable_vcpkg", v)?;
495 Ok(())
496 },
497 },
498 Setting {
499 key: "enable_cmake_build",
500 since: "1.8.0",
501 kind: Kind::Toggle,
502 help: "Turn on the opt-in CMake adapter (build trees proven by their CMakeCache.txt).",
503 plain: "Clean C and C++ build folders CMake configured. A `build/` you made by hand is \
504 never touched.",
505 get: |s| s.enable_cmake_build.to_string(),
506 set: |s, v| {
507 s.enable_cmake_build = parse_bool("enable_cmake_build", v)?;
508 Ok(())
509 },
510 },
511 Setting {
512 key: "build_idle_days",
513 since: "1.3.0",
514 kind: Kind::Number,
515 help: "Idle days before the opt-in adapters' build trees are pruned. Applied as max(this, idle_days).",
516 plain: "A longer wait, used only for the build folders above, because getting those back costs a recompile rather than a download.",
517 get: |s| s.build_idle_days.to_string(),
518 set: |s, v| {
519 let days: u64 = v
520 .parse()
521 .map_err(|_| anyhow::anyhow!("build_idle_days must be a non-negative integer"))?;
522 s.build_idle_days = days;
523 Ok(())
524 },
525 },
526 Setting {
527 key: "auto_update",
528 since: "1.3.0",
529 kind: Kind::Toggle,
530 help: "Install a newer release by itself at the end of a prune pass. On by default.",
531 plain: "Whether dev-prune installs its own updates after a cleanup. The download is checked against its published fingerprint first.",
532 get: |s| s.auto_update.to_string(),
533 set: |s, v| {
534 s.auto_update = parse_bool("auto_update", v)?;
535 Ok(())
536 },
537 },
538 Setting {
539 key: "version_lock",
540 since: "1.8.0",
541 kind: Kind::Toggle,
542 help: "Pin this copy to the version it is. Overrides auto_update, `devp update \
543 --install`, `devp install --channel` and the install scripts.",
544 plain: "Stay on exactly this version. Nothing dev-prune does replaces the binary \
545 while this is on -- not the automatic update, not a re-run of the install \
546 one-liner.",
547 get: |s| s.version_lock.to_string(),
548 set: |s, v| {
549 s.version_lock = parse_bool("version_lock", v)?;
550 Ok(())
551 },
552 },
553 Setting {
554 key: "disabled_adapters",
555 since: "1.4.0",
556 kind: Kind::Adapters,
557 help: "Adapters to leave alone entirely, by name. Empty means every one of them is active.",
558 plain: "Ecosystems to ignore completely — as if you did not have them installed at all.",
559 get: |s| {
560 if s.disabled_adapters.is_empty() {
561 "(none)".to_string()
562 } else {
563 s.disabled_adapters.join(",")
564 }
565 },
566 set: |s, v| {
567 s.disabled_adapters = parse_adapter_list(v)?;
568 Ok(())
569 },
570 },
571 Setting {
572 key: "adapter_idle_days",
573 since: "1.5.0",
574 kind: Kind::AdapterDays,
575 help: "Per-adapter idle windows, as `cargo=60,npm=30`. Each one can only raise its own wait.",
576 plain: "A different waiting period for one ecosystem. Useful when your Rust projects should wait longer than your Node ones.",
577 get: |s| {
578 if s.adapter_idle_days.is_empty() {
579 "(none)".to_string()
580 } else {
581 s.adapter_idle_days
582 .iter()
583 .map(|(name, days)| format!("{name}={days}"))
584 .collect::<Vec<_>>()
585 .join(",")
586 }
587 },
588 set: |s, v| {
589 s.adapter_idle_days = parse_adapter_days(v)?;
590 Ok(())
591 },
592 },
593 Setting {
594 key: "cache_max_gb",
595 since: "1.8.0",
596 kind: Kind::CacheCaps,
597 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`.",
598 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.",
599 get: |s| {
600 if s.cache_max_gb.is_empty() {
601 "(none)".to_string()
602 } else {
603 s.cache_max_gb
604 .iter()
605 .map(|(name, gb)| format!("{name}={gb}"))
606 .collect::<Vec<_>>()
607 .join(",")
608 }
609 },
610 set: |s, v| {
611 s.cache_max_gb = parse_cache_caps(v)?;
612 Ok(())
613 },
614 },
615];
616
617fn parse_cache_caps(value: &str) -> Result<std::collections::BTreeMap<String, u64>> {
634 let trimmed = value.trim();
635 if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
636 return Ok(std::collections::BTreeMap::new());
637 }
638
639 let mut caps = std::collections::BTreeMap::new();
640 for raw in trimmed.split(',') {
641 let entry = raw.trim();
642 if entry.is_empty() {
643 continue;
644 }
645 let Some((name, value)) = entry.split_once('=') else {
646 bail!("`{entry}` must be written as `<manager>=<gib>`, for example `uv=10`.");
647 };
648 let name = name.trim().to_lowercase();
649 if !crate::commands::caches::is_cache_manager(&name) {
650 bail!(
651 "`{name}` is not a manager dev-prune knows a cache for. Valid names: {}",
652 crate::commands::caches::known_managers().join(", ")
653 );
654 }
655 let parsed: u64 = value.trim().parse().map_err(|_| {
656 anyhow::anyhow!(
657 "`{name}` needs a whole number of gibibytes, not `{}`.",
658 value.trim()
659 )
660 })?;
661 if parsed == 0 {
662 bail!(
663 "`{name}=0` would call the cache too big the moment it exists. Use `-` to clear the caps instead."
664 );
665 }
666 caps.insert(name, parsed);
667 }
668 Ok(caps)
669}
670
671fn parse_adapter_days(value: &str) -> Result<std::collections::BTreeMap<String, u64>> {
676 let trimmed = value.trim();
677 if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
678 return Ok(std::collections::BTreeMap::new());
679 }
680
681 let mut days = std::collections::BTreeMap::new();
682 for raw in trimmed.split(',') {
683 let entry = raw.trim();
684 if entry.is_empty() {
685 continue;
686 }
687 let Some((name, value)) = entry.split_once('=') else {
688 bail!("`{entry}` must be written as `<adapter>=<days>`, for example `cargo=60`.");
689 };
690 let name = name.trim().to_lowercase();
691 if !crate::adapters::is_adapter_name(&name) {
692 bail!(
693 "`{name}` is not an adapter. Valid names: {}",
694 crate::adapters::all_adapter_names().join(", ")
695 );
696 }
697 let parsed: u64 = value.trim().parse().map_err(|_| {
698 anyhow::anyhow!(
699 "`{name}` needs a whole number of days, not `{}`.",
700 value.trim()
701 )
702 })?;
703 days.insert(name, parsed);
704 }
705 Ok(days)
706}
707
708fn parse_adapter_list(value: &str) -> Result<Vec<String>> {
709 let trimmed = value.trim();
710 if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
713 return Ok(Vec::new());
714 }
715
716 let mut names: Vec<String> = Vec::new();
717 for raw in trimmed.split(',') {
718 let name = raw.trim().to_lowercase();
719 if name.is_empty() {
720 continue;
721 }
722 if !crate::adapters::is_adapter_name(&name) {
723 bail!(
724 "`{name}` is not an adapter. Valid names: {}",
725 crate::adapters::all_adapter_names().join(", ")
726 );
727 }
728 if !names.contains(&name) {
729 names.push(name);
730 }
731 }
732 Ok(names)
733}
734
735fn parse_bool(key: &str, value: &str) -> Result<bool> {
736 match value.trim().to_lowercase().as_str() {
737 "true" | "yes" | "y" | "on" | "1" => Ok(true),
738 "false" | "no" | "n" | "off" | "0" => Ok(false),
739 _ => bail!("{key} must be true or false"),
740 }
741}
742
743pub fn invalid_settings(settings: &Settings) -> Vec<(&'static str, String)> {
754 SETTINGS
755 .iter()
756 .filter_map(|setting| {
757 let mut probe = settings.clone();
758 (setting.set)(&mut probe, &(setting.get)(settings))
759 .err()
760 .map(|e| (setting.key, e.to_string()))
761 })
762 .collect()
763}
764
765pub fn setting_count() -> usize {
767 SETTINGS.len()
768}
769
770fn find_setting(key: &str) -> Result<&'static Setting> {
771 SETTINGS
772 .iter()
773 .find(|s| s.key == key)
774 .ok_or_else(|| anyhow::anyhow!("Unknown config key: {key}. Valid keys: {}", valid_keys()))
775}
776
777fn valid_keys() -> String {
778 SETTINGS
779 .iter()
780 .map(|s| s.key)
781 .collect::<Vec<_>>()
782 .join(", ")
783}
784
785#[derive(Debug, PartialEq, Eq)]
787pub enum Toggle {
788 Enable,
789 Disable,
790 Status,
791}
792
793pub fn parse_toggle(action: &str) -> Result<Toggle> {
803 match action.to_lowercase().as_str() {
804 "enable" | "install" | "on" => Ok(Toggle::Enable),
805 "disable" | "uninstall" | "remove" | "off" => Ok(Toggle::Disable),
806 "" | "status" | "show" => Ok(Toggle::Status),
807 other => bail!(
808 "Unknown action `{other}`. Expected `enable`, `disable` or `status` \
809 (`install` / `uninstall` / `on` / `off` also work)."
810 ),
811 }
812}
813
814pub fn is_toggle_word(word: &str) -> bool {
820 parse_toggle(word).is_ok() && !word.is_empty()
821}
822
823fn resolve_workspace(path: &str) -> Result<std::path::PathBuf> {
830 let raw = Path::new(path);
831 if !raw.is_dir() {
832 bail!(
833 "`{path}` is neither an action nor an existing directory.\n\
834 Expected `enable`, `disable` or `status`, or a path to a repository."
835 );
836 }
837 Ok(raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf()))
838}
839
840pub fn run_get(key: &str) -> Result<()> {
842 let registry = Registry::load()?;
843 let setting = find_setting(key)?;
844 println!("{key} = {}", (setting.get)(®istry.settings));
845 Ok(())
846}
847
848pub fn run_set(key: &str, value: &str) -> Result<()> {
850 let mut registry = Registry::load()?;
851 let setting = find_setting(key)?;
852 (setting.set)(&mut registry.settings, value)?;
853 registry.save()?;
854
855 output::print_success(&format!("{key} = {}", (setting.get)(®istry.settings)));
858 Ok(())
859}
860
861fn key_column_width() -> usize {
863 SETTINGS.iter().map(|s| s.key.len()).max().unwrap_or(0)
864}
865
866pub fn run_show() -> Result<()> {
868 let registry = Registry::load()?;
869 let width = key_column_width();
870
871 output::print_header("dev-prune Global Configuration");
872 for setting in SETTINGS {
873 println!(
874 " {:<width$} = {}",
875 setting.key,
876 (setting.get)(®istry.settings)
877 );
878 }
879 println!(" {:<width$} = {}", "tracked_repos", registry.repo_count());
880
881 let reg_path = Registry::registry_path()
882 .map(|p| output::clean_path(&p))
883 .unwrap_or_else(|_| "unknown".to_string());
884 println!("\n {:<width$} = {reg_path}", "registry_file");
885 println!();
886 output::print_info("Change any of these with `devp config set <key> <value>`.");
887 output::print_info("Walk through them one at a time with `devp config wizard`.");
888
889 Ok(())
890}
891
892pub fn run_wizard(no_tui: bool) -> Result<()> {
903 if !no_tui && full_screen_is_usable() {
904 return run_wizard_tui();
905 }
906 run_wizard_prompts()
907}
908
909fn full_screen_is_usable() -> bool {
917 use std::io::IsTerminal;
918 if std::env::var_os(crate::constants::ENV_NO_TUI).is_some() {
919 return false;
920 }
921 std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
922}
923
924fn run_wizard_tui() -> Result<()> {
926 use crate::tui::config_view::{ConfigRow, ConfigSession, Control, Outcome};
927
928 let mut registry = Registry::load()?;
929 let new_keys = settings_added_since_review();
930
931 let rows: Vec<ConfigRow> = SETTINGS
932 .iter()
933 .map(|setting| {
934 let value = (setting.get)(®istry.settings);
935 ConfigRow {
936 key: setting.key,
937 help: setting.help,
938 plain: setting.plain,
939 control: match setting.kind {
940 Kind::Toggle => Control::Toggle,
941 Kind::Number => Control::Number,
942 Kind::Adapters => Control::Adapters,
943 Kind::AdapterDays => Control::AdapterDays,
944 Kind::CacheCaps => Control::CacheCaps,
945 },
946 original: value.clone(),
947 value,
948 is_new: new_keys.contains(&setting.key),
949 }
950 })
951 .collect();
952
953 let base = registry.settings.clone();
956 let validate = move |key: &str, value: &str| -> std::result::Result<(), String> {
957 let setting = find_setting(key).map_err(|e| e.to_string())?;
958 let mut probe = base.clone();
959 (setting.set)(&mut probe, value).map_err(|e| format!("{e}"))
960 };
961
962 let report = crate::commands::trust::build(®istry);
963 let adapters = crate::adapters::all_adapter_names();
964 let opt_in = crate::adapters::opt_in_adapter_names();
965 let capped: Vec<&'static str> = adapters
968 .iter()
969 .copied()
970 .filter(|name| crate::commands::caches::is_cache_manager(name))
971 .collect();
972
973 let outcome = crate::tui::config_view::run(ConfigSession {
974 declaration: declaration_lines(&report),
975 standing: NOTHING_DELETED_YET.to_string(),
976 suggestions: first_run_suggestions(),
977 rows,
978 adapters: &adapters,
979 opt_in_adapters: &opt_in,
980 capped_adapters: &capped,
981 groups: crate::adapters::ADAPTER_GROUPS,
982 validate: &validate,
983 title: "dev-prune configuration",
984 })?;
985
986 match outcome {
987 Outcome::Cancelled => {
991 output::print_info("Cancelled — nothing was changed.");
992 Ok(())
993 }
994 Outcome::KeepAll => {
995 mark_reviewed();
996 output::print_success(
997 "Keeping the current values. `devp config set <key> <value>` changes any.",
998 );
999 Ok(())
1000 }
1001 Outcome::Save(changed) => {
1002 for row in &changed {
1003 (find_setting(row.key)?.set)(&mut registry.settings, &row.value)?;
1004 }
1005 registry.save()?;
1006 mark_reviewed();
1007
1008 output::print_header("Saved");
1012 let width = changed.iter().map(|r| r.key.len()).max().unwrap_or(0);
1013 for row in &changed {
1014 println!(
1015 " {:<width$} = {} (was {})",
1016 row.key, row.value, row.original
1017 );
1018 }
1019 println!();
1020 output::print_success(&format!(
1021 "{} {} saved. `devp config show` lists every setting.",
1022 changed.len(),
1023 output::plural(changed.len(), "change", "changes")
1024 ));
1025 Ok(())
1026 }
1027 }
1028}
1029
1030fn first_run_suggestions() -> Vec<crate::tui::config_view::Suggestion> {
1041 use crate::tui::config_view::Suggestion;
1042
1043 if reviewed_version().is_some() {
1044 return Vec::new();
1045 }
1046 RECOMMENDED
1047 .iter()
1048 .filter_map(|r| {
1049 let setting = find_setting(r.key).ok()?;
1050 Some(Suggestion {
1051 key: r.key,
1052 label: r.label,
1053 help: setting.help,
1054 plain: setting.plain,
1055 why: r.why,
1056 value: r.value,
1057 cautious: r.cautious,
1058 })
1059 })
1060 .collect()
1061}
1062
1063const NOTHING_DELETED_YET: &str =
1065 "Nothing has been deleted, and nothing will be until a lockfile proves it comes back.";
1066
1067fn declaration_lines(
1073 report: &crate::commands::trust::TrustReport,
1074) -> Vec<crate::tui::config_view::DeclarationLine> {
1075 use crate::commands::trust::{TrustRow, Verdict};
1076 use crate::tui::config_view::DeclarationLine;
1077
1078 let heading = |text: &str| DeclarationLine {
1079 mark: '#',
1080 subject: text.to_string(),
1081 state: String::new(),
1082 };
1083 let row = |r: &TrustRow| DeclarationLine {
1084 mark: match r.verdict {
1085 Verdict::Guaranteed | Verdict::Safe => '+',
1086 Verdict::Widened => '!',
1087 Verdict::Neutral => ' ',
1088 },
1089 subject: r.subject.to_string(),
1090 state: r.state.clone(),
1091 };
1092
1093 let mut lines = vec![heading("Guaranteed by the code")];
1094 lines.extend(report.guarantees.iter().map(&row));
1095 lines.push(heading(""));
1096 lines.push(heading("On this machine"));
1097 lines.extend(report.machine.iter().map(&row));
1098 lines
1099}
1100
1101fn run_wizard_prompts() -> Result<()> {
1105 use std::io::{self, IsTerminal, Write};
1106
1107 if !io::stdin().is_terminal() {
1108 bail!(
1109 "`devp config wizard` needs a terminal to ask questions on.\n\
1110 Use `devp config show` to read the settings and `devp config set <key> <value>` \
1111 to change one."
1112 );
1113 }
1114
1115 let mut registry = Registry::load()?;
1116 let width = key_column_width();
1117 let new_keys = settings_added_since_review();
1118
1119 output::print_header("dev-prune configuration");
1120 output::print_info("These are the defaults every run will use. Nothing has been changed yet.");
1121 println!();
1122 for setting in SETTINGS {
1123 let badge = if new_keys.contains(&setting.key) {
1126 " (new in this version)"
1127 } else {
1128 ""
1129 };
1130 println!(
1131 " {:<width$} = {}{badge}",
1132 setting.key,
1133 (setting.get)(®istry.settings)
1134 );
1135 println!(" {:<width$} {}", "", setting.help);
1136 println!(" {:<width$} {}", "", setting.plain);
1139 }
1140 println!();
1141
1142 print!("Keep all of these? [Y/n] ");
1143 io::stdout().flush()?;
1144 let mut answer = String::new();
1145 io::stdin().read_line(&mut answer)?;
1146 let keep = !matches!(answer.trim().to_lowercase().as_str(), "n" | "no");
1147
1148 if keep {
1149 mark_reviewed();
1150 output::print_success("Keeping the defaults. `devp config set <key> <value>` changes any.");
1151 return Ok(());
1152 }
1153
1154 println!();
1155 output::print_info("Enter a new value, or press Enter to keep the one shown.");
1156 println!();
1157
1158 let mut changed = 0usize;
1159 for setting in SETTINGS {
1160 let current = (setting.get)(®istry.settings);
1161 loop {
1162 print!(" {} [{current}]: ", setting.key);
1163 io::stdout().flush()?;
1164 let mut line = String::new();
1165 if io::stdin().read_line(&mut line)? == 0 {
1168 println!();
1169 break;
1170 }
1171 let typed = line.trim();
1172 if typed.is_empty() {
1173 break;
1174 }
1175 match (setting.set)(&mut registry.settings, typed) {
1176 Ok(()) => {
1177 changed += 1;
1178 break;
1179 }
1180 Err(e) => output::print_error(&format!("{e}")),
1183 }
1184 }
1185 }
1186
1187 registry.save()?;
1188 mark_reviewed();
1189 println!();
1190 if changed == 0 {
1191 output::print_success("Nothing changed — the defaults are in place.");
1192 } else {
1193 output::print_success(&format!(
1194 "Saved {changed} {}. `devp config show` lists them all.",
1195 output::plural(changed, "change", "changes")
1196 ));
1197 }
1198 Ok(())
1199}
1200
1201const REVIEW_MARKER: &str = "config-reviewed";
1203
1204pub fn config_review_is_due() -> bool {
1213 let Ok(dir) = Registry::config_dir() else {
1214 return false;
1215 };
1216 if !dir.join(REVIEW_MARKER).exists() {
1217 return true;
1218 }
1219 !settings_added_since_review().is_empty()
1220}
1221
1222fn reviewed_version() -> Option<String> {
1224 let dir = Registry::config_dir().ok()?;
1225 let recorded = std::fs::read_to_string(dir.join(REVIEW_MARKER)).ok()?;
1226 let recorded = recorded.trim().to_string();
1227 (!recorded.is_empty()).then_some(recorded)
1228}
1229
1230pub fn settings_added_since_review() -> Vec<&'static str> {
1239 let Some(reviewed) = reviewed_version() else {
1240 return Vec::new();
1241 };
1242 SETTINGS
1243 .iter()
1244 .filter(|s| {
1245 crate::commands::update::compare_versions(s.since, &reviewed)
1246 == Some(std::cmp::Ordering::Greater)
1247 })
1248 .map(|s| s.key)
1249 .collect()
1250}
1251
1252fn mark_reviewed() {
1253 if let Ok(dir) = Registry::config_dir() {
1254 let _ = std::fs::create_dir_all(&dir);
1255 let _ = std::fs::write(dir.join(REVIEW_MARKER), crate::constants::VERSION);
1256 }
1257}
1258
1259pub fn skip_config_review() {
1264 mark_reviewed();
1265}
1266
1267pub fn run_global_update() -> Result<()> {
1269 output::print_header("dev-prune Global Configuration Audit & Sync");
1270
1271 let registry = Registry::load()?;
1272 let mut total_audited = 0;
1273 let mut errors_found = 0;
1274
1275 for repo_path in registry.repositories.keys() {
1276 let clean = output::clean_path(repo_path);
1277
1278 if !repo_path.exists() {
1282 output::print_warning(&format!(
1283 "Skipped {clean} — the path no longer exists. `devp unlink --missing` \
1284 clears such entries."
1285 ));
1286 continue;
1287 }
1288 total_audited += 1;
1289
1290 match PerRepoConfig::load_with_diagnostics(repo_path) {
1291 Ok(Some(cfg)) => {
1292 if let Err(e) = cfg.save_to_repo(repo_path) {
1293 output::print_error(&format!("Failed to write config for {clean}: {e}"));
1294 errors_found += 1;
1295 } else {
1296 output::print_success(&format!("Audited & synced config for {clean}"));
1297 }
1298 }
1299 Ok(None) => {
1300 output::print_info(&format!(
1304 "{clean} has no .devprune.json — global defaults apply."
1305 ));
1306 }
1307 Err(err_msg) => {
1308 errors_found += 1;
1309 output::print_error(&format!("Syntax/Schema Error in {clean}:"));
1310 for line in err_msg.lines() {
1311 eprintln!(" {line}");
1312 }
1313 output::print_info(&format!(
1314 "Hint: fix the syntax by hand, or run `devp config {clean} --update` to \
1315 replace the file with a valid default."
1316 ));
1317 }
1318 }
1319 }
1320
1321 if errors_found > 0 {
1322 anyhow::bail!(
1325 "Audit complete: {total_audited} repos checked, {errors_found} could not be read \
1326 or written."
1327 );
1328 }
1329 output::print_success(&format!(
1330 "Audit complete: All {total_audited} registered repositories are healthy & synced!"
1331 ));
1332
1333 Ok(())
1334}
1335
1336pub fn run_path_config(path_str: &str, force_update: bool) -> Result<()> {
1338 let raw_path = Path::new(path_str);
1339
1340 let path = if raw_path.exists() {
1341 raw_path
1342 .canonicalize()
1343 .unwrap_or_else(|_| raw_path.to_path_buf())
1344 } else {
1345 raw_path.to_path_buf()
1346 };
1347
1348 let clean = output::clean_path(&path);
1349
1350 if !path.exists() {
1351 bail!("Path does not exist: {clean}");
1352 }
1353
1354 if !crate::scanner::is_git_repo(&path) {
1355 bail!(
1357 "`{clean}` is not a Git repository.\n \
1358 Run `git init` there first, then `devp config {clean}` again."
1359 );
1360 }
1361
1362 let mut registry = Registry::load()?;
1363 if !registry.repositories.contains_key(&path) {
1364 output::print_info(&format!(
1365 "{clean} is not yet registered with dev-prune. Registering now..."
1366 ));
1367 registry.add_repo(path.clone());
1368 registry.save()?;
1369 }
1370
1371 let cfg_file = path.join(crate::constants::PER_REPO_CONFIG_FILE);
1372
1373 if cfg_file.exists() && !force_update {
1374 output::print_header(&format!("dev-prune Per-Repo Config for {clean}"));
1375 match PerRepoConfig::load_with_diagnostics(&path) {
1376 Ok(cfg) => {
1377 let json_str = serde_json::to_string_pretty(&cfg)?;
1378 println!("{json_str}");
1379 output::print_info("File location: .devprune.json");
1380 }
1381 Err(err_msg) => {
1382 output::print_error(&format!("Invalid configuration in {clean}:"));
1383 for line in err_msg.lines() {
1384 eprintln!(" {line}");
1385 }
1386 anyhow::bail!(
1389 "Run `devp config {clean} --update` to reset this file back to defaults \
1390 (your current overrides in it are discarded)."
1391 );
1392 }
1393 }
1394 } else {
1395 output::print_info(&format!("Initializing .devprune.json for {clean}..."));
1396 let cfg = PerRepoConfig::default();
1397 cfg.save_to_repo(&path)?;
1398 output::print_success(&format!("Created .devprune.json in {clean}"));
1399 }
1400
1401 Ok(())
1402}
1403
1404fn load_workspace_config_for_write(repo_path: &Path) -> Result<PerRepoConfig> {
1410 match PerRepoConfig::load_with_diagnostics(repo_path) {
1411 Ok(Some(cfg)) => Ok(cfg),
1412 Ok(None) => Ok(PerRepoConfig::default()),
1413 Err(e) => bail!(
1414 "{e}\n \
1415 Fix that file, or run `devp config {} --update` to reset it back to defaults \
1416 (your current overrides in it are discarded).",
1417 output::clean_path(repo_path)
1418 ),
1419 }
1420}
1421
1422pub fn run_daemon_toggle(path: Option<&str>, action: &str) -> Result<()> {
1424 if let Some(p) = path {
1425 let repo_path = resolve_workspace(p)?;
1426 let mut cfg = load_workspace_config_for_write(&repo_path)?;
1427 match parse_toggle(action)? {
1428 Toggle::Enable => {
1429 cfg.disable_daemon = false;
1430 cfg.save_to_repo(&repo_path)?;
1431 output::print_success(&format!(
1432 "Enabled background daemon for workspace: {}",
1433 output::clean_path(&repo_path)
1434 ));
1435 }
1436 Toggle::Disable => {
1437 cfg.disable_daemon = true;
1438 cfg.save_to_repo(&repo_path)?;
1439 output::print_success(&format!(
1440 "Disabled background daemon for workspace: {}",
1441 output::clean_path(&repo_path)
1442 ));
1443 }
1444 Toggle::Status => {
1445 let st = if cfg.disable_daemon {
1446 "Disabled for workspace"
1447 } else {
1448 "Enabled for workspace"
1449 };
1450 output::print_info(&format!(
1451 "Daemon Status ({}): {}",
1452 output::clean_path(&repo_path),
1453 st
1454 ));
1455 }
1456 }
1457 } else {
1458 match parse_toggle(action)? {
1459 Toggle::Enable => crate::commands::daemon::run_install()?,
1460 Toggle::Disable => crate::commands::daemon::run_uninstall()?,
1461 Toggle::Status => crate::commands::daemon::run_status()?,
1462 }
1463 }
1464 Ok(())
1465}
1466
1467pub fn run_hook_toggle(path: Option<&str>, action: &str, chain: bool) -> Result<()> {
1469 if let Some(p) = path {
1470 if chain {
1471 bail!(
1472 "`--chain` changes the single global `core.hooksPath`, so it has no \
1473 per-workspace form. Drop the path: `devp hook install --chain`."
1474 );
1475 }
1476 let repo_path = resolve_workspace(p)?;
1477 let mut cfg = load_workspace_config_for_write(&repo_path)?;
1478 match parse_toggle(action)? {
1479 Toggle::Enable => {
1480 cfg.disable_hooks = false;
1481 cfg.save_to_repo(&repo_path)?;
1482 output::print_success(&format!(
1483 "Enabled background Git hooks for workspace: {}",
1484 output::clean_path(&repo_path)
1485 ));
1486 }
1487 Toggle::Disable => {
1488 cfg.disable_hooks = true;
1489 cfg.save_to_repo(&repo_path)?;
1490 output::print_success(&format!(
1491 "Disabled background Git hooks for workspace: {}",
1492 output::clean_path(&repo_path)
1493 ));
1494 }
1495 Toggle::Status => {
1496 let st = if cfg.disable_hooks {
1497 "Disabled for workspace"
1498 } else {
1499 "Enabled for workspace"
1500 };
1501 output::print_info(&format!(
1502 "Git Hook Status ({}): {}",
1503 output::clean_path(&repo_path),
1504 st
1505 ));
1506 }
1507 }
1508 } else {
1509 match parse_toggle(action)? {
1510 Toggle::Enable => crate::commands::hook::run_install(chain)?,
1511 Toggle::Disable => crate::commands::hook::run_uninstall()?,
1512 Toggle::Status => crate::commands::hook::run_status()?,
1513 }
1514 }
1515 Ok(())
1516}
1517
1518#[cfg(test)]
1519mod tests {
1520 use super::*;
1521
1522 #[test]
1523 fn enable_synonyms_all_resolve_to_enable() {
1524 for word in ["enable", "install", "on", "INSTALL", "On"] {
1525 assert_eq!(parse_toggle(word).unwrap(), Toggle::Enable, "{word}");
1526 }
1527 }
1528
1529 #[test]
1530 fn disable_synonyms_all_resolve_to_disable() {
1531 for word in ["disable", "uninstall", "remove", "off", "Uninstall"] {
1532 assert_eq!(parse_toggle(word).unwrap(), Toggle::Disable, "{word}");
1533 }
1534 }
1535
1536 #[test]
1537 fn status_is_the_default_and_is_also_spellable() {
1538 for word in ["", "status", "show"] {
1539 assert_eq!(parse_toggle(word).unwrap(), Toggle::Status, "{word}");
1540 }
1541 }
1542
1543 #[test]
1544 fn a_typo_is_an_error_rather_than_a_silent_status_report() {
1545 let err = parse_toggle("enabel").unwrap_err().to_string();
1548 assert!(err.contains("enabel"), "{err}");
1549 assert!(err.contains("enable"), "{err}");
1550 }
1551
1552 #[test]
1553 fn a_workspace_toggle_refuses_to_write_over_a_broken_config() {
1554 let tmp = tempfile::TempDir::new().unwrap();
1557 let broken = r#"{ "project_name": "api", "override_idle_days": 90, }"#;
1558 std::fs::write(
1559 tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE),
1560 broken,
1561 )
1562 .unwrap();
1563
1564 let err = load_workspace_config_for_write(tmp.path())
1565 .unwrap_err()
1566 .to_string();
1567 assert!(err.contains("Syntax error"), "{err}");
1568 assert!(err.contains("--update"), "{err}");
1569
1570 let on_disk =
1572 std::fs::read_to_string(tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE))
1573 .unwrap();
1574 assert_eq!(on_disk, broken);
1575 }
1576
1577 #[test]
1578 fn a_workspace_with_no_config_yet_starts_from_the_defaults() {
1579 let tmp = tempfile::TempDir::new().unwrap();
1580 assert_eq!(
1581 load_workspace_config_for_write(tmp.path()).unwrap(),
1582 PerRepoConfig::default()
1583 );
1584 }
1585
1586 #[test]
1587 fn a_cache_cap_is_written_the_way_it_is_read_back() {
1588 let caps = parse_cache_caps("uv=10,npm=4").unwrap();
1589 assert_eq!(caps.get("uv"), Some(&10));
1590 assert_eq!(caps.get("npm"), Some(&4));
1591 let settings = Settings {
1594 cache_max_gb: parse_cache_caps("UV = 10 , npm=4").unwrap(),
1595 ..Settings::default()
1596 };
1597 let printed = SETTINGS
1598 .iter()
1599 .find(|s| s.key == "cache_max_gb")
1600 .map(|s| (s.get)(&settings))
1601 .unwrap();
1602 assert_eq!(printed, "npm=4,uv=10");
1603 assert_eq!(parse_cache_caps(&printed).unwrap(), settings.cache_max_gb);
1604 }
1605
1606 #[test]
1607 fn clearing_the_caps_is_spelled_the_way_the_getter_prints_an_empty_map() {
1608 for blank in ["", "-", "none", "(none)", "NONE"] {
1609 assert!(
1610 parse_cache_caps(blank).unwrap().is_empty(),
1611 "`{blank}` should clear every cap"
1612 );
1613 }
1614 }
1615
1616 #[test]
1617 fn a_cap_on_something_that_is_not_a_cache_is_refused_with_the_list() {
1618 let err = parse_cache_caps("venv=10").unwrap_err().to_string();
1621 assert!(err.contains("venv"), "{err}");
1622 assert!(err.contains("npm"), "the error lists what is valid: {err}");
1623 }
1624
1625 #[test]
1626 fn a_cap_has_to_be_a_whole_number_of_gibibytes() {
1627 for bad in ["uv=10.5", "uv=ten", "uv=-1", "uv="] {
1628 assert!(parse_cache_caps(bad).is_err(), "`{bad}` was accepted");
1629 }
1630 assert!(parse_cache_caps("uv").is_err());
1633 }
1634
1635 #[test]
1636 fn a_cap_of_zero_is_refused_rather_than_stored() {
1637 let err = parse_cache_caps("uv=0").unwrap_err().to_string();
1640 assert!(
1641 err.contains("`-`"),
1642 "the error names the way to clear it: {err}"
1643 );
1644 }
1645
1646 #[test]
1647 fn every_setting_round_trips_through_its_own_getter() {
1648 let mut settings = Settings::default();
1652 for setting in SETTINGS {
1653 let before = (setting.get)(&settings);
1654 let probe = match setting.kind {
1655 Kind::Toggle => if before == "true" { "false" } else { "true" }.to_string(),
1656 Kind::Number => "7".to_string(),
1659 Kind::Adapters => "cargo".to_string(),
1662 Kind::AdapterDays => "cargo=45".to_string(),
1665 Kind::CacheCaps => "cargo=10".to_string(),
1668 };
1669 (setting.set)(&mut settings, &probe)
1670 .unwrap_or_else(|e| panic!("{} rejected `{probe}`: {e}", setting.key));
1671 assert_eq!(
1672 (setting.get)(&settings),
1673 probe,
1674 "{} reads back a different field than it writes",
1675 setting.key
1676 );
1677 }
1678 }
1679
1680 #[test]
1681 fn every_setting_is_documented_and_uniquely_named() {
1682 let mut seen = std::collections::HashSet::new();
1683 for setting in SETTINGS {
1684 assert!(seen.insert(setting.key), "duplicate key {}", setting.key);
1685 assert!(!setting.help.is_empty(), "{} has no help", setting.key);
1686 assert!(
1687 !setting.plain.is_empty(),
1688 "{} has no plain text",
1689 setting.key
1690 );
1691 assert!(
1693 setting.help.ends_with('.'),
1694 "{} help should read as a sentence",
1695 setting.key
1696 );
1697 assert!(
1698 setting.plain.ends_with('.'),
1699 "{} plain text should read as a sentence",
1700 setting.key
1701 );
1702 assert_ne!(
1705 setting.plain, setting.help,
1706 "{} says the same thing twice",
1707 setting.key
1708 );
1709 }
1710 }
1711
1712 #[test]
1713 fn the_settings_table_covers_every_field_of_settings() {
1714 let json = serde_json::to_value(Settings::default()).unwrap();
1718 let fields: Vec<String> = json.as_object().unwrap().keys().cloned().collect();
1719 for field in fields {
1720 assert!(
1721 SETTINGS.iter().any(|s| s.key == field),
1722 "`{field}` is a setting with no entry in SETTINGS, so `devp config set \
1723 {field}` cannot reach it"
1724 );
1725 }
1726 }
1727
1728 #[test]
1729 fn a_rejected_value_leaves_the_previous_one_in_place() {
1730 let mut settings = Settings::default();
1731 assert!((find_setting("scan_depth").unwrap().set)(&mut settings, "0").is_err());
1732 assert_eq!(settings.scan_depth, Settings::default().scan_depth);
1733
1734 assert!((find_setting("command_timeout_secs").unwrap().set)(&mut settings, "0").is_err());
1735 assert!((find_setting("check_interval_days").unwrap().set)(&mut settings, "0").is_err());
1736 assert!(
1737 (find_setting("update_check_interval_days").unwrap().set)(&mut settings, "0").is_err()
1738 );
1739 }
1740
1741 #[test]
1742 fn booleans_accept_the_words_people_actually_type() {
1743 assert!(parse_bool("k", "yes").unwrap());
1744 assert!(parse_bool("k", "ON").unwrap());
1745 assert!(!parse_bool("k", "0").unwrap());
1746 assert!(parse_bool("k", "maybe").is_err());
1747 }
1748
1749 #[test]
1750 fn an_unknown_key_lists_the_ones_that_exist() {
1751 let err = match find_setting("idel_days") {
1752 Ok(_) => panic!("`idel_days` is not a setting"),
1753 Err(e) => e.to_string(),
1754 };
1755 assert!(err.contains("idle_days"), "{err}");
1756 }
1757
1758 #[test]
1759 fn a_path_is_never_mistaken_for_an_action() {
1760 assert!(!is_toggle_word("~/Code/my-repo"));
1762 assert!(!is_toggle_word("."));
1763 assert!(!is_toggle_word(""));
1764 assert!(is_toggle_word("install"));
1765 }
1766}