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,
35 get: fn(&Settings) -> String,
36 set: fn(&mut Settings, &str) -> Result<()>,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45enum Kind {
46 Toggle,
48 Number,
50 Adapters,
52}
53
54const SETTINGS: &[Setting] = &[
56 Setting {
57 key: "idle_days",
58 since: "1.0.0",
59 kind: Kind::Number,
60 help: "Days a repository must sit untouched before it is eligible for pruning.",
61 get: |s| s.idle_days.to_string(),
62 set: |s, v| {
63 s.idle_days = v
64 .parse()
65 .map_err(|_| anyhow::anyhow!("idle_days must be a whole number of days"))?;
66 Ok(())
67 },
68 },
69 Setting {
70 key: "min_size_mb",
71 since: "1.0.0",
72 kind: Kind::Number,
73 help: "Smallest bloat directory worth deleting, in MiB. 0 removes the floor.",
74 get: |s| s.min_size_mb.to_string(),
75 set: |s, v| {
76 s.min_size_mb = v.parse().map_err(|_| {
77 anyhow::anyhow!("min_size_mb must be a whole number of MiB (0 disables the floor)")
78 })?;
79 Ok(())
80 },
81 },
82 Setting {
83 key: "scan_depth",
84 since: "1.0.0",
85 kind: Kind::Number,
86 help: "How many directory levels below a repo root project discovery descends.",
87 get: |s| s.scan_depth.to_string(),
88 set: |s, v| {
89 let depth: usize = v
90 .parse()
91 .map_err(|_| anyhow::anyhow!("scan_depth must be a positive integer"))?;
92 if depth == 0 {
96 bail!("scan_depth must be at least 1 — 0 would find no projects at all.");
97 }
98 if depth > crate::constants::MAX_SCAN_DEPTH_LIMIT {
99 bail!(
100 "scan_depth must be at most {} — deeper walks stall on generated trees.",
101 crate::constants::MAX_SCAN_DEPTH_LIMIT
102 );
103 }
104 s.scan_depth = depth;
105 Ok(())
106 },
107 },
108 Setting {
109 key: "require_confirmation",
110 since: "1.0.0",
111 kind: Kind::Toggle,
112 help: "Ask before deleting anything. Turning this off makes every run unattended.",
113 get: |s| s.require_confirmation.to_string(),
114 set: |s, v| {
115 s.require_confirmation = parse_bool("require_confirmation", v)?;
116 Ok(())
117 },
118 },
119 Setting {
120 key: "allow_manifest_rewrite",
121 since: "1.0.0",
122 kind: Kind::Toggle,
123 help: "Let cargo and go run the sync command that rewrites tracked manifests.",
124 get: |s| s.allow_manifest_rewrite.to_string(),
125 set: |s, v| {
126 s.allow_manifest_rewrite = parse_bool("allow_manifest_rewrite", v)?;
127 Ok(())
128 },
129 },
130 Setting {
131 key: "command_timeout_secs",
132 since: "1.0.0",
133 kind: Kind::Number,
134 help: "How long a lockfile command may run before it is killed.",
135 get: |s| s.command_timeout_secs.to_string(),
136 set: |s, v| {
137 let secs: u64 = v
138 .parse()
139 .map_err(|_| anyhow::anyhow!("command_timeout_secs must be a positive integer"))?;
140 if secs == 0 {
144 bail!(
145 "command_timeout_secs must be at least 1 — 0 would kill every command \
146 the instant it starts."
147 );
148 }
149 s.command_timeout_secs = secs;
150 Ok(())
151 },
152 },
153 Setting {
154 key: "auto_setup",
155 since: "1.0.0",
156 kind: Kind::Toggle,
157 help: "Install missing integrations by itself, once per installed version.",
158 get: |s| s.auto_setup.to_string(),
159 set: |s, v| {
160 s.auto_setup = parse_bool("auto_setup", v)?;
161 Ok(())
162 },
163 },
164 Setting {
165 key: "auto_config",
166 since: "1.3.0",
167 kind: Kind::Toggle,
168 help: "Write a default .devprune.json into repositories that link/init register.",
169 get: |s| s.auto_config.to_string(),
170 set: |s, v| {
171 s.auto_config = parse_bool("auto_config", v)?;
172 Ok(())
173 },
174 },
175 Setting {
176 key: "auto_daemon",
177 since: "1.0.0",
178 kind: Kind::Toggle,
179 help: "Register the OS scheduler so passes run without being remembered.",
180 get: |s| s.auto_daemon.to_string(),
181 set: |s, v| {
182 s.auto_daemon = parse_bool("auto_daemon", v)?;
183 Ok(())
184 },
185 },
186 Setting {
187 key: "check_interval_days",
188 since: "1.0.0",
189 kind: Kind::Number,
190 help: "Days between scheduled background passes.",
191 get: |s| s.check_interval_days.to_string(),
192 set: |s, v| {
193 let days: u64 = v
194 .parse()
195 .map_err(|_| anyhow::anyhow!("check_interval_days must be a positive integer"))?;
196 if days == 0 {
198 bail!("check_interval_days must be at least 1.");
199 }
200 s.check_interval_days = days;
201 Ok(())
202 },
203 },
204 Setting {
205 key: "auto_hooks",
206 since: "1.0.0",
207 kind: Kind::Toggle,
208 help: "Install the Git hooks that register repositories as you clone them.",
209 get: |s| s.auto_hooks.to_string(),
210 set: |s, v| {
211 s.auto_hooks = parse_bool("auto_hooks", v)?;
212 Ok(())
213 },
214 },
215 Setting {
216 key: "auto_hooks_chain",
217 since: "1.0.0",
218 kind: Kind::Toggle,
219 help: "If another tool owns core.hooksPath, install in front of it and forward.",
220 get: |s| s.auto_hooks_chain.to_string(),
221 set: |s, v| {
222 s.auto_hooks_chain = parse_bool("auto_hooks_chain", v)?;
223 Ok(())
224 },
225 },
226 Setting {
227 key: "update_check",
228 since: "1.0.0",
229 kind: Kind::Toggle,
230 help: "Ask GitHub for the latest release from time to time. Sends nothing but the request.",
231 get: |s| s.update_check.to_string(),
232 set: |s, v| {
233 s.update_check = parse_bool("update_check", v)?;
234 Ok(())
235 },
236 },
237 Setting {
238 key: "update_check_interval_days",
239 since: "1.0.0",
240 kind: Kind::Number,
241 help: "Days between automatic release checks.",
242 get: |s| s.update_check_interval_days.to_string(),
243 set: |s, v| {
244 let days: i64 = v.parse().map_err(|_| {
245 anyhow::anyhow!("update_check_interval_days must be a positive integer")
246 })?;
247 if days < 1 {
248 bail!("update_check_interval_days must be at least 1.");
249 }
250 s.update_check_interval_days = days;
251 Ok(())
252 },
253 },
254 Setting {
255 key: "update_check_timeout_secs",
256 since: "1.0.0",
257 kind: Kind::Number,
258 help: "Seconds the release check waits for GitHub. Raise it behind a slow proxy.",
259 get: |s| s.update_check_timeout_secs.to_string(),
260 set: |s, v| {
261 let secs: u64 = v.parse().map_err(|_| {
262 anyhow::anyhow!("update_check_timeout_secs must be a positive integer")
263 })?;
264 if secs == 0 {
265 bail!("update_check_timeout_secs must be at least 1.");
266 }
267 s.update_check_timeout_secs = secs;
268 Ok(())
269 },
270 },
271 Setting {
272 key: "enable_gradle",
273 since: "1.3.0",
274 kind: Kind::Toggle,
275 help: "Turn on the opt-in Gradle adapter (build/ and .gradle/ come back by recompiling).",
276 get: |s| s.enable_gradle.to_string(),
277 set: |s, v| {
278 s.enable_gradle = parse_bool("enable_gradle", v)?;
279 Ok(())
280 },
281 },
282 Setting {
283 key: "enable_maven",
284 since: "1.3.0",
285 kind: Kind::Toggle,
286 help: "Turn on the opt-in Maven adapter (target/ comes back by recompiling).",
287 get: |s| s.enable_maven.to_string(),
288 set: |s, v| {
289 s.enable_maven = parse_bool("enable_maven", v)?;
290 Ok(())
291 },
292 },
293 Setting {
294 key: "enable_swift",
295 since: "1.4.0",
296 kind: Kind::Toggle,
297 help: "Turn on the opt-in SwiftPM adapter (.build/ comes back by recompiling).",
298 get: |s| s.enable_swift.to_string(),
299 set: |s, v| {
300 s.enable_swift = parse_bool("enable_swift", v)?;
301 Ok(())
302 },
303 },
304 Setting {
305 key: "build_idle_days",
306 since: "1.3.0",
307 kind: Kind::Number,
308 help: "Idle days before gradle/maven/swift build trees are pruned. Applied as max(this, idle_days).",
309 get: |s| s.build_idle_days.to_string(),
310 set: |s, v| {
311 let days: u64 = v
312 .parse()
313 .map_err(|_| anyhow::anyhow!("build_idle_days must be a non-negative integer"))?;
314 s.build_idle_days = days;
315 Ok(())
316 },
317 },
318 Setting {
319 key: "auto_update",
320 since: "1.3.0",
321 kind: Kind::Toggle,
322 help: "Run `devp update --install` by itself after a prune pass when a newer release exists.",
323 get: |s| s.auto_update.to_string(),
324 set: |s, v| {
325 s.auto_update = parse_bool("auto_update", v)?;
326 Ok(())
327 },
328 },
329 Setting {
330 key: "disabled_adapters",
331 since: "1.4.0",
332 kind: Kind::Adapters,
333 help: "Adapters to leave alone entirely, by name. Empty means every one of them is active.",
334 get: |s| {
335 if s.disabled_adapters.is_empty() {
336 "(none)".to_string()
337 } else {
338 s.disabled_adapters.join(",")
339 }
340 },
341 set: |s, v| {
342 s.disabled_adapters = parse_adapter_list(v)?;
343 Ok(())
344 },
345 },
346];
347
348fn parse_adapter_list(value: &str) -> Result<Vec<String>> {
354 let trimmed = value.trim();
355 if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
358 return Ok(Vec::new());
359 }
360
361 let mut names: Vec<String> = Vec::new();
362 for raw in trimmed.split(',') {
363 let name = raw.trim().to_lowercase();
364 if name.is_empty() {
365 continue;
366 }
367 if !crate::adapters::is_adapter_name(&name) {
368 bail!(
369 "`{name}` is not an adapter. Valid names: {}",
370 crate::adapters::all_adapter_names().join(", ")
371 );
372 }
373 if !names.contains(&name) {
374 names.push(name);
375 }
376 }
377 Ok(names)
378}
379
380fn parse_bool(key: &str, value: &str) -> Result<bool> {
381 match value.trim().to_lowercase().as_str() {
382 "true" | "yes" | "y" | "on" | "1" => Ok(true),
383 "false" | "no" | "n" | "off" | "0" => Ok(false),
384 _ => bail!("{key} must be true or false"),
385 }
386}
387
388pub fn invalid_settings(settings: &Settings) -> Vec<(&'static str, String)> {
399 SETTINGS
400 .iter()
401 .filter_map(|setting| {
402 let mut probe = settings.clone();
403 (setting.set)(&mut probe, &(setting.get)(settings))
404 .err()
405 .map(|e| (setting.key, e.to_string()))
406 })
407 .collect()
408}
409
410pub fn setting_count() -> usize {
412 SETTINGS.len()
413}
414
415fn find_setting(key: &str) -> Result<&'static Setting> {
416 SETTINGS
417 .iter()
418 .find(|s| s.key == key)
419 .ok_or_else(|| anyhow::anyhow!("Unknown config key: {key}. Valid keys: {}", valid_keys()))
420}
421
422fn valid_keys() -> String {
423 SETTINGS
424 .iter()
425 .map(|s| s.key)
426 .collect::<Vec<_>>()
427 .join(", ")
428}
429
430#[derive(Debug, PartialEq, Eq)]
432pub enum Toggle {
433 Enable,
434 Disable,
435 Status,
436}
437
438pub fn parse_toggle(action: &str) -> Result<Toggle> {
448 match action.to_lowercase().as_str() {
449 "enable" | "install" | "on" => Ok(Toggle::Enable),
450 "disable" | "uninstall" | "remove" | "off" => Ok(Toggle::Disable),
451 "" | "status" | "show" => Ok(Toggle::Status),
452 other => bail!(
453 "Unknown action `{other}`. Expected `enable`, `disable` or `status` \
454 (`install` / `uninstall` / `on` / `off` also work)."
455 ),
456 }
457}
458
459pub fn is_toggle_word(word: &str) -> bool {
465 parse_toggle(word).is_ok() && !word.is_empty()
466}
467
468fn resolve_workspace(path: &str) -> Result<std::path::PathBuf> {
475 let raw = Path::new(path);
476 if !raw.is_dir() {
477 bail!(
478 "`{path}` is neither an action nor an existing directory.\n\
479 Expected `enable`, `disable` or `status`, or a path to a repository."
480 );
481 }
482 Ok(raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf()))
483}
484
485pub fn run_get(key: &str) -> Result<()> {
487 let registry = Registry::load()?;
488 let setting = find_setting(key)?;
489 println!("{key} = {}", (setting.get)(®istry.settings));
490 Ok(())
491}
492
493pub fn run_set(key: &str, value: &str) -> Result<()> {
495 let mut registry = Registry::load()?;
496 let setting = find_setting(key)?;
497 (setting.set)(&mut registry.settings, value)?;
498 registry.save()?;
499
500 output::print_success(&format!("{key} = {}", (setting.get)(®istry.settings)));
503 Ok(())
504}
505
506fn key_column_width() -> usize {
508 SETTINGS.iter().map(|s| s.key.len()).max().unwrap_or(0)
509}
510
511pub fn run_show() -> Result<()> {
513 let registry = Registry::load()?;
514 let width = key_column_width();
515
516 output::print_header("dev-prune Global Configuration");
517 for setting in SETTINGS {
518 println!(
519 " {:<width$} = {}",
520 setting.key,
521 (setting.get)(®istry.settings)
522 );
523 }
524 println!(" {:<width$} = {}", "tracked_repos", registry.repo_count());
525
526 let reg_path = Registry::registry_path()
527 .map(|p| output::clean_path(&p))
528 .unwrap_or_else(|_| "unknown".to_string());
529 println!("\n {:<width$} = {reg_path}", "registry_file");
530 println!();
531 output::print_info("Change any of these with `devp config set <key> <value>`.");
532 output::print_info("Walk through them one at a time with `devp config wizard`.");
533
534 Ok(())
535}
536
537pub fn run_wizard(no_tui: bool) -> Result<()> {
548 if !no_tui && full_screen_is_usable() {
549 return run_wizard_tui();
550 }
551 run_wizard_prompts()
552}
553
554fn full_screen_is_usable() -> bool {
562 use std::io::IsTerminal;
563 if std::env::var_os(crate::constants::ENV_NO_TUI).is_some() {
564 return false;
565 }
566 std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
567}
568
569fn run_wizard_tui() -> Result<()> {
571 use crate::tui::config_view::{ConfigRow, ConfigSession, Control, Outcome};
572
573 let mut registry = Registry::load()?;
574 let new_keys = settings_added_since_review();
575
576 let rows: Vec<ConfigRow> = SETTINGS
577 .iter()
578 .map(|setting| {
579 let value = (setting.get)(®istry.settings);
580 ConfigRow {
581 key: setting.key,
582 help: setting.help,
583 control: match setting.kind {
584 Kind::Toggle => Control::Toggle,
585 Kind::Number => Control::Number,
586 Kind::Adapters => Control::Adapters,
587 },
588 original: value.clone(),
589 value,
590 is_new: new_keys.contains(&setting.key),
591 }
592 })
593 .collect();
594
595 let base = registry.settings.clone();
598 let validate = move |key: &str, value: &str| -> std::result::Result<(), String> {
599 let setting = find_setting(key).map_err(|e| e.to_string())?;
600 let mut probe = base.clone();
601 (setting.set)(&mut probe, value).map_err(|e| format!("{e}"))
602 };
603
604 let report = crate::commands::trust::build(®istry);
605 let adapters = crate::adapters::all_adapter_names();
606 let opt_in = crate::adapters::opt_in_adapter_names();
607
608 let outcome = crate::tui::config_view::run(ConfigSession {
609 declaration: declaration_lines(&report),
610 standing: NOTHING_DELETED_YET.to_string(),
611 rows,
612 adapters: &adapters,
613 opt_in_adapters: &opt_in,
614 validate: &validate,
615 title: "dev-prune configuration",
616 })?;
617
618 match outcome {
619 Outcome::Cancelled => {
623 output::print_info("Cancelled — nothing was changed.");
624 Ok(())
625 }
626 Outcome::KeepAll => {
627 mark_reviewed();
628 output::print_success(
629 "Keeping the current values. `devp config set <key> <value>` changes any.",
630 );
631 Ok(())
632 }
633 Outcome::Save(changed) => {
634 for row in &changed {
635 (find_setting(row.key)?.set)(&mut registry.settings, &row.value)?;
636 }
637 registry.save()?;
638 mark_reviewed();
639
640 output::print_header("Saved");
644 let width = changed.iter().map(|r| r.key.len()).max().unwrap_or(0);
645 for row in &changed {
646 println!(
647 " {:<width$} = {} (was {})",
648 row.key, row.value, row.original
649 );
650 }
651 println!();
652 output::print_success(&format!(
653 "{} {} saved. `devp config show` lists every setting.",
654 changed.len(),
655 output::plural(changed.len(), "change", "changes")
656 ));
657 Ok(())
658 }
659 }
660}
661
662const NOTHING_DELETED_YET: &str =
664 "Nothing has been deleted, and nothing will be until a lockfile proves it comes back.";
665
666fn declaration_lines(
672 report: &crate::commands::trust::TrustReport,
673) -> Vec<crate::tui::config_view::DeclarationLine> {
674 use crate::commands::trust::{TrustRow, Verdict};
675 use crate::tui::config_view::DeclarationLine;
676
677 let heading = |text: &str| DeclarationLine {
678 mark: '#',
679 subject: text.to_string(),
680 state: String::new(),
681 };
682 let row = |r: &TrustRow| DeclarationLine {
683 mark: match r.verdict {
684 Verdict::Guaranteed | Verdict::Safe => '+',
685 Verdict::Widened => '!',
686 Verdict::Neutral => ' ',
687 },
688 subject: r.subject.to_string(),
689 state: r.state.clone(),
690 };
691
692 let mut lines = vec![heading("Guaranteed by the code")];
693 lines.extend(report.guarantees.iter().map(&row));
694 lines.push(heading(""));
695 lines.push(heading("On this machine"));
696 lines.extend(report.machine.iter().map(&row));
697 lines
698}
699
700fn run_wizard_prompts() -> Result<()> {
704 use std::io::{self, IsTerminal, Write};
705
706 if !io::stdin().is_terminal() {
707 bail!(
708 "`devp config wizard` needs a terminal to ask questions on.\n\
709 Use `devp config show` to read the settings and `devp config set <key> <value>` \
710 to change one."
711 );
712 }
713
714 let mut registry = Registry::load()?;
715 let width = key_column_width();
716 let new_keys = settings_added_since_review();
717
718 output::print_header("dev-prune configuration");
719 output::print_info("These are the defaults every run will use. Nothing has been changed yet.");
720 println!();
721 for setting in SETTINGS {
722 let badge = if new_keys.contains(&setting.key) {
725 " (new in this version)"
726 } else {
727 ""
728 };
729 println!(
730 " {:<width$} = {}{badge}",
731 setting.key,
732 (setting.get)(®istry.settings)
733 );
734 println!(" {:<width$} {}", "", setting.help);
735 }
736 println!();
737
738 print!("Keep all of these? [Y/n] ");
739 io::stdout().flush()?;
740 let mut answer = String::new();
741 io::stdin().read_line(&mut answer)?;
742 let keep = !matches!(answer.trim().to_lowercase().as_str(), "n" | "no");
743
744 if keep {
745 mark_reviewed();
746 output::print_success("Keeping the defaults. `devp config set <key> <value>` changes any.");
747 return Ok(());
748 }
749
750 println!();
751 output::print_info("Enter a new value, or press Enter to keep the one shown.");
752 println!();
753
754 let mut changed = 0usize;
755 for setting in SETTINGS {
756 let current = (setting.get)(®istry.settings);
757 loop {
758 print!(" {} [{current}]: ", setting.key);
759 io::stdout().flush()?;
760 let mut line = String::new();
761 if io::stdin().read_line(&mut line)? == 0 {
764 println!();
765 break;
766 }
767 let typed = line.trim();
768 if typed.is_empty() {
769 break;
770 }
771 match (setting.set)(&mut registry.settings, typed) {
772 Ok(()) => {
773 changed += 1;
774 break;
775 }
776 Err(e) => output::print_error(&format!("{e}")),
779 }
780 }
781 }
782
783 registry.save()?;
784 mark_reviewed();
785 println!();
786 if changed == 0 {
787 output::print_success("Nothing changed — the defaults are in place.");
788 } else {
789 output::print_success(&format!(
790 "Saved {changed} {}. `devp config show` lists them all.",
791 output::plural(changed, "change", "changes")
792 ));
793 }
794 Ok(())
795}
796
797const REVIEW_MARKER: &str = "config-reviewed";
799
800pub fn config_review_is_due() -> bool {
809 let Ok(dir) = Registry::config_dir() else {
810 return false;
811 };
812 if !dir.join(REVIEW_MARKER).exists() {
813 return true;
814 }
815 !settings_added_since_review().is_empty()
816}
817
818fn reviewed_version() -> Option<String> {
820 let dir = Registry::config_dir().ok()?;
821 let recorded = std::fs::read_to_string(dir.join(REVIEW_MARKER)).ok()?;
822 let recorded = recorded.trim().to_string();
823 (!recorded.is_empty()).then_some(recorded)
824}
825
826pub fn settings_added_since_review() -> Vec<&'static str> {
835 let Some(reviewed) = reviewed_version() else {
836 return Vec::new();
837 };
838 SETTINGS
839 .iter()
840 .filter(|s| {
841 crate::commands::update::compare_versions(s.since, &reviewed)
842 == Some(std::cmp::Ordering::Greater)
843 })
844 .map(|s| s.key)
845 .collect()
846}
847
848fn mark_reviewed() {
849 if let Ok(dir) = Registry::config_dir() {
850 let _ = std::fs::create_dir_all(&dir);
851 let _ = std::fs::write(dir.join(REVIEW_MARKER), crate::constants::VERSION);
852 }
853}
854
855pub fn skip_config_review() {
860 mark_reviewed();
861}
862
863pub fn run_global_update() -> Result<()> {
865 output::print_header("dev-prune Global Configuration Audit & Sync");
866
867 let registry = Registry::load()?;
868 let mut total_audited = 0;
869 let mut errors_found = 0;
870
871 for repo_path in registry.repositories.keys() {
872 let clean = output::clean_path(repo_path);
873
874 if !repo_path.exists() {
878 output::print_warning(&format!(
879 "Skipped {clean} — the path no longer exists. `devp unlink --missing` \
880 clears such entries."
881 ));
882 continue;
883 }
884 total_audited += 1;
885
886 match PerRepoConfig::load_with_diagnostics(repo_path) {
887 Ok(Some(cfg)) => {
888 if let Err(e) = cfg.save_to_repo(repo_path) {
889 output::print_error(&format!("Failed to write config for {clean}: {e}"));
890 errors_found += 1;
891 } else {
892 output::print_success(&format!("Audited & synced config for {clean}"));
893 }
894 }
895 Ok(None) => {
896 output::print_info(&format!(
900 "{clean} has no .devprune.json — global defaults apply."
901 ));
902 }
903 Err(err_msg) => {
904 errors_found += 1;
905 output::print_error(&format!("Syntax/Schema Error in {clean}:"));
906 for line in err_msg.lines() {
907 eprintln!(" {line}");
908 }
909 output::print_info(&format!(
910 "Hint: fix the syntax by hand, or run `devp config {clean} --update` to \
911 replace the file with a valid default."
912 ));
913 }
914 }
915 }
916
917 if errors_found > 0 {
918 anyhow::bail!(
921 "Audit complete: {total_audited} repos checked, {errors_found} could not be read \
922 or written."
923 );
924 }
925 output::print_success(&format!(
926 "Audit complete: All {total_audited} registered repositories are healthy & synced!"
927 ));
928
929 Ok(())
930}
931
932pub fn run_path_config(path_str: &str, force_update: bool) -> Result<()> {
934 let raw_path = Path::new(path_str);
935
936 let path = if raw_path.exists() {
937 raw_path
938 .canonicalize()
939 .unwrap_or_else(|_| raw_path.to_path_buf())
940 } else {
941 raw_path.to_path_buf()
942 };
943
944 let clean = output::clean_path(&path);
945
946 if !path.exists() {
947 bail!("Path does not exist: {clean}");
948 }
949
950 if !crate::scanner::is_git_repo(&path) {
951 bail!(
953 "`{clean}` is not a Git repository.\n \
954 Run `git init` there first, then `devp config {clean}` again."
955 );
956 }
957
958 let mut registry = Registry::load()?;
959 if !registry.repositories.contains_key(&path) {
960 output::print_info(&format!(
961 "{clean} is not yet registered with dev-prune. Registering now..."
962 ));
963 registry.add_repo(path.clone());
964 registry.save()?;
965 }
966
967 let cfg_file = path.join(crate::constants::PER_REPO_CONFIG_FILE);
968
969 if cfg_file.exists() && !force_update {
970 output::print_header(&format!("dev-prune Per-Repo Config for {clean}"));
971 match PerRepoConfig::load_with_diagnostics(&path) {
972 Ok(cfg) => {
973 let json_str = serde_json::to_string_pretty(&cfg)?;
974 println!("{json_str}");
975 output::print_info("File location: .devprune.json");
976 }
977 Err(err_msg) => {
978 output::print_error(&format!("Invalid configuration in {clean}:"));
979 for line in err_msg.lines() {
980 eprintln!(" {line}");
981 }
982 anyhow::bail!(
985 "Run `devp config {clean} --update` to reset this file back to defaults \
986 (your current overrides in it are discarded)."
987 );
988 }
989 }
990 } else {
991 output::print_info(&format!("Initializing .devprune.json for {clean}..."));
992 let cfg = PerRepoConfig::default();
993 cfg.save_to_repo(&path)?;
994 output::print_success(&format!("Created .devprune.json in {clean}"));
995 }
996
997 Ok(())
998}
999
1000fn load_workspace_config_for_write(repo_path: &Path) -> Result<PerRepoConfig> {
1006 match PerRepoConfig::load_with_diagnostics(repo_path) {
1007 Ok(Some(cfg)) => Ok(cfg),
1008 Ok(None) => Ok(PerRepoConfig::default()),
1009 Err(e) => bail!(
1010 "{e}\n \
1011 Fix that file, or run `devp config {} --update` to reset it back to defaults \
1012 (your current overrides in it are discarded).",
1013 output::clean_path(repo_path)
1014 ),
1015 }
1016}
1017
1018pub fn run_daemon_toggle(path: Option<&str>, action: &str) -> Result<()> {
1020 if let Some(p) = path {
1021 let repo_path = resolve_workspace(p)?;
1022 let mut cfg = load_workspace_config_for_write(&repo_path)?;
1023 match parse_toggle(action)? {
1024 Toggle::Enable => {
1025 cfg.disable_daemon = false;
1026 cfg.save_to_repo(&repo_path)?;
1027 output::print_success(&format!(
1028 "Enabled background daemon for workspace: {}",
1029 output::clean_path(&repo_path)
1030 ));
1031 }
1032 Toggle::Disable => {
1033 cfg.disable_daemon = true;
1034 cfg.save_to_repo(&repo_path)?;
1035 output::print_success(&format!(
1036 "Disabled background daemon for workspace: {}",
1037 output::clean_path(&repo_path)
1038 ));
1039 }
1040 Toggle::Status => {
1041 let st = if cfg.disable_daemon {
1042 "Disabled for workspace"
1043 } else {
1044 "Enabled for workspace"
1045 };
1046 output::print_info(&format!(
1047 "Daemon Status ({}): {}",
1048 output::clean_path(&repo_path),
1049 st
1050 ));
1051 }
1052 }
1053 } else {
1054 match parse_toggle(action)? {
1055 Toggle::Enable => crate::commands::daemon::run_install()?,
1056 Toggle::Disable => crate::commands::daemon::run_uninstall()?,
1057 Toggle::Status => crate::commands::daemon::run_status()?,
1058 }
1059 }
1060 Ok(())
1061}
1062
1063pub fn run_hook_toggle(path: Option<&str>, action: &str, chain: bool) -> Result<()> {
1065 if let Some(p) = path {
1066 if chain {
1067 bail!(
1068 "`--chain` changes the single global `core.hooksPath`, so it has no \
1069 per-workspace form. Drop the path: `devp hook install --chain`."
1070 );
1071 }
1072 let repo_path = resolve_workspace(p)?;
1073 let mut cfg = load_workspace_config_for_write(&repo_path)?;
1074 match parse_toggle(action)? {
1075 Toggle::Enable => {
1076 cfg.disable_hooks = false;
1077 cfg.save_to_repo(&repo_path)?;
1078 output::print_success(&format!(
1079 "Enabled background Git hooks for workspace: {}",
1080 output::clean_path(&repo_path)
1081 ));
1082 }
1083 Toggle::Disable => {
1084 cfg.disable_hooks = true;
1085 cfg.save_to_repo(&repo_path)?;
1086 output::print_success(&format!(
1087 "Disabled background Git hooks for workspace: {}",
1088 output::clean_path(&repo_path)
1089 ));
1090 }
1091 Toggle::Status => {
1092 let st = if cfg.disable_hooks {
1093 "Disabled for workspace"
1094 } else {
1095 "Enabled for workspace"
1096 };
1097 output::print_info(&format!(
1098 "Git Hook Status ({}): {}",
1099 output::clean_path(&repo_path),
1100 st
1101 ));
1102 }
1103 }
1104 } else {
1105 match parse_toggle(action)? {
1106 Toggle::Enable => crate::commands::hook::run_install(chain)?,
1107 Toggle::Disable => crate::commands::hook::run_uninstall()?,
1108 Toggle::Status => crate::commands::hook::run_status()?,
1109 }
1110 }
1111 Ok(())
1112}
1113
1114#[cfg(test)]
1115mod tests {
1116 use super::*;
1117
1118 #[test]
1119 fn enable_synonyms_all_resolve_to_enable() {
1120 for word in ["enable", "install", "on", "INSTALL", "On"] {
1121 assert_eq!(parse_toggle(word).unwrap(), Toggle::Enable, "{word}");
1122 }
1123 }
1124
1125 #[test]
1126 fn disable_synonyms_all_resolve_to_disable() {
1127 for word in ["disable", "uninstall", "remove", "off", "Uninstall"] {
1128 assert_eq!(parse_toggle(word).unwrap(), Toggle::Disable, "{word}");
1129 }
1130 }
1131
1132 #[test]
1133 fn status_is_the_default_and_is_also_spellable() {
1134 for word in ["", "status", "show"] {
1135 assert_eq!(parse_toggle(word).unwrap(), Toggle::Status, "{word}");
1136 }
1137 }
1138
1139 #[test]
1140 fn a_typo_is_an_error_rather_than_a_silent_status_report() {
1141 let err = parse_toggle("enabel").unwrap_err().to_string();
1144 assert!(err.contains("enabel"), "{err}");
1145 assert!(err.contains("enable"), "{err}");
1146 }
1147
1148 #[test]
1149 fn a_workspace_toggle_refuses_to_write_over_a_broken_config() {
1150 let tmp = tempfile::TempDir::new().unwrap();
1153 let broken = r#"{ "project_name": "api", "override_idle_days": 90, }"#;
1154 std::fs::write(
1155 tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE),
1156 broken,
1157 )
1158 .unwrap();
1159
1160 let err = load_workspace_config_for_write(tmp.path())
1161 .unwrap_err()
1162 .to_string();
1163 assert!(err.contains("Syntax error"), "{err}");
1164 assert!(err.contains("--update"), "{err}");
1165
1166 let on_disk =
1168 std::fs::read_to_string(tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE))
1169 .unwrap();
1170 assert_eq!(on_disk, broken);
1171 }
1172
1173 #[test]
1174 fn a_workspace_with_no_config_yet_starts_from_the_defaults() {
1175 let tmp = tempfile::TempDir::new().unwrap();
1176 assert_eq!(
1177 load_workspace_config_for_write(tmp.path()).unwrap(),
1178 PerRepoConfig::default()
1179 );
1180 }
1181
1182 #[test]
1183 fn every_setting_round_trips_through_its_own_getter() {
1184 let mut settings = Settings::default();
1188 for setting in SETTINGS {
1189 let before = (setting.get)(&settings);
1190 let probe = match setting.kind {
1191 Kind::Toggle => if before == "true" { "false" } else { "true" }.to_string(),
1192 Kind::Number => "7".to_string(),
1195 Kind::Adapters => "cargo".to_string(),
1198 };
1199 (setting.set)(&mut settings, &probe)
1200 .unwrap_or_else(|e| panic!("{} rejected `{probe}`: {e}", setting.key));
1201 assert_eq!(
1202 (setting.get)(&settings),
1203 probe,
1204 "{} reads back a different field than it writes",
1205 setting.key
1206 );
1207 }
1208 }
1209
1210 #[test]
1211 fn every_setting_is_documented_and_uniquely_named() {
1212 let mut seen = std::collections::HashSet::new();
1213 for setting in SETTINGS {
1214 assert!(seen.insert(setting.key), "duplicate key {}", setting.key);
1215 assert!(!setting.help.is_empty(), "{} has no help", setting.key);
1216 assert!(
1218 setting.help.ends_with('.'),
1219 "{} help should read as a sentence",
1220 setting.key
1221 );
1222 }
1223 }
1224
1225 #[test]
1226 fn the_settings_table_covers_every_field_of_settings() {
1227 let json = serde_json::to_value(Settings::default()).unwrap();
1231 let fields: Vec<String> = json.as_object().unwrap().keys().cloned().collect();
1232 for field in fields {
1233 assert!(
1234 SETTINGS.iter().any(|s| s.key == field),
1235 "`{field}` is a setting with no entry in SETTINGS, so `devp config set \
1236 {field}` cannot reach it"
1237 );
1238 }
1239 }
1240
1241 #[test]
1242 fn a_rejected_value_leaves_the_previous_one_in_place() {
1243 let mut settings = Settings::default();
1244 assert!((find_setting("scan_depth").unwrap().set)(&mut settings, "0").is_err());
1245 assert_eq!(settings.scan_depth, Settings::default().scan_depth);
1246
1247 assert!((find_setting("command_timeout_secs").unwrap().set)(&mut settings, "0").is_err());
1248 assert!((find_setting("check_interval_days").unwrap().set)(&mut settings, "0").is_err());
1249 assert!(
1250 (find_setting("update_check_interval_days").unwrap().set)(&mut settings, "0").is_err()
1251 );
1252 }
1253
1254 #[test]
1255 fn booleans_accept_the_words_people_actually_type() {
1256 assert!(parse_bool("k", "yes").unwrap());
1257 assert!(parse_bool("k", "ON").unwrap());
1258 assert!(!parse_bool("k", "0").unwrap());
1259 assert!(parse_bool("k", "maybe").is_err());
1260 }
1261
1262 #[test]
1263 fn an_unknown_key_lists_the_ones_that_exist() {
1264 let err = match find_setting("idel_days") {
1265 Ok(_) => panic!("`idel_days` is not a setting"),
1266 Err(e) => e.to_string(),
1267 };
1268 assert!(err.contains("idle_days"), "{err}");
1269 }
1270
1271 #[test]
1272 fn a_path_is_never_mistaken_for_an_action() {
1273 assert!(!is_toggle_word("~/Code/my-repo"));
1275 assert!(!is_toggle_word("."));
1276 assert!(!is_toggle_word(""));
1277 assert!(is_toggle_word("install"));
1278 }
1279}