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: "enable_dart",
324 since: "1.6.0",
325 kind: Kind::Toggle,
326 help: "Turn on the opt-in Dart/Flutter adapter (.dart_tool/ holds build caches).",
327 get: |s| s.enable_dart.to_string(),
328 set: |s, v| {
329 s.enable_dart = parse_bool("enable_dart", v)?;
330 Ok(())
331 },
332 },
333 Setting {
334 key: "build_idle_days",
335 since: "1.3.0",
336 kind: Kind::Number,
337 help: "Idle days before cargo/gradle/maven/swift build trees are pruned. Applied as max(this, idle_days).",
338 get: |s| s.build_idle_days.to_string(),
339 set: |s, v| {
340 let days: u64 = v
341 .parse()
342 .map_err(|_| anyhow::anyhow!("build_idle_days must be a non-negative integer"))?;
343 s.build_idle_days = days;
344 Ok(())
345 },
346 },
347 Setting {
348 key: "auto_update",
349 since: "1.3.0",
350 kind: Kind::Toggle,
351 help: "Run `devp update --install` by itself after a prune pass when a newer release exists.",
352 get: |s| s.auto_update.to_string(),
353 set: |s, v| {
354 s.auto_update = parse_bool("auto_update", v)?;
355 Ok(())
356 },
357 },
358 Setting {
359 key: "disabled_adapters",
360 since: "1.4.0",
361 kind: Kind::Adapters,
362 help: "Adapters to leave alone entirely, by name. Empty means every one of them is active.",
363 get: |s| {
364 if s.disabled_adapters.is_empty() {
365 "(none)".to_string()
366 } else {
367 s.disabled_adapters.join(",")
368 }
369 },
370 set: |s, v| {
371 s.disabled_adapters = parse_adapter_list(v)?;
372 Ok(())
373 },
374 },
375 Setting {
376 key: "adapter_idle_days",
377 since: "1.5.0",
378 kind: Kind::AdapterDays,
379 help: "Per-adapter idle windows, as `cargo=60,npm=30`. Each one can only raise its own wait.",
380 get: |s| {
381 if s.adapter_idle_days.is_empty() {
382 "(none)".to_string()
383 } else {
384 s.adapter_idle_days
385 .iter()
386 .map(|(name, days)| format!("{name}={days}"))
387 .collect::<Vec<_>>()
388 .join(",")
389 }
390 },
391 set: |s, v| {
392 s.adapter_idle_days = parse_adapter_days(v)?;
393 Ok(())
394 },
395 },
396];
397
398fn parse_adapter_days(value: &str) -> Result<std::collections::BTreeMap<String, u64>> {
408 let trimmed = value.trim();
409 if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
410 return Ok(std::collections::BTreeMap::new());
411 }
412
413 let mut days = std::collections::BTreeMap::new();
414 for raw in trimmed.split(',') {
415 let entry = raw.trim();
416 if entry.is_empty() {
417 continue;
418 }
419 let Some((name, value)) = entry.split_once('=') else {
420 bail!("`{entry}` must be written as `<adapter>=<days>`, for example `cargo=60`.");
421 };
422 let name = name.trim().to_lowercase();
423 if !crate::adapters::is_adapter_name(&name) {
424 bail!(
425 "`{name}` is not an adapter. Valid names: {}",
426 crate::adapters::all_adapter_names().join(", ")
427 );
428 }
429 let parsed: u64 = value.trim().parse().map_err(|_| {
430 anyhow::anyhow!(
431 "`{name}` needs a whole number of days, not `{}`.",
432 value.trim()
433 )
434 })?;
435 days.insert(name, parsed);
436 }
437 Ok(days)
438}
439
440fn parse_adapter_list(value: &str) -> Result<Vec<String>> {
441 let trimmed = value.trim();
442 if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
445 return Ok(Vec::new());
446 }
447
448 let mut names: Vec<String> = Vec::new();
449 for raw in trimmed.split(',') {
450 let name = raw.trim().to_lowercase();
451 if name.is_empty() {
452 continue;
453 }
454 if !crate::adapters::is_adapter_name(&name) {
455 bail!(
456 "`{name}` is not an adapter. Valid names: {}",
457 crate::adapters::all_adapter_names().join(", ")
458 );
459 }
460 if !names.contains(&name) {
461 names.push(name);
462 }
463 }
464 Ok(names)
465}
466
467fn parse_bool(key: &str, value: &str) -> Result<bool> {
468 match value.trim().to_lowercase().as_str() {
469 "true" | "yes" | "y" | "on" | "1" => Ok(true),
470 "false" | "no" | "n" | "off" | "0" => Ok(false),
471 _ => bail!("{key} must be true or false"),
472 }
473}
474
475pub fn invalid_settings(settings: &Settings) -> Vec<(&'static str, String)> {
486 SETTINGS
487 .iter()
488 .filter_map(|setting| {
489 let mut probe = settings.clone();
490 (setting.set)(&mut probe, &(setting.get)(settings))
491 .err()
492 .map(|e| (setting.key, e.to_string()))
493 })
494 .collect()
495}
496
497pub fn setting_count() -> usize {
499 SETTINGS.len()
500}
501
502fn find_setting(key: &str) -> Result<&'static Setting> {
503 SETTINGS
504 .iter()
505 .find(|s| s.key == key)
506 .ok_or_else(|| anyhow::anyhow!("Unknown config key: {key}. Valid keys: {}", valid_keys()))
507}
508
509fn valid_keys() -> String {
510 SETTINGS
511 .iter()
512 .map(|s| s.key)
513 .collect::<Vec<_>>()
514 .join(", ")
515}
516
517#[derive(Debug, PartialEq, Eq)]
519pub enum Toggle {
520 Enable,
521 Disable,
522 Status,
523}
524
525pub fn parse_toggle(action: &str) -> Result<Toggle> {
535 match action.to_lowercase().as_str() {
536 "enable" | "install" | "on" => Ok(Toggle::Enable),
537 "disable" | "uninstall" | "remove" | "off" => Ok(Toggle::Disable),
538 "" | "status" | "show" => Ok(Toggle::Status),
539 other => bail!(
540 "Unknown action `{other}`. Expected `enable`, `disable` or `status` \
541 (`install` / `uninstall` / `on` / `off` also work)."
542 ),
543 }
544}
545
546pub fn is_toggle_word(word: &str) -> bool {
552 parse_toggle(word).is_ok() && !word.is_empty()
553}
554
555fn resolve_workspace(path: &str) -> Result<std::path::PathBuf> {
562 let raw = Path::new(path);
563 if !raw.is_dir() {
564 bail!(
565 "`{path}` is neither an action nor an existing directory.\n\
566 Expected `enable`, `disable` or `status`, or a path to a repository."
567 );
568 }
569 Ok(raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf()))
570}
571
572pub fn run_get(key: &str) -> Result<()> {
574 let registry = Registry::load()?;
575 let setting = find_setting(key)?;
576 println!("{key} = {}", (setting.get)(®istry.settings));
577 Ok(())
578}
579
580pub fn run_set(key: &str, value: &str) -> Result<()> {
582 let mut registry = Registry::load()?;
583 let setting = find_setting(key)?;
584 (setting.set)(&mut registry.settings, value)?;
585 registry.save()?;
586
587 output::print_success(&format!("{key} = {}", (setting.get)(®istry.settings)));
590 Ok(())
591}
592
593fn key_column_width() -> usize {
595 SETTINGS.iter().map(|s| s.key.len()).max().unwrap_or(0)
596}
597
598pub fn run_show() -> Result<()> {
600 let registry = Registry::load()?;
601 let width = key_column_width();
602
603 output::print_header("dev-prune Global Configuration");
604 for setting in SETTINGS {
605 println!(
606 " {:<width$} = {}",
607 setting.key,
608 (setting.get)(®istry.settings)
609 );
610 }
611 println!(" {:<width$} = {}", "tracked_repos", registry.repo_count());
612
613 let reg_path = Registry::registry_path()
614 .map(|p| output::clean_path(&p))
615 .unwrap_or_else(|_| "unknown".to_string());
616 println!("\n {:<width$} = {reg_path}", "registry_file");
617 println!();
618 output::print_info("Change any of these with `devp config set <key> <value>`.");
619 output::print_info("Walk through them one at a time with `devp config wizard`.");
620
621 Ok(())
622}
623
624pub fn run_wizard(no_tui: bool) -> Result<()> {
635 if !no_tui && full_screen_is_usable() {
636 return run_wizard_tui();
637 }
638 run_wizard_prompts()
639}
640
641fn full_screen_is_usable() -> bool {
649 use std::io::IsTerminal;
650 if std::env::var_os(crate::constants::ENV_NO_TUI).is_some() {
651 return false;
652 }
653 std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
654}
655
656fn run_wizard_tui() -> Result<()> {
658 use crate::tui::config_view::{ConfigRow, ConfigSession, Control, Outcome};
659
660 let mut registry = Registry::load()?;
661 let new_keys = settings_added_since_review();
662
663 let rows: Vec<ConfigRow> = SETTINGS
664 .iter()
665 .map(|setting| {
666 let value = (setting.get)(®istry.settings);
667 ConfigRow {
668 key: setting.key,
669 help: setting.help,
670 control: match setting.kind {
671 Kind::Toggle => Control::Toggle,
672 Kind::Number => Control::Number,
673 Kind::Adapters => Control::Adapters,
674 Kind::AdapterDays => Control::AdapterDays,
675 },
676 original: value.clone(),
677 value,
678 is_new: new_keys.contains(&setting.key),
679 }
680 })
681 .collect();
682
683 let base = registry.settings.clone();
686 let validate = move |key: &str, value: &str| -> std::result::Result<(), String> {
687 let setting = find_setting(key).map_err(|e| e.to_string())?;
688 let mut probe = base.clone();
689 (setting.set)(&mut probe, value).map_err(|e| format!("{e}"))
690 };
691
692 let report = crate::commands::trust::build(®istry);
693 let adapters = crate::adapters::all_adapter_names();
694 let opt_in = crate::adapters::opt_in_adapter_names();
695
696 let outcome = crate::tui::config_view::run(ConfigSession {
697 declaration: declaration_lines(&report),
698 standing: NOTHING_DELETED_YET.to_string(),
699 rows,
700 adapters: &adapters,
701 opt_in_adapters: &opt_in,
702 groups: crate::adapters::ADAPTER_GROUPS,
703 validate: &validate,
704 title: "dev-prune configuration",
705 })?;
706
707 match outcome {
708 Outcome::Cancelled => {
712 output::print_info("Cancelled — nothing was changed.");
713 Ok(())
714 }
715 Outcome::KeepAll => {
716 mark_reviewed();
717 output::print_success(
718 "Keeping the current values. `devp config set <key> <value>` changes any.",
719 );
720 Ok(())
721 }
722 Outcome::Save(changed) => {
723 for row in &changed {
724 (find_setting(row.key)?.set)(&mut registry.settings, &row.value)?;
725 }
726 registry.save()?;
727 mark_reviewed();
728
729 output::print_header("Saved");
733 let width = changed.iter().map(|r| r.key.len()).max().unwrap_or(0);
734 for row in &changed {
735 println!(
736 " {:<width$} = {} (was {})",
737 row.key, row.value, row.original
738 );
739 }
740 println!();
741 output::print_success(&format!(
742 "{} {} saved. `devp config show` lists every setting.",
743 changed.len(),
744 output::plural(changed.len(), "change", "changes")
745 ));
746 Ok(())
747 }
748 }
749}
750
751const NOTHING_DELETED_YET: &str =
753 "Nothing has been deleted, and nothing will be until a lockfile proves it comes back.";
754
755fn declaration_lines(
761 report: &crate::commands::trust::TrustReport,
762) -> Vec<crate::tui::config_view::DeclarationLine> {
763 use crate::commands::trust::{TrustRow, Verdict};
764 use crate::tui::config_view::DeclarationLine;
765
766 let heading = |text: &str| DeclarationLine {
767 mark: '#',
768 subject: text.to_string(),
769 state: String::new(),
770 };
771 let row = |r: &TrustRow| DeclarationLine {
772 mark: match r.verdict {
773 Verdict::Guaranteed | Verdict::Safe => '+',
774 Verdict::Widened => '!',
775 Verdict::Neutral => ' ',
776 },
777 subject: r.subject.to_string(),
778 state: r.state.clone(),
779 };
780
781 let mut lines = vec![heading("Guaranteed by the code")];
782 lines.extend(report.guarantees.iter().map(&row));
783 lines.push(heading(""));
784 lines.push(heading("On this machine"));
785 lines.extend(report.machine.iter().map(&row));
786 lines
787}
788
789fn run_wizard_prompts() -> Result<()> {
793 use std::io::{self, IsTerminal, Write};
794
795 if !io::stdin().is_terminal() {
796 bail!(
797 "`devp config wizard` needs a terminal to ask questions on.\n\
798 Use `devp config show` to read the settings and `devp config set <key> <value>` \
799 to change one."
800 );
801 }
802
803 let mut registry = Registry::load()?;
804 let width = key_column_width();
805 let new_keys = settings_added_since_review();
806
807 output::print_header("dev-prune configuration");
808 output::print_info("These are the defaults every run will use. Nothing has been changed yet.");
809 println!();
810 for setting in SETTINGS {
811 let badge = if new_keys.contains(&setting.key) {
814 " (new in this version)"
815 } else {
816 ""
817 };
818 println!(
819 " {:<width$} = {}{badge}",
820 setting.key,
821 (setting.get)(®istry.settings)
822 );
823 println!(" {:<width$} {}", "", setting.help);
824 }
825 println!();
826
827 print!("Keep all of these? [Y/n] ");
828 io::stdout().flush()?;
829 let mut answer = String::new();
830 io::stdin().read_line(&mut answer)?;
831 let keep = !matches!(answer.trim().to_lowercase().as_str(), "n" | "no");
832
833 if keep {
834 mark_reviewed();
835 output::print_success("Keeping the defaults. `devp config set <key> <value>` changes any.");
836 return Ok(());
837 }
838
839 println!();
840 output::print_info("Enter a new value, or press Enter to keep the one shown.");
841 println!();
842
843 let mut changed = 0usize;
844 for setting in SETTINGS {
845 let current = (setting.get)(®istry.settings);
846 loop {
847 print!(" {} [{current}]: ", setting.key);
848 io::stdout().flush()?;
849 let mut line = String::new();
850 if io::stdin().read_line(&mut line)? == 0 {
853 println!();
854 break;
855 }
856 let typed = line.trim();
857 if typed.is_empty() {
858 break;
859 }
860 match (setting.set)(&mut registry.settings, typed) {
861 Ok(()) => {
862 changed += 1;
863 break;
864 }
865 Err(e) => output::print_error(&format!("{e}")),
868 }
869 }
870 }
871
872 registry.save()?;
873 mark_reviewed();
874 println!();
875 if changed == 0 {
876 output::print_success("Nothing changed — the defaults are in place.");
877 } else {
878 output::print_success(&format!(
879 "Saved {changed} {}. `devp config show` lists them all.",
880 output::plural(changed, "change", "changes")
881 ));
882 }
883 Ok(())
884}
885
886const REVIEW_MARKER: &str = "config-reviewed";
888
889pub fn config_review_is_due() -> bool {
898 let Ok(dir) = Registry::config_dir() else {
899 return false;
900 };
901 if !dir.join(REVIEW_MARKER).exists() {
902 return true;
903 }
904 !settings_added_since_review().is_empty()
905}
906
907fn reviewed_version() -> Option<String> {
909 let dir = Registry::config_dir().ok()?;
910 let recorded = std::fs::read_to_string(dir.join(REVIEW_MARKER)).ok()?;
911 let recorded = recorded.trim().to_string();
912 (!recorded.is_empty()).then_some(recorded)
913}
914
915pub fn settings_added_since_review() -> Vec<&'static str> {
924 let Some(reviewed) = reviewed_version() else {
925 return Vec::new();
926 };
927 SETTINGS
928 .iter()
929 .filter(|s| {
930 crate::commands::update::compare_versions(s.since, &reviewed)
931 == Some(std::cmp::Ordering::Greater)
932 })
933 .map(|s| s.key)
934 .collect()
935}
936
937fn mark_reviewed() {
938 if let Ok(dir) = Registry::config_dir() {
939 let _ = std::fs::create_dir_all(&dir);
940 let _ = std::fs::write(dir.join(REVIEW_MARKER), crate::constants::VERSION);
941 }
942}
943
944pub fn skip_config_review() {
949 mark_reviewed();
950}
951
952pub fn run_global_update() -> Result<()> {
954 output::print_header("dev-prune Global Configuration Audit & Sync");
955
956 let registry = Registry::load()?;
957 let mut total_audited = 0;
958 let mut errors_found = 0;
959
960 for repo_path in registry.repositories.keys() {
961 let clean = output::clean_path(repo_path);
962
963 if !repo_path.exists() {
967 output::print_warning(&format!(
968 "Skipped {clean} — the path no longer exists. `devp unlink --missing` \
969 clears such entries."
970 ));
971 continue;
972 }
973 total_audited += 1;
974
975 match PerRepoConfig::load_with_diagnostics(repo_path) {
976 Ok(Some(cfg)) => {
977 if let Err(e) = cfg.save_to_repo(repo_path) {
978 output::print_error(&format!("Failed to write config for {clean}: {e}"));
979 errors_found += 1;
980 } else {
981 output::print_success(&format!("Audited & synced config for {clean}"));
982 }
983 }
984 Ok(None) => {
985 output::print_info(&format!(
989 "{clean} has no .devprune.json — global defaults apply."
990 ));
991 }
992 Err(err_msg) => {
993 errors_found += 1;
994 output::print_error(&format!("Syntax/Schema Error in {clean}:"));
995 for line in err_msg.lines() {
996 eprintln!(" {line}");
997 }
998 output::print_info(&format!(
999 "Hint: fix the syntax by hand, or run `devp config {clean} --update` to \
1000 replace the file with a valid default."
1001 ));
1002 }
1003 }
1004 }
1005
1006 if errors_found > 0 {
1007 anyhow::bail!(
1010 "Audit complete: {total_audited} repos checked, {errors_found} could not be read \
1011 or written."
1012 );
1013 }
1014 output::print_success(&format!(
1015 "Audit complete: All {total_audited} registered repositories are healthy & synced!"
1016 ));
1017
1018 Ok(())
1019}
1020
1021pub fn run_path_config(path_str: &str, force_update: bool) -> Result<()> {
1023 let raw_path = Path::new(path_str);
1024
1025 let path = if raw_path.exists() {
1026 raw_path
1027 .canonicalize()
1028 .unwrap_or_else(|_| raw_path.to_path_buf())
1029 } else {
1030 raw_path.to_path_buf()
1031 };
1032
1033 let clean = output::clean_path(&path);
1034
1035 if !path.exists() {
1036 bail!("Path does not exist: {clean}");
1037 }
1038
1039 if !crate::scanner::is_git_repo(&path) {
1040 bail!(
1042 "`{clean}` is not a Git repository.\n \
1043 Run `git init` there first, then `devp config {clean}` again."
1044 );
1045 }
1046
1047 let mut registry = Registry::load()?;
1048 if !registry.repositories.contains_key(&path) {
1049 output::print_info(&format!(
1050 "{clean} is not yet registered with dev-prune. Registering now..."
1051 ));
1052 registry.add_repo(path.clone());
1053 registry.save()?;
1054 }
1055
1056 let cfg_file = path.join(crate::constants::PER_REPO_CONFIG_FILE);
1057
1058 if cfg_file.exists() && !force_update {
1059 output::print_header(&format!("dev-prune Per-Repo Config for {clean}"));
1060 match PerRepoConfig::load_with_diagnostics(&path) {
1061 Ok(cfg) => {
1062 let json_str = serde_json::to_string_pretty(&cfg)?;
1063 println!("{json_str}");
1064 output::print_info("File location: .devprune.json");
1065 }
1066 Err(err_msg) => {
1067 output::print_error(&format!("Invalid configuration in {clean}:"));
1068 for line in err_msg.lines() {
1069 eprintln!(" {line}");
1070 }
1071 anyhow::bail!(
1074 "Run `devp config {clean} --update` to reset this file back to defaults \
1075 (your current overrides in it are discarded)."
1076 );
1077 }
1078 }
1079 } else {
1080 output::print_info(&format!("Initializing .devprune.json for {clean}..."));
1081 let cfg = PerRepoConfig::default();
1082 cfg.save_to_repo(&path)?;
1083 output::print_success(&format!("Created .devprune.json in {clean}"));
1084 }
1085
1086 Ok(())
1087}
1088
1089fn load_workspace_config_for_write(repo_path: &Path) -> Result<PerRepoConfig> {
1095 match PerRepoConfig::load_with_diagnostics(repo_path) {
1096 Ok(Some(cfg)) => Ok(cfg),
1097 Ok(None) => Ok(PerRepoConfig::default()),
1098 Err(e) => bail!(
1099 "{e}\n \
1100 Fix that file, or run `devp config {} --update` to reset it back to defaults \
1101 (your current overrides in it are discarded).",
1102 output::clean_path(repo_path)
1103 ),
1104 }
1105}
1106
1107pub fn run_daemon_toggle(path: Option<&str>, action: &str) -> Result<()> {
1109 if let Some(p) = path {
1110 let repo_path = resolve_workspace(p)?;
1111 let mut cfg = load_workspace_config_for_write(&repo_path)?;
1112 match parse_toggle(action)? {
1113 Toggle::Enable => {
1114 cfg.disable_daemon = false;
1115 cfg.save_to_repo(&repo_path)?;
1116 output::print_success(&format!(
1117 "Enabled background daemon for workspace: {}",
1118 output::clean_path(&repo_path)
1119 ));
1120 }
1121 Toggle::Disable => {
1122 cfg.disable_daemon = true;
1123 cfg.save_to_repo(&repo_path)?;
1124 output::print_success(&format!(
1125 "Disabled background daemon for workspace: {}",
1126 output::clean_path(&repo_path)
1127 ));
1128 }
1129 Toggle::Status => {
1130 let st = if cfg.disable_daemon {
1131 "Disabled for workspace"
1132 } else {
1133 "Enabled for workspace"
1134 };
1135 output::print_info(&format!(
1136 "Daemon Status ({}): {}",
1137 output::clean_path(&repo_path),
1138 st
1139 ));
1140 }
1141 }
1142 } else {
1143 match parse_toggle(action)? {
1144 Toggle::Enable => crate::commands::daemon::run_install()?,
1145 Toggle::Disable => crate::commands::daemon::run_uninstall()?,
1146 Toggle::Status => crate::commands::daemon::run_status()?,
1147 }
1148 }
1149 Ok(())
1150}
1151
1152pub fn run_hook_toggle(path: Option<&str>, action: &str, chain: bool) -> Result<()> {
1154 if let Some(p) = path {
1155 if chain {
1156 bail!(
1157 "`--chain` changes the single global `core.hooksPath`, so it has no \
1158 per-workspace form. Drop the path: `devp hook install --chain`."
1159 );
1160 }
1161 let repo_path = resolve_workspace(p)?;
1162 let mut cfg = load_workspace_config_for_write(&repo_path)?;
1163 match parse_toggle(action)? {
1164 Toggle::Enable => {
1165 cfg.disable_hooks = false;
1166 cfg.save_to_repo(&repo_path)?;
1167 output::print_success(&format!(
1168 "Enabled background Git hooks for workspace: {}",
1169 output::clean_path(&repo_path)
1170 ));
1171 }
1172 Toggle::Disable => {
1173 cfg.disable_hooks = true;
1174 cfg.save_to_repo(&repo_path)?;
1175 output::print_success(&format!(
1176 "Disabled background Git hooks for workspace: {}",
1177 output::clean_path(&repo_path)
1178 ));
1179 }
1180 Toggle::Status => {
1181 let st = if cfg.disable_hooks {
1182 "Disabled for workspace"
1183 } else {
1184 "Enabled for workspace"
1185 };
1186 output::print_info(&format!(
1187 "Git Hook Status ({}): {}",
1188 output::clean_path(&repo_path),
1189 st
1190 ));
1191 }
1192 }
1193 } else {
1194 match parse_toggle(action)? {
1195 Toggle::Enable => crate::commands::hook::run_install(chain)?,
1196 Toggle::Disable => crate::commands::hook::run_uninstall()?,
1197 Toggle::Status => crate::commands::hook::run_status()?,
1198 }
1199 }
1200 Ok(())
1201}
1202
1203#[cfg(test)]
1204mod tests {
1205 use super::*;
1206
1207 #[test]
1208 fn enable_synonyms_all_resolve_to_enable() {
1209 for word in ["enable", "install", "on", "INSTALL", "On"] {
1210 assert_eq!(parse_toggle(word).unwrap(), Toggle::Enable, "{word}");
1211 }
1212 }
1213
1214 #[test]
1215 fn disable_synonyms_all_resolve_to_disable() {
1216 for word in ["disable", "uninstall", "remove", "off", "Uninstall"] {
1217 assert_eq!(parse_toggle(word).unwrap(), Toggle::Disable, "{word}");
1218 }
1219 }
1220
1221 #[test]
1222 fn status_is_the_default_and_is_also_spellable() {
1223 for word in ["", "status", "show"] {
1224 assert_eq!(parse_toggle(word).unwrap(), Toggle::Status, "{word}");
1225 }
1226 }
1227
1228 #[test]
1229 fn a_typo_is_an_error_rather_than_a_silent_status_report() {
1230 let err = parse_toggle("enabel").unwrap_err().to_string();
1233 assert!(err.contains("enabel"), "{err}");
1234 assert!(err.contains("enable"), "{err}");
1235 }
1236
1237 #[test]
1238 fn a_workspace_toggle_refuses_to_write_over_a_broken_config() {
1239 let tmp = tempfile::TempDir::new().unwrap();
1242 let broken = r#"{ "project_name": "api", "override_idle_days": 90, }"#;
1243 std::fs::write(
1244 tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE),
1245 broken,
1246 )
1247 .unwrap();
1248
1249 let err = load_workspace_config_for_write(tmp.path())
1250 .unwrap_err()
1251 .to_string();
1252 assert!(err.contains("Syntax error"), "{err}");
1253 assert!(err.contains("--update"), "{err}");
1254
1255 let on_disk =
1257 std::fs::read_to_string(tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE))
1258 .unwrap();
1259 assert_eq!(on_disk, broken);
1260 }
1261
1262 #[test]
1263 fn a_workspace_with_no_config_yet_starts_from_the_defaults() {
1264 let tmp = tempfile::TempDir::new().unwrap();
1265 assert_eq!(
1266 load_workspace_config_for_write(tmp.path()).unwrap(),
1267 PerRepoConfig::default()
1268 );
1269 }
1270
1271 #[test]
1272 fn every_setting_round_trips_through_its_own_getter() {
1273 let mut settings = Settings::default();
1277 for setting in SETTINGS {
1278 let before = (setting.get)(&settings);
1279 let probe = match setting.kind {
1280 Kind::Toggle => if before == "true" { "false" } else { "true" }.to_string(),
1281 Kind::Number => "7".to_string(),
1284 Kind::Adapters => "cargo".to_string(),
1287 Kind::AdapterDays => "cargo=45".to_string(),
1290 };
1291 (setting.set)(&mut settings, &probe)
1292 .unwrap_or_else(|e| panic!("{} rejected `{probe}`: {e}", setting.key));
1293 assert_eq!(
1294 (setting.get)(&settings),
1295 probe,
1296 "{} reads back a different field than it writes",
1297 setting.key
1298 );
1299 }
1300 }
1301
1302 #[test]
1303 fn every_setting_is_documented_and_uniquely_named() {
1304 let mut seen = std::collections::HashSet::new();
1305 for setting in SETTINGS {
1306 assert!(seen.insert(setting.key), "duplicate key {}", setting.key);
1307 assert!(!setting.help.is_empty(), "{} has no help", setting.key);
1308 assert!(
1310 setting.help.ends_with('.'),
1311 "{} help should read as a sentence",
1312 setting.key
1313 );
1314 }
1315 }
1316
1317 #[test]
1318 fn the_settings_table_covers_every_field_of_settings() {
1319 let json = serde_json::to_value(Settings::default()).unwrap();
1323 let fields: Vec<String> = json.as_object().unwrap().keys().cloned().collect();
1324 for field in fields {
1325 assert!(
1326 SETTINGS.iter().any(|s| s.key == field),
1327 "`{field}` is a setting with no entry in SETTINGS, so `devp config set \
1328 {field}` cannot reach it"
1329 );
1330 }
1331 }
1332
1333 #[test]
1334 fn a_rejected_value_leaves_the_previous_one_in_place() {
1335 let mut settings = Settings::default();
1336 assert!((find_setting("scan_depth").unwrap().set)(&mut settings, "0").is_err());
1337 assert_eq!(settings.scan_depth, Settings::default().scan_depth);
1338
1339 assert!((find_setting("command_timeout_secs").unwrap().set)(&mut settings, "0").is_err());
1340 assert!((find_setting("check_interval_days").unwrap().set)(&mut settings, "0").is_err());
1341 assert!(
1342 (find_setting("update_check_interval_days").unwrap().set)(&mut settings, "0").is_err()
1343 );
1344 }
1345
1346 #[test]
1347 fn booleans_accept_the_words_people_actually_type() {
1348 assert!(parse_bool("k", "yes").unwrap());
1349 assert!(parse_bool("k", "ON").unwrap());
1350 assert!(!parse_bool("k", "0").unwrap());
1351 assert!(parse_bool("k", "maybe").is_err());
1352 }
1353
1354 #[test]
1355 fn an_unknown_key_lists_the_ones_that_exist() {
1356 let err = match find_setting("idel_days") {
1357 Ok(_) => panic!("`idel_days` is not a setting"),
1358 Err(e) => e.to_string(),
1359 };
1360 assert!(err.contains("idle_days"), "{err}");
1361 }
1362
1363 #[test]
1364 fn a_path_is_never_mistaken_for_an_action() {
1365 assert!(!is_toggle_word("~/Code/my-repo"));
1367 assert!(!is_toggle_word("."));
1368 assert!(!is_toggle_word(""));
1369 assert!(is_toggle_word("install"));
1370 }
1371}