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 help: &'static str,
26 get: fn(&Settings) -> String,
27 set: fn(&mut Settings, &str) -> Result<()>,
28}
29
30const SETTINGS: &[Setting] = &[
32 Setting {
33 key: "idle_days",
34 help: "Days a repository must sit untouched before it is eligible for pruning.",
35 get: |s| s.idle_days.to_string(),
36 set: |s, v| {
37 s.idle_days = v
38 .parse()
39 .map_err(|_| anyhow::anyhow!("idle_days must be a whole number of days"))?;
40 Ok(())
41 },
42 },
43 Setting {
44 key: "min_size_mb",
45 help: "Smallest bloat directory worth deleting, in MiB. 0 removes the floor.",
46 get: |s| s.min_size_mb.to_string(),
47 set: |s, v| {
48 s.min_size_mb = v.parse().map_err(|_| {
49 anyhow::anyhow!("min_size_mb must be a whole number of MiB (0 disables the floor)")
50 })?;
51 Ok(())
52 },
53 },
54 Setting {
55 key: "scan_depth",
56 help: "How many directory levels below a repo root project discovery descends.",
57 get: |s| s.scan_depth.to_string(),
58 set: |s, v| {
59 let depth: usize = v
60 .parse()
61 .map_err(|_| anyhow::anyhow!("scan_depth must be a positive integer"))?;
62 if depth == 0 {
66 bail!("scan_depth must be at least 1 — 0 would find no projects at all.");
67 }
68 if depth > crate::constants::MAX_SCAN_DEPTH_LIMIT {
69 bail!(
70 "scan_depth must be at most {} — deeper walks stall on generated trees.",
71 crate::constants::MAX_SCAN_DEPTH_LIMIT
72 );
73 }
74 s.scan_depth = depth;
75 Ok(())
76 },
77 },
78 Setting {
79 key: "require_confirmation",
80 help: "Ask before deleting anything. Turning this off makes every run unattended.",
81 get: |s| s.require_confirmation.to_string(),
82 set: |s, v| {
83 s.require_confirmation = parse_bool("require_confirmation", v)?;
84 Ok(())
85 },
86 },
87 Setting {
88 key: "allow_manifest_rewrite",
89 help: "Let cargo and go run the sync command that rewrites tracked manifests.",
90 get: |s| s.allow_manifest_rewrite.to_string(),
91 set: |s, v| {
92 s.allow_manifest_rewrite = parse_bool("allow_manifest_rewrite", v)?;
93 Ok(())
94 },
95 },
96 Setting {
97 key: "command_timeout_secs",
98 help: "How long a lockfile command may run before it is killed.",
99 get: |s| s.command_timeout_secs.to_string(),
100 set: |s, v| {
101 let secs: u64 = v
102 .parse()
103 .map_err(|_| anyhow::anyhow!("command_timeout_secs must be a positive integer"))?;
104 if secs == 0 {
108 bail!(
109 "command_timeout_secs must be at least 1 — 0 would kill every command \
110 the instant it starts."
111 );
112 }
113 s.command_timeout_secs = secs;
114 Ok(())
115 },
116 },
117 Setting {
118 key: "auto_setup",
119 help: "Install missing integrations by itself, once per installed version.",
120 get: |s| s.auto_setup.to_string(),
121 set: |s, v| {
122 s.auto_setup = parse_bool("auto_setup", v)?;
123 Ok(())
124 },
125 },
126 Setting {
127 key: "auto_config",
128 help: "Write a default .devprune.json into repositories that link/init register.",
129 get: |s| s.auto_config.to_string(),
130 set: |s, v| {
131 s.auto_config = parse_bool("auto_config", v)?;
132 Ok(())
133 },
134 },
135 Setting {
136 key: "auto_daemon",
137 help: "Register the OS scheduler so passes run without being remembered.",
138 get: |s| s.auto_daemon.to_string(),
139 set: |s, v| {
140 s.auto_daemon = parse_bool("auto_daemon", v)?;
141 Ok(())
142 },
143 },
144 Setting {
145 key: "check_interval_days",
146 help: "Days between scheduled background passes.",
147 get: |s| s.check_interval_days.to_string(),
148 set: |s, v| {
149 let days: u64 = v
150 .parse()
151 .map_err(|_| anyhow::anyhow!("check_interval_days must be a positive integer"))?;
152 if days == 0 {
154 bail!("check_interval_days must be at least 1.");
155 }
156 s.check_interval_days = days;
157 Ok(())
158 },
159 },
160 Setting {
161 key: "auto_hooks",
162 help: "Install the Git hooks that register repositories as you clone them.",
163 get: |s| s.auto_hooks.to_string(),
164 set: |s, v| {
165 s.auto_hooks = parse_bool("auto_hooks", v)?;
166 Ok(())
167 },
168 },
169 Setting {
170 key: "auto_hooks_chain",
171 help: "If another tool owns core.hooksPath, install in front of it and forward.",
172 get: |s| s.auto_hooks_chain.to_string(),
173 set: |s, v| {
174 s.auto_hooks_chain = parse_bool("auto_hooks_chain", v)?;
175 Ok(())
176 },
177 },
178 Setting {
179 key: "update_check",
180 help: "Ask GitHub for the latest release from time to time. Sends nothing but the request.",
181 get: |s| s.update_check.to_string(),
182 set: |s, v| {
183 s.update_check = parse_bool("update_check", v)?;
184 Ok(())
185 },
186 },
187 Setting {
188 key: "update_check_interval_days",
189 help: "Days between automatic release checks.",
190 get: |s| s.update_check_interval_days.to_string(),
191 set: |s, v| {
192 let days: i64 = v.parse().map_err(|_| {
193 anyhow::anyhow!("update_check_interval_days must be a positive integer")
194 })?;
195 if days < 1 {
196 bail!("update_check_interval_days must be at least 1.");
197 }
198 s.update_check_interval_days = days;
199 Ok(())
200 },
201 },
202 Setting {
203 key: "update_check_timeout_secs",
204 help: "Seconds the release check waits for GitHub. Raise it behind a slow proxy.",
205 get: |s| s.update_check_timeout_secs.to_string(),
206 set: |s, v| {
207 let secs: u64 = v.parse().map_err(|_| {
208 anyhow::anyhow!("update_check_timeout_secs must be a positive integer")
209 })?;
210 if secs == 0 {
211 bail!("update_check_timeout_secs must be at least 1.");
212 }
213 s.update_check_timeout_secs = secs;
214 Ok(())
215 },
216 },
217 Setting {
218 key: "enable_gradle",
219 help: "Turn on the opt-in Gradle adapter (build/ and .gradle/ come back by recompiling).",
220 get: |s| s.enable_gradle.to_string(),
221 set: |s, v| {
222 s.enable_gradle = parse_bool("enable_gradle", v)?;
223 Ok(())
224 },
225 },
226 Setting {
227 key: "enable_maven",
228 help: "Turn on the opt-in Maven adapter (target/ comes back by recompiling).",
229 get: |s| s.enable_maven.to_string(),
230 set: |s, v| {
231 s.enable_maven = parse_bool("enable_maven", v)?;
232 Ok(())
233 },
234 },
235 Setting {
236 key: "build_idle_days",
237 help: "Idle days before gradle/maven build trees are pruned. Applied as max(this, idle_days).",
238 get: |s| s.build_idle_days.to_string(),
239 set: |s, v| {
240 let days: u64 = v
241 .parse()
242 .map_err(|_| anyhow::anyhow!("build_idle_days must be a non-negative integer"))?;
243 s.build_idle_days = days;
244 Ok(())
245 },
246 },
247 Setting {
248 key: "auto_update",
249 help: "Run `devp update --install` by itself after a prune pass when a newer release exists.",
250 get: |s| s.auto_update.to_string(),
251 set: |s, v| {
252 s.auto_update = parse_bool("auto_update", v)?;
253 Ok(())
254 },
255 },
256];
257
258fn parse_bool(key: &str, value: &str) -> Result<bool> {
259 match value.trim().to_lowercase().as_str() {
260 "true" | "yes" | "y" | "on" | "1" => Ok(true),
261 "false" | "no" | "n" | "off" | "0" => Ok(false),
262 _ => bail!("{key} must be true or false"),
263 }
264}
265
266pub fn invalid_settings(settings: &Settings) -> Vec<(&'static str, String)> {
277 SETTINGS
278 .iter()
279 .filter_map(|setting| {
280 let mut probe = settings.clone();
281 (setting.set)(&mut probe, &(setting.get)(settings))
282 .err()
283 .map(|e| (setting.key, e.to_string()))
284 })
285 .collect()
286}
287
288pub fn setting_count() -> usize {
290 SETTINGS.len()
291}
292
293fn find_setting(key: &str) -> Result<&'static Setting> {
294 SETTINGS
295 .iter()
296 .find(|s| s.key == key)
297 .ok_or_else(|| anyhow::anyhow!("Unknown config key: {key}. Valid keys: {}", valid_keys()))
298}
299
300fn valid_keys() -> String {
301 SETTINGS
302 .iter()
303 .map(|s| s.key)
304 .collect::<Vec<_>>()
305 .join(", ")
306}
307
308#[derive(Debug, PartialEq, Eq)]
310pub enum Toggle {
311 Enable,
312 Disable,
313 Status,
314}
315
316pub fn parse_toggle(action: &str) -> Result<Toggle> {
326 match action.to_lowercase().as_str() {
327 "enable" | "install" | "on" => Ok(Toggle::Enable),
328 "disable" | "uninstall" | "remove" | "off" => Ok(Toggle::Disable),
329 "" | "status" | "show" => Ok(Toggle::Status),
330 other => bail!(
331 "Unknown action `{other}`. Expected `enable`, `disable` or `status` \
332 (`install` / `uninstall` / `on` / `off` also work)."
333 ),
334 }
335}
336
337pub fn is_toggle_word(word: &str) -> bool {
343 parse_toggle(word).is_ok() && !word.is_empty()
344}
345
346fn resolve_workspace(path: &str) -> Result<std::path::PathBuf> {
353 let raw = Path::new(path);
354 if !raw.is_dir() {
355 bail!(
356 "`{path}` is neither an action nor an existing directory.\n\
357 Expected `enable`, `disable` or `status`, or a path to a repository."
358 );
359 }
360 Ok(raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf()))
361}
362
363pub fn run_get(key: &str) -> Result<()> {
365 let registry = Registry::load()?;
366 let setting = find_setting(key)?;
367 println!("{key} = {}", (setting.get)(®istry.settings));
368 Ok(())
369}
370
371pub fn run_set(key: &str, value: &str) -> Result<()> {
373 let mut registry = Registry::load()?;
374 let setting = find_setting(key)?;
375 (setting.set)(&mut registry.settings, value)?;
376 registry.save()?;
377
378 output::print_success(&format!("{key} = {}", (setting.get)(®istry.settings)));
381 Ok(())
382}
383
384fn key_column_width() -> usize {
386 SETTINGS.iter().map(|s| s.key.len()).max().unwrap_or(0)
387}
388
389pub fn run_show() -> Result<()> {
391 let registry = Registry::load()?;
392 let width = key_column_width();
393
394 output::print_header("dev-prune Global Configuration");
395 for setting in SETTINGS {
396 println!(
397 " {:<width$} = {}",
398 setting.key,
399 (setting.get)(®istry.settings)
400 );
401 }
402 println!(" {:<width$} = {}", "tracked_repos", registry.repo_count());
403
404 let reg_path = Registry::registry_path()
405 .map(|p| output::clean_path(&p))
406 .unwrap_or_else(|_| "unknown".to_string());
407 println!("\n {:<width$} = {reg_path}", "registry_file");
408 println!();
409 output::print_info("Change any of these with `devp config set <key> <value>`.");
410 output::print_info("Walk through them one at a time with `devp config wizard`.");
411
412 Ok(())
413}
414
415pub fn run_wizard() -> Result<()> {
424 use std::io::{self, IsTerminal, Write};
425
426 if !io::stdin().is_terminal() {
427 bail!(
428 "`devp config wizard` needs a terminal to ask questions on.\n\
429 Use `devp config show` to read the settings and `devp config set <key> <value>` \
430 to change one."
431 );
432 }
433
434 let mut registry = Registry::load()?;
435 let width = key_column_width();
436
437 output::print_header("dev-prune configuration");
438 output::print_info("These are the defaults every run will use. Nothing has been changed yet.");
439 println!();
440 for setting in SETTINGS {
441 println!(
442 " {:<width$} = {}",
443 setting.key,
444 (setting.get)(®istry.settings)
445 );
446 println!(" {:<width$} {}", "", setting.help);
447 }
448 println!();
449
450 print!("Keep all of these? [Y/n] ");
451 io::stdout().flush()?;
452 let mut answer = String::new();
453 io::stdin().read_line(&mut answer)?;
454 let keep = !matches!(answer.trim().to_lowercase().as_str(), "n" | "no");
455
456 if keep {
457 mark_reviewed();
458 output::print_success("Keeping the defaults. `devp config set <key> <value>` changes any.");
459 return Ok(());
460 }
461
462 println!();
463 output::print_info("Enter a new value, or press Enter to keep the one shown.");
464 println!();
465
466 let mut changed = 0usize;
467 for setting in SETTINGS {
468 let current = (setting.get)(®istry.settings);
469 loop {
470 print!(" {} [{current}]: ", setting.key);
471 io::stdout().flush()?;
472 let mut line = String::new();
473 if io::stdin().read_line(&mut line)? == 0 {
476 println!();
477 break;
478 }
479 let typed = line.trim();
480 if typed.is_empty() {
481 break;
482 }
483 match (setting.set)(&mut registry.settings, typed) {
484 Ok(()) => {
485 changed += 1;
486 break;
487 }
488 Err(e) => output::print_error(&format!("{e}")),
491 }
492 }
493 }
494
495 registry.save()?;
496 mark_reviewed();
497 println!();
498 if changed == 0 {
499 output::print_success("Nothing changed — the defaults are in place.");
500 } else {
501 output::print_success(&format!(
502 "Saved {changed} {}. `devp config show` lists them all.",
503 output::plural(changed, "change", "changes")
504 ));
505 }
506 Ok(())
507}
508
509const REVIEW_MARKER: &str = "config-reviewed";
511
512pub fn config_review_is_due() -> bool {
519 Registry::config_dir()
520 .map(|dir| !dir.join(REVIEW_MARKER).exists())
521 .unwrap_or(false)
522}
523
524fn mark_reviewed() {
525 if let Ok(dir) = Registry::config_dir() {
526 let _ = std::fs::create_dir_all(&dir);
527 let _ = std::fs::write(dir.join(REVIEW_MARKER), crate::constants::VERSION);
528 }
529}
530
531pub fn skip_config_review() {
536 mark_reviewed();
537}
538
539pub fn run_global_update() -> Result<()> {
541 output::print_header("dev-prune Global Configuration Audit & Sync");
542
543 let registry = Registry::load()?;
544 let mut total_audited = 0;
545 let mut errors_found = 0;
546
547 for repo_path in registry.repositories.keys() {
548 let clean = output::clean_path(repo_path);
549
550 if !repo_path.exists() {
554 output::print_warning(&format!(
555 "Skipped {clean} — the path no longer exists. `devp unlink --missing` \
556 clears such entries."
557 ));
558 continue;
559 }
560 total_audited += 1;
561
562 match PerRepoConfig::load_with_diagnostics(repo_path) {
563 Ok(Some(cfg)) => {
564 if let Err(e) = cfg.save_to_repo(repo_path) {
565 output::print_error(&format!("Failed to write config for {clean}: {e}"));
566 errors_found += 1;
567 } else {
568 output::print_success(&format!("Audited & synced config for {clean}"));
569 }
570 }
571 Ok(None) => {
572 output::print_info(&format!(
576 "{clean} has no .devprune.json — global defaults apply."
577 ));
578 }
579 Err(err_msg) => {
580 errors_found += 1;
581 output::print_error(&format!("Syntax/Schema Error in {clean}:"));
582 for line in err_msg.lines() {
583 eprintln!(" {line}");
584 }
585 output::print_info(&format!(
586 "Hint: fix the syntax by hand, or run `devp config {clean} --update` to \
587 replace the file with a valid default."
588 ));
589 }
590 }
591 }
592
593 if errors_found > 0 {
594 anyhow::bail!(
597 "Audit complete: {total_audited} repos checked, {errors_found} could not be read \
598 or written."
599 );
600 }
601 output::print_success(&format!(
602 "Audit complete: All {total_audited} registered repositories are healthy & synced!"
603 ));
604
605 Ok(())
606}
607
608pub fn run_path_config(path_str: &str, force_update: bool) -> Result<()> {
610 let raw_path = Path::new(path_str);
611
612 let path = if raw_path.exists() {
613 raw_path
614 .canonicalize()
615 .unwrap_or_else(|_| raw_path.to_path_buf())
616 } else {
617 raw_path.to_path_buf()
618 };
619
620 let clean = output::clean_path(&path);
621
622 if !path.exists() {
623 bail!("Path does not exist: {clean}");
624 }
625
626 if !crate::scanner::is_git_repo(&path) {
627 bail!(
629 "`{clean}` is not a Git repository.\n \
630 Run `git init` there first, then `devp config {clean}` again."
631 );
632 }
633
634 let mut registry = Registry::load()?;
635 if !registry.repositories.contains_key(&path) {
636 output::print_info(&format!(
637 "{clean} is not yet registered with dev-prune. Registering now..."
638 ));
639 registry.add_repo(path.clone());
640 registry.save()?;
641 }
642
643 let cfg_file = path.join(crate::constants::PER_REPO_CONFIG_FILE);
644
645 if cfg_file.exists() && !force_update {
646 output::print_header(&format!("dev-prune Per-Repo Config for {clean}"));
647 match PerRepoConfig::load_with_diagnostics(&path) {
648 Ok(cfg) => {
649 let json_str = serde_json::to_string_pretty(&cfg)?;
650 println!("{json_str}");
651 output::print_info("File location: .devprune.json");
652 }
653 Err(err_msg) => {
654 output::print_error(&format!("Invalid configuration in {clean}:"));
655 for line in err_msg.lines() {
656 eprintln!(" {line}");
657 }
658 anyhow::bail!(
661 "Run `devp config {clean} --update` to reset this file back to defaults \
662 (your current overrides in it are discarded)."
663 );
664 }
665 }
666 } else {
667 output::print_info(&format!("Initializing .devprune.json for {clean}..."));
668 let cfg = PerRepoConfig::default();
669 cfg.save_to_repo(&path)?;
670 output::print_success(&format!("Created .devprune.json in {clean}"));
671 }
672
673 Ok(())
674}
675
676fn load_workspace_config_for_write(repo_path: &Path) -> Result<PerRepoConfig> {
682 match PerRepoConfig::load_with_diagnostics(repo_path) {
683 Ok(Some(cfg)) => Ok(cfg),
684 Ok(None) => Ok(PerRepoConfig::default()),
685 Err(e) => bail!(
686 "{e}\n \
687 Fix that file, or run `devp config {} --update` to reset it back to defaults \
688 (your current overrides in it are discarded).",
689 output::clean_path(repo_path)
690 ),
691 }
692}
693
694pub fn run_daemon_toggle(path: Option<&str>, action: &str) -> Result<()> {
696 if let Some(p) = path {
697 let repo_path = resolve_workspace(p)?;
698 let mut cfg = load_workspace_config_for_write(&repo_path)?;
699 match parse_toggle(action)? {
700 Toggle::Enable => {
701 cfg.disable_daemon = false;
702 cfg.save_to_repo(&repo_path)?;
703 output::print_success(&format!(
704 "Enabled background daemon for workspace: {}",
705 output::clean_path(&repo_path)
706 ));
707 }
708 Toggle::Disable => {
709 cfg.disable_daemon = true;
710 cfg.save_to_repo(&repo_path)?;
711 output::print_success(&format!(
712 "Disabled background daemon for workspace: {}",
713 output::clean_path(&repo_path)
714 ));
715 }
716 Toggle::Status => {
717 let st = if cfg.disable_daemon {
718 "Disabled for workspace"
719 } else {
720 "Enabled for workspace"
721 };
722 output::print_info(&format!(
723 "Daemon Status ({}): {}",
724 output::clean_path(&repo_path),
725 st
726 ));
727 }
728 }
729 } else {
730 match parse_toggle(action)? {
731 Toggle::Enable => crate::commands::daemon::run_install()?,
732 Toggle::Disable => crate::commands::daemon::run_uninstall()?,
733 Toggle::Status => crate::commands::daemon::run_status()?,
734 }
735 }
736 Ok(())
737}
738
739pub fn run_hook_toggle(path: Option<&str>, action: &str, chain: bool) -> Result<()> {
741 if let Some(p) = path {
742 if chain {
743 bail!(
744 "`--chain` changes the single global `core.hooksPath`, so it has no \
745 per-workspace form. Drop the path: `devp hook install --chain`."
746 );
747 }
748 let repo_path = resolve_workspace(p)?;
749 let mut cfg = load_workspace_config_for_write(&repo_path)?;
750 match parse_toggle(action)? {
751 Toggle::Enable => {
752 cfg.disable_hooks = false;
753 cfg.save_to_repo(&repo_path)?;
754 output::print_success(&format!(
755 "Enabled background Git hooks for workspace: {}",
756 output::clean_path(&repo_path)
757 ));
758 }
759 Toggle::Disable => {
760 cfg.disable_hooks = true;
761 cfg.save_to_repo(&repo_path)?;
762 output::print_success(&format!(
763 "Disabled background Git hooks for workspace: {}",
764 output::clean_path(&repo_path)
765 ));
766 }
767 Toggle::Status => {
768 let st = if cfg.disable_hooks {
769 "Disabled for workspace"
770 } else {
771 "Enabled for workspace"
772 };
773 output::print_info(&format!(
774 "Git Hook Status ({}): {}",
775 output::clean_path(&repo_path),
776 st
777 ));
778 }
779 }
780 } else {
781 match parse_toggle(action)? {
782 Toggle::Enable => crate::commands::hook::run_install(chain)?,
783 Toggle::Disable => crate::commands::hook::run_uninstall()?,
784 Toggle::Status => crate::commands::hook::run_status()?,
785 }
786 }
787 Ok(())
788}
789
790#[cfg(test)]
791mod tests {
792 use super::*;
793
794 #[test]
795 fn enable_synonyms_all_resolve_to_enable() {
796 for word in ["enable", "install", "on", "INSTALL", "On"] {
797 assert_eq!(parse_toggle(word).unwrap(), Toggle::Enable, "{word}");
798 }
799 }
800
801 #[test]
802 fn disable_synonyms_all_resolve_to_disable() {
803 for word in ["disable", "uninstall", "remove", "off", "Uninstall"] {
804 assert_eq!(parse_toggle(word).unwrap(), Toggle::Disable, "{word}");
805 }
806 }
807
808 #[test]
809 fn status_is_the_default_and_is_also_spellable() {
810 for word in ["", "status", "show"] {
811 assert_eq!(parse_toggle(word).unwrap(), Toggle::Status, "{word}");
812 }
813 }
814
815 #[test]
816 fn a_typo_is_an_error_rather_than_a_silent_status_report() {
817 let err = parse_toggle("enabel").unwrap_err().to_string();
820 assert!(err.contains("enabel"), "{err}");
821 assert!(err.contains("enable"), "{err}");
822 }
823
824 #[test]
825 fn a_workspace_toggle_refuses_to_write_over_a_broken_config() {
826 let tmp = tempfile::TempDir::new().unwrap();
829 let broken = r#"{ "project_name": "api", "override_idle_days": 90, }"#;
830 std::fs::write(
831 tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE),
832 broken,
833 )
834 .unwrap();
835
836 let err = load_workspace_config_for_write(tmp.path())
837 .unwrap_err()
838 .to_string();
839 assert!(err.contains("Syntax error"), "{err}");
840 assert!(err.contains("--update"), "{err}");
841
842 let on_disk =
844 std::fs::read_to_string(tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE))
845 .unwrap();
846 assert_eq!(on_disk, broken);
847 }
848
849 #[test]
850 fn a_workspace_with_no_config_yet_starts_from_the_defaults() {
851 let tmp = tempfile::TempDir::new().unwrap();
852 assert_eq!(
853 load_workspace_config_for_write(tmp.path()).unwrap(),
854 PerRepoConfig::default()
855 );
856 }
857
858 #[test]
859 fn every_setting_round_trips_through_its_own_getter() {
860 let mut settings = Settings::default();
864 for setting in SETTINGS {
865 let before = (setting.get)(&settings);
866 let probe = match before.as_str() {
867 "true" => "false".to_string(),
868 "false" => "true".to_string(),
869 _ => "7".to_string(),
872 };
873 (setting.set)(&mut settings, &probe)
874 .unwrap_or_else(|e| panic!("{} rejected `{probe}`: {e}", setting.key));
875 assert_eq!(
876 (setting.get)(&settings),
877 probe,
878 "{} reads back a different field than it writes",
879 setting.key
880 );
881 }
882 }
883
884 #[test]
885 fn every_setting_is_documented_and_uniquely_named() {
886 let mut seen = std::collections::HashSet::new();
887 for setting in SETTINGS {
888 assert!(seen.insert(setting.key), "duplicate key {}", setting.key);
889 assert!(!setting.help.is_empty(), "{} has no help", setting.key);
890 assert!(
892 setting.help.ends_with('.'),
893 "{} help should read as a sentence",
894 setting.key
895 );
896 }
897 }
898
899 #[test]
900 fn the_settings_table_covers_every_field_of_settings() {
901 let json = serde_json::to_value(Settings::default()).unwrap();
905 let fields: Vec<String> = json.as_object().unwrap().keys().cloned().collect();
906 for field in fields {
907 assert!(
908 SETTINGS.iter().any(|s| s.key == field),
909 "`{field}` is a setting with no entry in SETTINGS, so `devp config set \
910 {field}` cannot reach it"
911 );
912 }
913 }
914
915 #[test]
916 fn a_rejected_value_leaves_the_previous_one_in_place() {
917 let mut settings = Settings::default();
918 assert!((find_setting("scan_depth").unwrap().set)(&mut settings, "0").is_err());
919 assert_eq!(settings.scan_depth, Settings::default().scan_depth);
920
921 assert!((find_setting("command_timeout_secs").unwrap().set)(&mut settings, "0").is_err());
922 assert!((find_setting("check_interval_days").unwrap().set)(&mut settings, "0").is_err());
923 assert!(
924 (find_setting("update_check_interval_days").unwrap().set)(&mut settings, "0").is_err()
925 );
926 }
927
928 #[test]
929 fn booleans_accept_the_words_people_actually_type() {
930 assert!(parse_bool("k", "yes").unwrap());
931 assert!(parse_bool("k", "ON").unwrap());
932 assert!(!parse_bool("k", "0").unwrap());
933 assert!(parse_bool("k", "maybe").is_err());
934 }
935
936 #[test]
937 fn an_unknown_key_lists_the_ones_that_exist() {
938 let err = match find_setting("idel_days") {
939 Ok(_) => panic!("`idel_days` is not a setting"),
940 Err(e) => e.to_string(),
941 };
942 assert!(err.contains("idle_days"), "{err}");
943 }
944
945 #[test]
946 fn a_path_is_never_mistaken_for_an_action() {
947 assert!(!is_toggle_word("~/Code/my-repo"));
949 assert!(!is_toggle_word("."));
950 assert!(!is_toggle_word(""));
951 assert!(is_toggle_word("install"));
952 }
953}