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