use anyhow::{Result, bail};
use std::path::Path;
use crate::config::{PerRepoConfig, Registry, Settings};
use crate::i18n;
use crate::output;
struct Setting {
key: &'static str,
category: Category,
since: &'static str,
kind: Kind,
help: &'static str,
plain: &'static str,
get: fn(&Settings) -> String,
set: fn(&mut Settings, &str) -> Result<()>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Category {
Presentation,
Scope,
Safety,
BuildTrees,
Caches,
Unattended,
Updates,
}
impl Category {
fn title(self) -> &'static str {
match self {
Category::Presentation => i18n::t("config.category.presentation"),
Category::Scope => i18n::t("config.category.scope"),
Category::Safety => i18n::t("config.category.safety"),
Category::BuildTrees => i18n::t("config.category.build_trees"),
Category::Caches => i18n::t("config.category.caches"),
Category::Unattended => i18n::t("config.category.unattended"),
Category::Updates => i18n::t("config.category.updates"),
}
}
}
const CATEGORIES: &[Category] = &[
Category::Presentation,
Category::Scope,
Category::Safety,
Category::BuildTrees,
Category::Caches,
Category::Unattended,
Category::Updates,
];
fn settings_by_category() -> Vec<(Category, Vec<&'static Setting>)> {
CATEGORIES
.iter()
.map(|&category| {
(
category,
SETTINGS.iter().filter(|s| s.category == category).collect(),
)
})
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Kind {
Toggle,
Number,
Adapters,
CacheCaps,
Choice,
AdapterDays,
}
struct Recommendation {
key: &'static str,
label: &'static str,
why: &'static str,
value: &'static str,
cautious: bool,
taken: Option<fn(&str) -> bool>,
}
const SAFE_TIER: &str = "Recommended";
const CAUTIOUS_TIER: &str = "Recommended, with one thing to know first";
const RECOMMENDED: &[Recommendation] = &[
Recommendation {
key: "enable_cargo",
label: "Rust build folders",
why: "Rust `target/` directories are usually the largest thing on a developer's disk — \
tens of gigabytes across a handful of old projects. Nothing is lost: `cargo build` \
rebuilds it, and a project has to sit untouched for 45 days before this one is even \
considered.",
value: "true",
cautious: false,
taken: None,
},
Recommendation {
key: "enable_gradle",
label: "Android / Gradle builds",
why: "`build/` and `.gradle/` grow with every Android build and are never cleaned up by \
anything else. They come back on the next build, under the same 45-day wait.",
value: "true",
cautious: false,
taken: None,
},
Recommendation {
key: "enable_maven",
label: "Maven builds",
why: "Maven `target/` directories accumulate quietly per module, so a multi-module project \
has several. `mvn package` brings them back.",
value: "true",
cautious: false,
taken: None,
},
Recommendation {
key: "enable_swift",
label: "Swift builds",
why: "`.build/` holds compiled modules for every configuration you have ever built, and \
`swift build` recreates the one you actually use.",
value: "true",
cautious: false,
taken: None,
},
Recommendation {
key: "enable_dart",
label: "Dart / Flutter caches",
why: "`.dart_tool/` carries the pub metadata — back in a second — alongside `build_runner` \
and `flutter_build` caches that are worth real disk space.",
value: "true",
cautious: false,
taken: None,
},
Recommendation {
key: "enable_mix_build",
label: "Elixir build trees",
why: "`_build/` holds compiled beam files for every Mix environment you have built, and \
`mix compile` recreates the one you are working in.",
value: "true",
cautious: false,
taken: None,
},
Recommendation {
key: "enable_vcpkg",
label: "C / C++ vcpkg trees",
why: "`vcpkg_installed/` holds libraries vcpkg compiled from source for one \
project, and `vcpkg install` builds them again from the manifest beside \
them.",
value: "true",
cautious: false,
taken: None,
},
Recommendation {
key: "enable_cmake_build",
label: "C / C++ CMake build trees",
why: "A configured CMake build tree is object files and linked binaries, and \
`cmake` writes a `CMakeCache.txt` at the top of it that says which sources \
build it again — so a `build/` you made by hand is left alone.",
value: "true",
cautious: false,
taken: None,
},
Recommendation {
key: "enable_dotnet_build",
label: ".NET bin/ and obj/ output",
why: "NuGet's restore writes `obj/project.assets.json` naming the project it \
restored, so only output `dotnet build` wrote is claimed — a committed \
`bin/` holding anything of yours is left alone.",
value: "true",
cautious: false,
taken: None,
},
Recommendation {
key: "enable_godot",
label: "Godot imported-resource caches",
why: "`.godot/` (and `.import/` on Godot 3) holds the editor's import cache for \
every asset, often larger than the assets themselves, and the editor \
rebuilds it the next time it opens the project.",
value: "true",
cautious: false,
taken: None,
},
Recommendation {
key: "enable_unity",
label: "Unity imported-asset database",
why: "`Library/` holds the editor's imported copy of every asset and is routinely \
larger than the assets themselves; the editor rebuilds it on the next open, \
and a project the editor has open right now is refused outright.",
value: "true",
cautious: false,
taken: None,
},
Recommendation {
key: "enable_unreal",
label: "Unreal derived data and intermediates",
why: "`DerivedDataCache/` and `Intermediate/` are compiled shaders and build \
output the editor and UnrealBuildTool regenerate; `Saved/` and `Binaries/` \
are never touched.",
value: "true",
cautious: false,
taken: None,
},
Recommendation {
key: "enable_defold",
label: "Defold build output",
why: "`build/` beside `game.project` is the compiled project, and the editor or \
bob rebuilds it in full on the next build.",
value: "true",
cautious: false,
taken: None,
},
Recommendation {
key: "enable_cocos",
label: "Cocos Creator imported-asset caches",
why: "`library/` and `temp/` are the editor's import caches, rebuilt from the \
committed sources on the next open; a plain Node `package.json` without \
Creator's own keys is never claimed.",
value: "true",
cautious: false,
taken: None,
},
Recommendation {
key: "enable_zig",
label: "Zig build caches and install output",
why: "`.zig-cache/` (or the older `zig-cache/`) and `zig-out/` are written only \
by `zig build` and come back in full on the next one; `build.zig` is \
checked for its `pub fn build` entry point first.",
value: "true",
cautious: false,
taken: None,
},
Recommendation {
key: "enable_stack",
label: "Haskell Stack build tree",
why: "`.stack-work/` is compiled output that `stack build` regenerates against \
the snapshot `stack.yaml` pins; a project file naming no snapshot is \
refused.",
value: "true",
cautious: false,
taken: None,
},
Recommendation {
key: "enable_cabal",
label: "Haskell Cabal build tree",
why: "`dist-newstyle/` is compiled output that `cabal build` regenerates from \
the packages `cabal.project` declares.",
value: "true",
cautious: false,
taken: None,
},
Recommendation {
key: "enable_sbt",
label: "sbt build trees",
why: "`target/` and `project/target/` beside `build.sbt` are compiled output \
that `sbt compile` regenerates; sub-project targets deeper in a \
multi-module build are left alone.",
value: "true",
cautious: false,
taken: None,
},
Recommendation {
key: "cache_max_gb",
label: "Cache size ceilings",
why: "Every other suggestion here is about one project's folders. This one is about the \
download caches all of them share, which only ever grow — npm's on the machine this \
was written on had passed 10 GiB while every project it served fit in a fraction of \
that. `default=10` is one ceiling for every manager at once. Crossing it deletes \
nothing: `devp caches` says which manager is over, and `devp caches clear \
--over-cap all` is still typed by hand. Name a manager to give it a figure of its \
own — `devp config set cache_max_gb default=10,npm=4`.",
value: crate::constants::RECOMMENDED_CACHE_CAP,
cautious: false,
taken: Some(|current| parse_cache_caps(current).is_ok_and(|caps| !caps.is_empty())),
},
Recommendation {
key: "allow_manifest_rewrite",
label: "Let cargo and go tidy up",
why: "Cautious, not risky. The commands that restore a Rust or Go project can also update \
`Cargo.lock` or `go.mod` — files Git tracks. Nothing is lost and nothing is deleted, \
but the next `git status` may show a change you did not make by hand. Turn it on if \
that is fine; leave it off if a clean working tree matters more than a fully \
automatic restore.",
value: "true",
cautious: true,
taken: None,
},
];
const SETTINGS: &[Setting] = &[
Setting {
key: "language",
category: Category::Presentation,
since: "1.10.0",
kind: Kind::Choice,
help: "Language for dev-prune's own headings and summary lines. `--json`, exit codes, flag names and config keys stay English in every language.",
plain: "What language dev-prune talks to you in. Only its own headings change — the words you type and anything a script reads stay in English, so nothing breaks. Everything but English is a community translation, and some have not been proofread yet.",
get: |s| s.language.clone(),
set: |s, v| {
let code = v.trim();
let Some(meta) = i18n::language(code) else {
bail!(
"unknown language `{code}` — available: {}",
i18n::catalogue_line()
);
};
s.language = meta.code.clone();
Ok(())
},
},
Setting {
key: "recommendations",
category: Category::Presentation,
since: "1.22.0",
kind: Kind::Toggle,
help: "End a dry run with the switched-off opt-in adapters that would have found something. Changes nothing about what is scanned or deleted.",
plain: "Whether a dry run points out build-tool and game-engine directories it noticed but is not allowed to touch, with the command that would let it. Turn it off once you have made your choices and the reminder has become noise.",
get: |s| s.recommendations.to_string(),
set: |s, v| {
s.recommendations = parse_bool("recommendations", v)?;
Ok(())
},
},
Setting {
key: "idle_days",
category: Category::Scope,
since: "1.0.0",
kind: Kind::Number,
help: "Days a repository must sit untouched before it is eligible for pruning.",
plain: "How long a project has to sit untouched before dev-prune will clean it. Something you worked on yesterday is never touched.",
get: |s| s.idle_days.to_string(),
set: |s, v| {
s.idle_days = v
.parse()
.map_err(|_| anyhow::anyhow!("idle_days must be a whole number of days"))?;
Ok(())
},
},
Setting {
key: "min_size_mb",
category: Category::Scope,
since: "1.0.0",
kind: Kind::Number,
help: "Smallest bloat directory worth deleting, in MiB. 0 removes the floor.",
plain: "Ignore small folders. Deleting a 2 MB folder is not worth the download to get it back.",
get: |s| s.min_size_mb.to_string(),
set: |s, v| {
s.min_size_mb = v.parse().map_err(|_| {
anyhow::anyhow!("min_size_mb must be a whole number of MiB (0 disables the floor)")
})?;
Ok(())
},
},
Setting {
key: "scan_depth",
category: Category::Scope,
since: "1.0.0",
kind: Kind::Number,
help: "How many directory levels below a repo root project discovery descends.",
plain: "How deep inside a repository to look for projects. Raise it if your projects live several folders down; lower it if scanning feels slow.",
get: |s| s.scan_depth.to_string(),
set: |s, v| {
let depth: usize = v
.parse()
.map_err(|_| anyhow::anyhow!("scan_depth must be a positive integer"))?;
if depth == 0 {
bail!("scan_depth must be at least 1 — 0 would find no projects at all.");
}
if depth > crate::constants::MAX_SCAN_DEPTH_LIMIT {
bail!(
"scan_depth must be at most {} — deeper walks stall on generated trees.",
crate::constants::MAX_SCAN_DEPTH_LIMIT
);
}
s.scan_depth = depth;
Ok(())
},
},
Setting {
key: "require_confirmation",
category: Category::Safety,
since: "1.0.0",
kind: Kind::Toggle,
help: "Ask before deleting anything. Turning this off makes every run unattended.",
plain: "Whether dev-prune asks \"delete these?\" before it deletes. Leave this on unless you want it to run silently while you are away.",
get: |s| s.require_confirmation.to_string(),
set: |s, v| {
s.require_confirmation = parse_bool("require_confirmation", v)?;
Ok(())
},
},
Setting {
key: "allow_manifest_rewrite",
category: Category::Safety,
since: "1.0.0",
kind: Kind::Toggle,
help: "Let cargo and go run the sync command that rewrites tracked manifests.",
plain: "Lets dev-prune run the command that puts a Rust or Go project back together — which can edit files that are checked into Git. Nothing is lost, but the change shows up in `git status`.",
get: |s| s.allow_manifest_rewrite.to_string(),
set: |s, v| {
s.allow_manifest_rewrite = parse_bool("allow_manifest_rewrite", v)?;
Ok(())
},
},
Setting {
key: "command_timeout_secs",
category: Category::Safety,
since: "1.0.0",
kind: Kind::Number,
help: "How long one package-manager command may run before it is killed — the lockfile check and `devp restore`, never a recompile.",
plain: "How long to wait for a package manager to answer before giving up on it: the lockfile check before a delete, and the reinstall `devp restore` runs. Nothing is compiled under it — the opt-in build adapters run no command at all during a prune — except a restore whose install builds a native module. Raise it on a slow connection.",
get: |s| s.command_timeout_secs.to_string(),
set: |s, v| {
let secs: u64 = v
.parse()
.map_err(|_| anyhow::anyhow!("command_timeout_secs must be a positive integer"))?;
if secs == 0 {
bail!(
"command_timeout_secs must be at least 1 — 0 would kill every command \
the instant it starts."
);
}
s.command_timeout_secs = secs;
Ok(())
},
},
Setting {
key: "auto_setup",
category: Category::Unattended,
since: "1.0.0",
kind: Kind::Toggle,
help: "Install missing integrations by itself, once per installed version.",
plain: "Whether dev-prune finishes setting itself up on its own instead of making you run `devp setup`.",
get: |s| s.auto_setup.to_string(),
set: |s, v| {
s.auto_setup = parse_bool("auto_setup", v)?;
Ok(())
},
},
Setting {
key: "auto_config",
category: Category::Unattended,
since: "1.3.0",
kind: Kind::Toggle,
help: "Write a default .devprune.json into repositories that link/init register.",
plain: "Drops a small settings file into each repository you register, so you can give that one project different rules later.",
get: |s| s.auto_config.to_string(),
set: |s, v| {
s.auto_config = parse_bool("auto_config", v)?;
Ok(())
},
},
Setting {
key: "auto_daemon",
category: Category::Unattended,
since: "1.0.0",
kind: Kind::Toggle,
help: "Register the OS scheduler so passes run without being remembered.",
plain: "Lets your operating system run dev-prune on a schedule, so you never have to remember to.",
get: |s| s.auto_daemon.to_string(),
set: |s, v| {
s.auto_daemon = parse_bool("auto_daemon", v)?;
Ok(())
},
},
Setting {
key: "check_interval_days",
category: Category::Unattended,
since: "1.0.0",
kind: Kind::Number,
help: "Days between scheduled background passes.",
plain: "How often that scheduled cleanup runs.",
get: |s| s.check_interval_days.to_string(),
set: |s, v| {
let days: u64 = v
.parse()
.map_err(|_| anyhow::anyhow!("check_interval_days must be a positive integer"))?;
if days == 0 {
bail!("check_interval_days must be at least 1.");
}
s.check_interval_days = days;
Ok(())
},
},
Setting {
key: "auto_discover",
category: Category::Unattended,
since: "1.14.0",
kind: Kind::Toggle,
help: "Let the scheduled pass register repositories no Git hook could see — unzipped, copied or restored ones.",
plain: "Finds projects you never added by looking beside the ones you already have, so a repository you unzipped or copied from another machine still gets cleaned up.",
get: |s| s.auto_discover.to_string(),
set: |s, v| {
s.auto_discover = parse_bool("auto_discover", v)?;
Ok(())
},
},
Setting {
key: "auto_hooks",
category: Category::Unattended,
since: "1.0.0",
kind: Kind::Toggle,
help: "Install the Git hooks that register a repository when you clone, commit or merge in it.",
plain: "Adds repositories as Git creates them. Anything Git did not create — a copied or unzipped project — is left to `auto_discover`.",
get: |s| s.auto_hooks.to_string(),
set: |s, v| {
s.auto_hooks = parse_bool("auto_hooks", v)?;
Ok(())
},
},
Setting {
key: "auto_hooks_chain",
category: Category::Unattended,
since: "1.0.0",
kind: Kind::Toggle,
help: "If another tool owns core.hooksPath, install in front of it and forward. Off by default: that slot is machine-wide and already someone else's.",
plain: "Git only has one slot for this kind of automation. If something else — husky, pre-commit, lefthook — is already using it, share the slot instead of taking it over. Off by default because the slot is global to your machine and dev-prune would be taking over another tool's setup to use it. `devp doctor` names the command when it finds one of those tools holding it.",
get: |s| s.auto_hooks_chain.to_string(),
set: |s, v| {
s.auto_hooks_chain = parse_bool("auto_hooks_chain", v)?;
Ok(())
},
},
Setting {
key: "update_check",
category: Category::Updates,
since: "1.0.0",
kind: Kind::Toggle,
help: "Ask GitHub for the latest release from time to time. Sends nothing but the request.",
plain: "Whether dev-prune checks GitHub now and then to see if there is a newer version. It sends no information about you.",
get: |s| s.update_check.to_string(),
set: |s, v| {
s.update_check = parse_bool("update_check", v)?;
Ok(())
},
},
Setting {
key: "update_check_interval_days",
category: Category::Updates,
since: "1.0.0",
kind: Kind::Number,
help: "Days between automatic release checks.",
plain: "How often that version check happens.",
get: |s| s.update_check_interval_days.to_string(),
set: |s, v| {
let days: i64 = v.parse().map_err(|_| {
anyhow::anyhow!("update_check_interval_days must be a positive integer")
})?;
if days < 1 {
bail!("update_check_interval_days must be at least 1.");
}
s.update_check_interval_days = days;
Ok(())
},
},
Setting {
key: "update_check_timeout_secs",
category: Category::Updates,
since: "1.0.0",
kind: Kind::Number,
help: "Seconds the release check waits for GitHub. Raise it behind a slow proxy.",
plain: "How long the version check waits before giving up. Raise it if you are behind a slow proxy.",
get: |s| s.update_check_timeout_secs.to_string(),
set: |s, v| {
let secs: u64 = v.parse().map_err(|_| {
anyhow::anyhow!("update_check_timeout_secs must be a positive integer")
})?;
if secs == 0 {
bail!("update_check_timeout_secs must be at least 1.");
}
s.update_check_timeout_secs = secs;
Ok(())
},
},
Setting {
key: "enable_cargo",
category: Category::BuildTrees,
since: "1.5.0",
kind: Kind::Toggle,
help: "Turn on the opt-in Cargo adapter (target/ comes back by recompiling, not downloading).",
plain: "Clean Rust build folders too. These come back by recompiling, which takes minutes rather than a download — so it is off by default.",
get: |s| s.enable_cargo.to_string(),
set: |s, v| {
s.enable_cargo = parse_bool("enable_cargo", v)?;
Ok(())
},
},
Setting {
key: "enable_gradle",
category: Category::BuildTrees,
since: "1.3.0",
kind: Kind::Toggle,
help: "Turn on the opt-in Gradle adapter (build/ and .gradle/ come back by recompiling).",
plain: "Clean Android and Java build folders too. Same trade: they come back by recompiling, not downloading.",
get: |s| s.enable_gradle.to_string(),
set: |s, v| {
s.enable_gradle = parse_bool("enable_gradle", v)?;
Ok(())
},
},
Setting {
key: "enable_maven",
category: Category::BuildTrees,
since: "1.3.0",
kind: Kind::Toggle,
help: "Turn on the opt-in Maven adapter (target/ comes back by recompiling).",
plain: "Clean Maven build folders too. They come back by recompiling.",
get: |s| s.enable_maven.to_string(),
set: |s, v| {
s.enable_maven = parse_bool("enable_maven", v)?;
Ok(())
},
},
Setting {
key: "enable_swift",
category: Category::BuildTrees,
since: "1.4.0",
kind: Kind::Toggle,
help: "Turn on the opt-in SwiftPM adapter (.build/ comes back by recompiling).",
plain: "Clean Swift build folders too. They come back by recompiling.",
get: |s| s.enable_swift.to_string(),
set: |s, v| {
s.enable_swift = parse_bool("enable_swift", v)?;
Ok(())
},
},
Setting {
key: "enable_dart",
category: Category::BuildTrees,
since: "1.6.0",
kind: Kind::Toggle,
help: "Turn on the opt-in Dart/Flutter adapter (.dart_tool/ holds build caches).",
plain: "Clean Dart and Flutter caches too. Part comes back instantly, part by recompiling.",
get: |s| s.enable_dart.to_string(),
set: |s, v| {
s.enable_dart = parse_bool("enable_dart", v)?;
Ok(())
},
},
Setting {
key: "enable_mix_build",
category: Category::BuildTrees,
since: "1.7.0",
kind: Kind::Toggle,
help: "Turn on the opt-in Elixir Mix build-tree adapter (_build/ comes back by recompiling).",
plain: "Elixir projects only. Mix is Elixir's build tool, and it compiles your project and every dependency into `_build/` — this cleans that folder. The downloaded `deps/` folder beside it belongs to a different adapter that is already on. Off by default, because `_build/` comes back by recompiling rather than by downloading.",
get: |s| s.enable_mix_build.to_string(),
set: |s, v| {
s.enable_mix_build = parse_bool("enable_mix_build", v)?;
Ok(())
},
},
Setting {
key: "enable_vcpkg",
category: Category::BuildTrees,
since: "1.8.0",
kind: Kind::Toggle,
help: "Turn on the opt-in vcpkg adapter (vcpkg_installed/ comes back by recompiling).",
plain: "Clean C and C++ vcpkg_installed/ folders too. They come back by recompiling.",
get: |s| s.enable_vcpkg.to_string(),
set: |s, v| {
s.enable_vcpkg = parse_bool("enable_vcpkg", v)?;
Ok(())
},
},
Setting {
key: "enable_cmake_build",
category: Category::BuildTrees,
since: "1.8.0",
kind: Kind::Toggle,
help: "Turn on the opt-in CMake adapter (build trees proven by their CMakeCache.txt).",
plain: "Clean C and C++ build folders CMake configured. A `build/` you made by hand is \
never touched.",
get: |s| s.enable_cmake_build.to_string(),
set: |s, v| {
s.enable_cmake_build = parse_bool("enable_cmake_build", v)?;
Ok(())
},
},
Setting {
key: "enable_dotnet_build",
category: Category::BuildTrees,
since: "1.18.0",
kind: Kind::Toggle,
help: "Turn on the opt-in .NET adapter (bin/ and obj/ proven by NuGet's project.assets.json).",
plain: "Clean .NET bin/ and obj/ folders MSBuild wrote. A `bin/` holding anything of \
yours is never touched.",
get: |s| s.enable_dotnet_build.to_string(),
set: |s, v| {
s.enable_dotnet_build = parse_bool("enable_dotnet_build", v)?;
Ok(())
},
},
Setting {
key: "enable_godot",
category: Category::BuildTrees,
since: "1.22.0",
kind: Kind::Toggle,
help: "Turn on the opt-in Godot adapter (.godot/ and .import/ come back by re-importing).",
plain: "Clean Godot's imported-resource caches too. The editor rebuilds them from \
your assets the next time it opens the project.",
get: |s| s.enable_godot.to_string(),
set: |s, v| {
s.enable_godot = parse_bool("enable_godot", v)?;
Ok(())
},
},
Setting {
key: "enable_unity",
category: Category::BuildTrees,
since: "1.22.0",
kind: Kind::Toggle,
help: "Turn on the opt-in Unity adapter (Library/ and Temp/ come back by re-importing).",
plain: "Clean Unity's imported-asset database too. The editor rebuilds it from your \
assets the next time it opens the project, and an open editor is refused.",
get: |s| s.enable_unity.to_string(),
set: |s, v| {
s.enable_unity = parse_bool("enable_unity", v)?;
Ok(())
},
},
Setting {
key: "enable_unreal",
category: Category::BuildTrees,
since: "1.22.0",
kind: Kind::Toggle,
help: "Turn on the opt-in Unreal adapter (DerivedDataCache/ and Intermediate/ come back by recompiling).",
plain: "Clean Unreal's derived data and intermediates too. Saved/ and Binaries/ are \
never touched.",
get: |s| s.enable_unreal.to_string(),
set: |s, v| {
s.enable_unreal = parse_bool("enable_unreal", v)?;
Ok(())
},
},
Setting {
key: "enable_defold",
category: Category::BuildTrees,
since: "1.22.0",
kind: Kind::Toggle,
help: "Turn on the opt-in Defold adapter (build/ comes back on the next build).",
plain: "Clean Defold's build output too. The editor or bob rebuilds it in full on \
the next build.",
get: |s| s.enable_defold.to_string(),
set: |s, v| {
s.enable_defold = parse_bool("enable_defold", v)?;
Ok(())
},
},
Setting {
key: "enable_cocos",
category: Category::BuildTrees,
since: "1.22.0",
kind: Kind::Toggle,
help: "Turn on the opt-in Cocos Creator adapter (library/ and temp/ come back by re-importing).",
plain: "Clean Cocos Creator's imported-asset caches too. The editor rebuilds them \
from your assets the next time it opens the project.",
get: |s| s.enable_cocos.to_string(),
set: |s, v| {
s.enable_cocos = parse_bool("enable_cocos", v)?;
Ok(())
},
},
Setting {
key: "enable_zig",
category: Category::BuildTrees,
since: "1.22.0",
kind: Kind::Toggle,
help: "Turn on the opt-in Zig adapter (.zig-cache/, zig-cache/ and zig-out/ come back on the next build).",
plain: "Clean Zig's build caches and install output too. `zig build` writes all \
three directories back the next time it runs.",
get: |s| s.enable_zig.to_string(),
set: |s, v| {
s.enable_zig = parse_bool("enable_zig", v)?;
Ok(())
},
},
Setting {
key: "enable_stack",
category: Category::BuildTrees,
since: "1.22.0",
kind: Kind::Toggle,
help: "Turn on the opt-in Haskell Stack adapter (.stack-work/ comes back by recompiling).",
plain: "Clean Stack's build tree too. `stack build` recompiles it against the \
snapshot `stack.yaml` pins.",
get: |s| s.enable_stack.to_string(),
set: |s, v| {
s.enable_stack = parse_bool("enable_stack", v)?;
Ok(())
},
},
Setting {
key: "enable_cabal",
category: Category::BuildTrees,
since: "1.22.0",
kind: Kind::Toggle,
help: "Turn on the opt-in Haskell Cabal adapter (dist-newstyle/ comes back by recompiling).",
plain: "Clean Cabal's build tree too. `cabal build` recompiles it from the \
packages `cabal.project` declares.",
get: |s| s.enable_cabal.to_string(),
set: |s, v| {
s.enable_cabal = parse_bool("enable_cabal", v)?;
Ok(())
},
},
Setting {
key: "enable_sbt",
category: Category::BuildTrees,
since: "1.22.0",
kind: Kind::Toggle,
help: "Turn on the opt-in sbt adapter (target/ and project/target/ come back by recompiling).",
plain: "Clean sbt's build trees too. `sbt compile` regenerates both the project's \
target/ and the build definition's project/target/.",
get: |s| s.enable_sbt.to_string(),
set: |s, v| {
s.enable_sbt = parse_bool("enable_sbt", v)?;
Ok(())
},
},
Setting {
key: "build_idle_days",
category: Category::BuildTrees,
since: "1.3.0",
kind: Kind::Number,
help: "Idle days before the opt-in adapters' build trees are pruned. Applied as max(this, idle_days).",
plain: "A longer wait, used only for the build folders above, because getting those back costs a recompile rather than a download.",
get: |s| s.build_idle_days.to_string(),
set: |s, v| {
let days: u64 = v
.parse()
.map_err(|_| anyhow::anyhow!("build_idle_days must be a non-negative integer"))?;
s.build_idle_days = days;
Ok(())
},
},
Setting {
key: "auto_update",
category: Category::Updates,
since: "1.3.0",
kind: Kind::Toggle,
help: "Install a newer release by itself at the end of a prune pass. On by default.",
plain: "Whether dev-prune installs its own updates after a cleanup. The download is checked against its published fingerprint first.",
get: |s| s.auto_update.to_string(),
set: |s, v| {
s.auto_update = parse_bool("auto_update", v)?;
Ok(())
},
},
Setting {
key: "version_lock",
category: Category::Updates,
since: "1.8.0",
kind: Kind::Toggle,
help: "Pin this copy to the version it is. Overrides auto_update, `devp update \
--install`, `devp install --channel` and the install scripts.",
plain: "Stay on exactly this version. Nothing dev-prune does replaces the binary \
while this is on -- not the automatic update, not a re-run of the install \
one-liner.",
get: |s| s.version_lock.to_string(),
set: |s, v| {
s.version_lock = parse_bool("version_lock", v)?;
Ok(())
},
},
Setting {
key: "disabled_adapters",
category: Category::Scope,
since: "1.4.0",
kind: Kind::Adapters,
help: "Adapters to leave alone entirely, by name. Empty means every one of them is active.",
plain: "Ecosystems to ignore completely — as if you did not have them installed at all.",
get: |s| {
if s.disabled_adapters.is_empty() {
"(none)".to_string()
} else {
s.disabled_adapters.join(",")
}
},
set: |s, v| {
s.disabled_adapters = parse_adapter_list(v)?;
Ok(())
},
},
Setting {
key: "adapter_idle_days",
category: Category::Scope,
since: "1.5.0",
kind: Kind::AdapterDays,
help: "Per-adapter idle windows, as `cargo=60,npm=30`. Each one can only raise its own wait.",
plain: "A different waiting period for one ecosystem. Useful when your Rust projects should wait longer than your Node ones.",
get: |s| {
if s.adapter_idle_days.is_empty() {
"(none)".to_string()
} else {
s.adapter_idle_days
.iter()
.map(|(name, days)| format!("{name}={days}"))
.collect::<Vec<_>>()
.join(",")
}
},
set: |s, v| {
s.adapter_idle_days = parse_adapter_days(v)?;
Ok(())
},
},
Setting {
key: "cache_max_gb",
category: Category::Caches,
since: "1.8.0",
kind: Kind::CacheCaps,
help: "Per-manager cache size caps in GiB, as `npm=10,uv=10`. Reported by `devp caches`; cleared only by `devp caches clear --over-cap`.",
plain: "How big one ecosystem's download cache is allowed to get before dev-prune says so. It still never deletes a cache on its own.",
get: |s| {
if s.cache_max_gb.is_empty() {
"(none)".to_string()
} else {
s.cache_max_gb
.iter()
.map(|(name, gb)| format!("{name}={gb}"))
.collect::<Vec<_>>()
.join(",")
}
},
set: |s, v| {
s.cache_max_gb = parse_cache_caps(v)?;
Ok(())
},
},
];
fn parse_cache_caps(value: &str) -> Result<std::collections::BTreeMap<String, u64>> {
let trimmed = value.trim();
if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
return Ok(std::collections::BTreeMap::new());
}
let mut caps = std::collections::BTreeMap::new();
for raw in trimmed.split(',') {
let entry = raw.trim();
if entry.is_empty() {
continue;
}
let Some((name, value)) = entry.split_once('=') else {
bail!("`{entry}` must be written as `<manager>=<gib>`, for example `uv=10`.");
};
let name = name.trim().to_lowercase();
if name != crate::constants::CACHE_CAP_DEFAULT_KEY
&& !crate::commands::caches::is_cache_manager(&name)
{
bail!(
"`{name}` is not a manager dev-prune knows a cache for. Valid names: {}. \
`{}` caps every manager that is not named separately.",
crate::commands::caches::known_managers().join(", "),
crate::constants::CACHE_CAP_DEFAULT_KEY
);
}
let parsed: u64 = value.trim().parse().map_err(|_| {
anyhow::anyhow!(
"`{name}` needs a whole number of gibibytes, not `{}`.",
value.trim()
)
})?;
if parsed == 0 {
bail!(
"`{name}=0` would call the cache too big the moment it exists. Use `-` to clear the caps instead."
);
}
caps.insert(name, parsed);
}
Ok(caps)
}
fn parse_adapter_days(value: &str) -> Result<std::collections::BTreeMap<String, u64>> {
let trimmed = value.trim();
if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
return Ok(std::collections::BTreeMap::new());
}
let mut days = std::collections::BTreeMap::new();
for raw in trimmed.split(',') {
let entry = raw.trim();
if entry.is_empty() {
continue;
}
let Some((name, value)) = entry.split_once('=') else {
bail!("`{entry}` must be written as `<adapter>=<days>`, for example `cargo=60`.");
};
let name = name.trim().to_lowercase();
if !crate::adapters::is_adapter_name(&name) {
bail!(
"`{name}` is not an adapter. Valid names: {}",
crate::adapters::all_adapter_names().join(", ")
);
}
let parsed: u64 = value.trim().parse().map_err(|_| {
anyhow::anyhow!(
"`{name}` needs a whole number of days, not `{}`.",
value.trim()
)
})?;
days.insert(name, parsed);
}
Ok(days)
}
fn parse_adapter_list(value: &str) -> Result<Vec<String>> {
let trimmed = value.trim();
if trimmed.is_empty() || matches!(trimmed.to_lowercase().as_str(), "none" | "(none)" | "-") {
return Ok(Vec::new());
}
let mut names: Vec<String> = Vec::new();
for raw in trimmed.split(',') {
let name = raw.trim().to_lowercase();
if name.is_empty() {
continue;
}
if !crate::adapters::is_adapter_name(&name) {
bail!(
"`{name}` is not an adapter. Valid names: {}",
crate::adapters::all_adapter_names().join(", ")
);
}
if !names.contains(&name) {
names.push(name);
}
}
Ok(names)
}
fn parse_bool(key: &str, value: &str) -> Result<bool> {
match value.trim().to_lowercase().as_str() {
"true" | "yes" | "y" | "on" | "1" => Ok(true),
"false" | "no" | "n" | "off" | "0" => Ok(false),
_ => bail!("{key} must be true or false"),
}
}
pub fn invalid_settings(settings: &Settings) -> Vec<(&'static str, String)> {
SETTINGS
.iter()
.filter_map(|setting| {
let mut probe = settings.clone();
(setting.set)(&mut probe, &(setting.get)(settings))
.err()
.map(|e| (setting.key, e.to_string()))
})
.collect()
}
pub fn setting_count() -> usize {
SETTINGS.len()
}
fn save_settings_edits<'a>(edits: impl Iterator<Item = (&'a str, &'a str)>) -> Result<()> {
let mut fresh = Registry::load()?;
for (key, value) in edits {
(find_setting(key)?.set)(&mut fresh.settings, value)?;
}
fresh.save()
}
fn find_setting(key: &str) -> Result<&'static Setting> {
SETTINGS
.iter()
.find(|s| s.key == key)
.ok_or_else(|| anyhow::anyhow!("Unknown config key: {key}. Valid keys: {}", valid_keys()))
}
fn valid_keys() -> String {
SETTINGS
.iter()
.map(|s| s.key)
.collect::<Vec<_>>()
.join(", ")
}
#[derive(Debug, PartialEq, Eq)]
pub enum Toggle {
Enable,
Disable,
Status,
}
pub fn parse_toggle(action: &str) -> Result<Toggle> {
match action.to_lowercase().as_str() {
"enable" | "install" | "on" => Ok(Toggle::Enable),
"disable" | "uninstall" | "remove" | "off" => Ok(Toggle::Disable),
"" | "status" | "show" => Ok(Toggle::Status),
other => bail!(
"Unknown action `{other}`. Expected `enable`, `disable` or `status` \
(`install` / `uninstall` / `on` / `off` also work)."
),
}
}
pub fn is_toggle_word(word: &str) -> bool {
parse_toggle(word).is_ok() && !word.is_empty()
}
fn resolve_workspace(path: &str) -> Result<std::path::PathBuf> {
let raw = Path::new(path);
if !raw.is_dir() {
bail!(
"`{path}` is neither an action nor an existing directory.\n\
Expected `enable`, `disable` or `status`, or a path to a repository."
);
}
Ok(raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf()))
}
pub fn run_get(key: &str) -> Result<()> {
let registry = Registry::load()?;
let setting = find_setting(key)?;
println!("{key} = {}", (setting.get)(®istry.settings));
Ok(())
}
pub fn run_set(key: &str, value: &str) -> Result<()> {
let mut registry = Registry::load()?;
let setting = find_setting(key)?;
(setting.set)(&mut registry.settings, value)?;
registry.save()?;
output::print_success(&format!("{key} = {}", (setting.get)(®istry.settings)));
if key == "language"
&& let Some(meta) = i18n::language(®istry.settings.language)
&& !meta.reviewed
{
output::print_info(&format!(
"No native speaker has reviewed the {} translation yet. Corrections are welcome — see docs/TRANSLATIONS.md.",
meta.english_name
));
}
Ok(())
}
fn key_column_width() -> usize {
SETTINGS.iter().map(|s| s.key.len()).max().unwrap_or(0)
}
pub fn run_show() -> Result<()> {
let registry = Registry::load()?;
let width = key_column_width();
output::print_header("dev-prune Global Configuration");
for (category, settings) in settings_by_category() {
output::print_section(category.title());
for setting in settings {
println!(
" {:<width$} = {}",
setting.key,
(setting.get)(®istry.settings)
);
}
}
output::print_section("This machine");
println!(
" {:<width$} = {}",
"tracked_repos",
registry.repo_count()
);
let reg_path = Registry::registry_path()
.map(|p| output::clean_path(&p))
.unwrap_or_else(|_| "unknown".to_string());
println!(" {:<width$} = {reg_path}", "registry_file");
print_recommendation_summary(®istry.settings);
println!();
output::print_info("Change any of these with `devp config set <key> <value>`.");
output::print_info("Walk through them one at a time with `devp config wizard`.");
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Opened {
ByRequest,
OnItsOwn,
FirstRun,
}
fn why_this_opened(opened: Opened) -> Option<String> {
if opened == Opened::ByRequest {
return None;
}
if opened == Opened::FirstRun {
return Some(
"You did not ask for this screen. dev-prune opens it once, before it \
installs anything, so that saying yes is something you do rather than \
something that happens to you. Finish the walkthrough and the items under \
\"What finishing this walkthrough installs\" are set up, honouring \
whatever you switch off on the way; quit (q) and nothing is installed. \
Whatever you typed runs as soon as you leave."
.to_string(),
);
}
let new = settings_added_since_review().len();
Some(if reviewed_version().is_none() || new == 0 {
"You did not ask for this screen. dev-prune opens it once, on the first command \
after it is installed, so that you see what its defaults do before they start \
doing it. Whatever you typed runs as soon as you leave. It will not open by \
itself again unless an upgrade adds a setting."
.to_string()
} else {
format!(
"You did not ask for this screen. This upgrade added {new} {}, and dev-prune \
shows a new one once before its default goes on applying. Nothing else about \
your configuration changed. Whatever you typed runs as soon as you leave.",
output::plural(new, "setting", "settings"),
)
})
}
pub fn run_wizard(no_tui: bool, opened: Opened) -> Result<WizardEnd> {
if !no_tui && crate::tui::full_screen_is_usable() {
return run_wizard_tui(opened);
}
run_wizard_prompts(opened)
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum WizardEnd {
Completed,
Cancelled,
}
fn run_wizard_tui(opened: Opened) -> Result<WizardEnd> {
use crate::tui::config_view::{ConfigRow, ConfigSession, Control, Outcome};
let registry = Registry::load()?;
let new_keys = settings_added_since_review();
let fresh = Settings::default();
let rows: Vec<ConfigRow> = settings_by_category()
.into_iter()
.flat_map(|(category, settings)| {
settings.into_iter().map(move |setting| (category, setting))
})
.map(|(category, setting)| {
let value = (setting.get)(®istry.settings);
ConfigRow {
key: setting.key,
category: category.title(),
help: setting.help,
plain: setting.plain,
control: match setting.kind {
Kind::Toggle => Control::Toggle,
Kind::Choice => Control::Choice(i18n::choices()),
Kind::Number => Control::Number,
Kind::Adapters => Control::Adapters,
Kind::AdapterDays => Control::AdapterDays,
Kind::CacheCaps => Control::CacheCaps,
},
original: value.clone(),
default: (setting.get)(&fresh),
recommended: recommended_value(setting.key),
cautious: recommendation(setting.key).is_some_and(|r| r.cautious),
value,
is_new: new_keys.contains(&setting.key),
}
})
.collect();
let base = registry.settings.clone();
let validate = move |key: &str, value: &str| -> std::result::Result<(), String> {
let setting = find_setting(key).map_err(|e| e.to_string())?;
let mut probe = base.clone();
(setting.set)(&mut probe, value).map_err(|e| format!("{e}"))
};
let report = crate::commands::trust::build(®istry);
let adapters = crate::adapters::all_adapter_names();
let opt_in = crate::adapters::opt_in_adapter_names();
let capped: Vec<&'static str> = adapters
.iter()
.copied()
.filter(|name| crate::commands::caches::is_cache_manager(name))
.collect();
let why = why_this_opened(opened);
let mut declaration = declaration_lines(&report);
if opened == Opened::FirstRun {
use crate::tui::config_view::DeclarationLine;
let line = |mark: char, subject: &str, state: String| DeclarationLine {
mark,
subject: subject.to_string(),
state,
};
declaration.push(line('#', "", String::new()));
declaration.push(line(
'#',
"What finishing this walkthrough installs",
String::new(),
));
for (subject, state) in setup_preview_lines() {
declaration.push(line('!', subject, state));
}
declaration.push(line(
' ',
"Quit instead (q)",
"and none of it is installed — `devp setup` installs it later".to_string(),
));
}
let outcome = crate::tui::config_view::run(ConfigSession {
declaration,
standing: NOTHING_DELETED_YET.to_string(),
suggestions: first_run_suggestions(),
rows,
adapters: &adapters,
opt_in_adapters: &opt_in,
capped_adapters: &capped,
groups: crate::adapters::ADAPTER_GROUPS,
validate: &validate,
title: "dev-prune configuration",
uninvited: why.as_deref(),
})?;
match outcome {
Outcome::Cancelled => {
output::print_info("Cancelled — nothing was changed.");
Ok(WizardEnd::Cancelled)
}
Outcome::KeepAll => {
mark_reviewed();
output::print_success(
"Keeping the current values. `devp config set <key> <value>` changes any.",
);
Ok(WizardEnd::Completed)
}
Outcome::Save(changed) => {
save_settings_edits(changed.iter().map(|row| (row.key, row.value.as_str())))?;
mark_reviewed();
output::print_header("Saved");
let width = changed.iter().map(|r| r.key.len()).max().unwrap_or(0);
for row in &changed {
println!(
" {:<width$} = {} (was {})",
row.key, row.value, row.original
);
}
println!();
output::print_success(&format!(
"{} {} saved. `devp config show` lists every setting.",
changed.len(),
output::plural(changed.len(), "change", "changes")
));
Ok(WizardEnd::Completed)
}
}
}
fn setup_preview_lines() -> Vec<(&'static str, String)> {
vec![
(
"Command on PATH",
"a managed copy of the binary, as both `dev-prune` and `devp`".to_string(),
),
(
"AI agent skills",
"SKILL.md instructions and file icons, in dev-prune's own config directory".to_string(),
),
(
"Git hooks",
"a hook that registers repositories as you work in them (while `auto_hooks` is on)"
.to_string(),
),
(
"Background schedule",
format!(
"runs `devp run --yes` every {} days — pruning without asking (while \
`auto_daemon` is on)",
Settings::default().check_interval_days
),
),
(
"Removed again by",
"`devp uninstall`, which takes all of it back out".to_string(),
),
]
}
pub enum FirstRunDecision {
Accepted,
Declined,
NoAnswer,
}
pub fn first_run_wizard() -> Result<FirstRunDecision> {
if crate::tui::full_screen_is_usable() {
return Ok(match run_wizard_tui(Opened::FirstRun)? {
WizardEnd::Completed => FirstRunDecision::Accepted,
WizardEnd::Cancelled => FirstRunDecision::Declined,
});
}
use std::io::Write;
output::print_header("dev-prune setup");
output::print_info("dev-prune is installed but not set up. Saying yes installs:");
for (subject, state) in setup_preview_lines() {
println!(" {} {state}", output::pad_display(subject, 22));
}
println!();
print!("Set up now, starting with the settings walkthrough? [y/N] ");
std::io::stdout().flush()?;
let mut line = String::new();
if std::io::stdin().read_line(&mut line)? == 0 {
println!();
return Ok(FirstRunDecision::NoAnswer);
}
let answer = line.trim();
if answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
run_wizard_prompts(Opened::FirstRun)?;
return Ok(FirstRunDecision::Accepted);
}
Ok(FirstRunDecision::Declined)
}
fn recommended_value(key: &str) -> Option<&'static str> {
recommendation(key).map(|r| r.value)
}
fn recommendation(key: &str) -> Option<&'static Recommendation> {
RECOMMENDED.iter().find(|r| r.key == key)
}
fn already_taken(rec: &Recommendation, current: &str) -> bool {
match rec.taken {
Some(is_taken) => is_taken(current),
None => current == rec.value,
}
}
fn outstanding(settings: &Settings) -> Vec<&'static Recommendation> {
RECOMMENDED
.iter()
.filter(|r| {
find_setting(r.key)
.map(|s| (s.get)(settings))
.is_ok_and(|current| !already_taken(r, ¤t))
})
.collect()
}
fn print_recommendation_summary(settings: &Settings) {
let outstanding = outstanding(settings);
if outstanding.is_empty() {
return;
}
let width = key_column_width();
let safe: Vec<_> = outstanding.iter().filter(|r| !r.cautious).collect();
if !safe.is_empty() {
output::print_section(SAFE_TIER);
for r in &safe {
println!(" {:<width$} = {} {}", r.key, r.value, r.label);
}
println!();
output::print_info(&format!(
"`devp config recommended` sets {} {} in one command.",
safe.len(),
output::plural(safe.len(), "setting", "settings")
));
}
let cautious: Vec<_> = outstanding.iter().filter(|r| r.cautious).collect();
if !cautious.is_empty() {
output::print_section(CAUTIOUS_TIER);
for r in &cautious {
println!(" {:<width$} = {} {}", r.key, r.value, r.label);
println!(" {:<width$} {}", "", r.why);
}
println!();
output::print_info(
"Not included above. `devp config recommended --with-cautious` includes it; \
`devp config set <key> <value>` sets one on its own.",
);
}
}
pub fn run_recommended(with_cautious: bool) -> Result<()> {
let mut registry = Registry::load()?;
let width = key_column_width();
output::print_header("dev-prune recommended settings");
let mut applied: Vec<(&'static str, String, &'static str)> = Vec::new();
let mut already: Vec<&'static Recommendation> = Vec::new();
let mut held_back: Vec<&'static Recommendation> = Vec::new();
for rec in RECOMMENDED {
let setting = find_setting(rec.key)?;
let current = (setting.get)(®istry.settings);
if already_taken(rec, ¤t) {
already.push(rec);
} else if rec.cautious && !with_cautious {
held_back.push(rec);
} else {
(setting.set)(&mut registry.settings, rec.value)?;
applied.push((rec.key, current, rec.value));
}
}
if !applied.is_empty() {
registry.save()?;
output::print_section("Turned on");
for (key, from, to) in &applied {
println!(" {:<width$} {from} → {to}", key);
}
}
if !already.is_empty() {
output::print_section("Already set");
for rec in &already {
println!(" {:<width$} {}", rec.key, rec.label);
}
}
if !held_back.is_empty() {
output::print_section(CAUTIOUS_TIER);
for rec in &held_back {
println!(" {:<width$} = {} {}", rec.key, rec.value, rec.label);
println!(" {:<width$} {}", "", rec.why);
}
println!();
output::print_info(
"Left alone. `devp config recommended --with-cautious` includes it; \
`devp config set <key> <value>` sets one on its own.",
);
}
println!();
if applied.is_empty() {
output::print_success(
"Nothing changed — everything recommended without a caveat is already set.",
);
} else {
output::print_success(&format!(
"{} {} changed. `devp config show` lists them all.",
applied.len(),
output::plural(applied.len(), "setting", "settings")
));
}
Ok(())
}
fn first_run_suggestions() -> Vec<crate::tui::config_view::Suggestion> {
use crate::tui::config_view::Suggestion;
if reviewed_version().is_some() {
return Vec::new();
}
RECOMMENDED
.iter()
.filter_map(|r| {
let setting = find_setting(r.key).ok()?;
Some(Suggestion {
key: r.key,
label: r.label,
help: setting.help,
plain: setting.plain,
why: r.why,
value: r.value,
cautious: r.cautious,
})
})
.collect()
}
const NOTHING_DELETED_YET: &str =
"Nothing has been deleted, and nothing will be until a lockfile proves it comes back.";
fn provenance_rows() -> Vec<(&'static str, String)> {
let url = |u: &str| u.trim_start_matches("https://").to_string();
vec![
(
"What you are running",
format!(
"{} v{}",
crate::constants::APP_NAME,
crate::constants::VERSION
),
),
(
"Written by",
format!("{}, under Apache-2.0", crate::constants::AUTHOR),
),
("Source code", url(crate::constants::REPO_URL)),
(
"Official downloads",
format!("{} · GitHub releases", url(crate::constants::HOMEPAGE_URL)),
),
(
"Package registries",
"crates.io · PyPI · npm, all named dev-prune".to_string(),
),
(
"Editor extension",
"VS Code Marketplace · Open VSX".to_string(),
),
(
"Any other source",
"is not a copy the author published".to_string(),
),
]
}
fn declaration_lines(
report: &crate::commands::trust::TrustReport,
) -> Vec<crate::tui::config_view::DeclarationLine> {
use crate::commands::trust::{TrustRow, Verdict};
use crate::tui::config_view::DeclarationLine;
let heading = |text: &str| DeclarationLine {
mark: '#',
subject: text.to_string(),
state: String::new(),
};
let row = |r: &TrustRow| DeclarationLine {
mark: match r.verdict {
Verdict::Guaranteed | Verdict::Safe => '+',
Verdict::Widened => '!',
Verdict::Neutral => ' ',
},
subject: r.subject.to_string(),
state: r.state.clone(),
};
let mut lines = vec![heading("What this is, and where it came from")];
lines.extend(
provenance_rows()
.into_iter()
.map(|(subject, state)| DeclarationLine {
mark: ' ',
subject: subject.to_string(),
state,
}),
);
lines.push(heading(""));
lines.push(heading("Guaranteed by the code"));
lines.extend(report.guarantees.iter().map(&row));
lines.push(heading(""));
lines.push(heading("On this machine"));
lines.extend(report.machine.iter().map(&row));
lines
}
fn run_wizard_prompts(opened: Opened) -> Result<WizardEnd> {
use std::io::{self, IsTerminal, Write};
if !io::stdin().is_terminal() {
bail!(
"`devp config wizard` needs a terminal to ask questions on.\n\
Use `devp config show` to read the settings and `devp config set <key> <value>` \
to change one."
);
}
let mut registry = Registry::load()?;
let width = key_column_width();
let new_keys = settings_added_since_review();
let fresh = Settings::default();
output::print_header("dev-prune configuration");
if let Some(why) = why_this_opened(opened) {
output::print_warning(&why);
println!();
}
output::print_section("What this is, and where it came from");
for (subject, state) in provenance_rows() {
println!(" {} {state}", output::pad_display(subject, 22));
}
println!(" {}", crate::constants::LICENCE_NOTICE);
println!();
output::print_info("These are the defaults every run will use. Nothing has been changed yet.");
println!();
for (category, settings) in settings_by_category() {
output::print_section(category.title());
for setting in settings {
let badge = if new_keys.contains(&setting.key) {
" (new in this version)"
} else {
""
};
println!(
" {:<width$} = {}{badge}",
setting.key,
(setting.get)(®istry.settings)
);
println!(" {:<width$} {}", "", setting.help);
println!(" {:<width$} {}", "", setting.plain);
let mut facts = format!("default {}", (setting.get)(&fresh));
if let Some(rec) = recommendation(setting.key) {
facts.push_str(&format!(
" · recommended {} ({}, not required)",
rec.value,
if rec.cautious {
"read the note below first"
} else {
"suggested"
}
));
}
println!(" {:<width$} {facts}", "");
if let Some(rec) = recommendation(setting.key).filter(|r| r.cautious) {
println!(" {:<width$} {}", "", rec.why);
}
}
}
println!();
print_recommendation_summary(®istry.settings);
println!();
if confirmed_twice("Press Enter twice to keep all of these, or type anything to change them: ")?
{
mark_reviewed();
output::print_success("Keeping the defaults. `devp config set <key> <value>` changes any.");
return Ok(WizardEnd::Completed);
}
println!();
output::print_info("Enter a new value, or press Enter to keep the one shown.");
println!();
let mut edits: Vec<(&'static str, String, String)> = Vec::new();
let mut input_ended = false;
for setting in SETTINGS {
if input_ended {
break;
}
let current = (setting.get)(®istry.settings);
loop {
print!(" {} [{current}]: ", setting.key);
io::stdout().flush()?;
let mut line = String::new();
if io::stdin().read_line(&mut line)? == 0 {
println!();
input_ended = true;
break;
}
let typed = line.trim();
if typed.is_empty() {
break;
}
match (setting.set)(&mut registry.settings, typed) {
Ok(()) => {
let now = (setting.get)(®istry.settings);
if now != current {
edits.push((setting.key, current.clone(), now));
}
break;
}
Err(e) => output::print_error(&format!("{e}")),
}
}
}
println!();
if edits.is_empty() {
if input_ended {
output::print_info("Input ended — nothing was changed.");
return Ok(WizardEnd::Cancelled);
}
mark_reviewed();
output::print_success("Nothing changed — the defaults are in place.");
return Ok(WizardEnd::Completed);
}
output::print_section("About to be saved");
for (key, from, to) in &edits {
println!(" {:<width$} {from} → {to}", key);
}
println!();
if !confirmed_twice("Press Enter twice to save, or type anything to abandon: ")? {
output::print_info("Nothing was written.");
return Ok(WizardEnd::Cancelled);
}
save_settings_edits(edits.iter().map(|(key, _, to)| (*key, to.as_str())))?;
mark_reviewed();
let changed = edits.len();
println!();
output::print_success(&format!(
"Saved {changed} {}. `devp config show` lists them all.",
output::plural(changed, "change", "changes")
));
Ok(WizardEnd::Completed)
}
fn confirmed_twice(prompt: &str) -> Result<bool> {
use std::io::{self, Write};
for pass in 0..2 {
print!(
"{}",
if pass == 0 {
prompt
} else {
"Press Enter once more to confirm: "
}
);
io::stdout().flush()?;
let mut line = String::new();
if io::stdin().read_line(&mut line)? == 0 {
println!();
return Ok(false);
}
if !line.trim().is_empty() {
return Ok(false);
}
}
Ok(true)
}
const REVIEW_MARKER: &str = "config-reviewed";
pub fn config_review_is_due() -> bool {
let Ok(dir) = Registry::config_dir() else {
return false;
};
if !dir.join(REVIEW_MARKER).exists() {
return true;
}
!settings_added_since_review().is_empty()
}
fn reviewed_version() -> Option<String> {
let dir = Registry::config_dir().ok()?;
let recorded = std::fs::read_to_string(dir.join(REVIEW_MARKER)).ok()?;
let recorded = recorded.trim().to_string();
(!recorded.is_empty()).then_some(recorded)
}
pub fn settings_added_since_review() -> Vec<&'static str> {
let Some(reviewed) = reviewed_version() else {
return Vec::new();
};
SETTINGS
.iter()
.filter(|s| {
crate::commands::update::compare_versions(s.since, &reviewed)
== Some(std::cmp::Ordering::Greater)
})
.map(|s| s.key)
.collect()
}
fn mark_reviewed() {
if let Ok(dir) = Registry::config_dir() {
let _ = std::fs::create_dir_all(&dir);
let _ = std::fs::write(dir.join(REVIEW_MARKER), crate::constants::VERSION);
}
}
pub fn skip_config_review() {
mark_reviewed();
}
pub fn run_global_update() -> Result<()> {
output::print_header("dev-prune Global Configuration Audit & Sync");
let registry = Registry::load()?;
let mut total_audited = 0;
let mut errors_found = 0;
for repo_path in registry.repositories.keys() {
let clean = output::clean_path(repo_path);
if !repo_path.exists() {
output::print_warning(&format!(
"Skipped {clean} — the path no longer exists. `devp unlink --missing` \
clears such entries."
));
continue;
}
total_audited += 1;
match PerRepoConfig::load_personal_for_write(repo_path) {
Ok(Some(cfg)) => {
if let Err(e) = cfg.save_to_repo(repo_path) {
output::print_error(&format!("Failed to write config for {clean}: {e}"));
errors_found += 1;
} else {
output::print_success(&format!("Audited & synced config for {clean}"));
}
}
Ok(None) => {
output::print_info(&format!(
"{clean} has no .devprune.json — global defaults apply."
));
}
Err(err_msg) => {
errors_found += 1;
output::print_error(&format!("Syntax/Schema Error in {clean}:"));
for line in err_msg.lines() {
eprintln!(" {line}");
}
output::print_info(&format!(
"Hint: fix the syntax by hand, or run `devp config project {clean} --update` to \
replace the file with a valid default."
));
}
}
}
if errors_found > 0 {
anyhow::bail!(
"Audit complete: {total_audited} repos checked, {errors_found} could not be read \
or written."
);
}
output::print_success(&format!(
"Audit complete: All {total_audited} registered repositories are healthy & synced!"
));
Ok(())
}
pub fn run_path_config(path_str: &str, force_update: bool, team: bool) -> Result<()> {
let raw_path = Path::new(path_str);
let path = if raw_path.exists() {
raw_path
.canonicalize()
.unwrap_or_else(|_| raw_path.to_path_buf())
} else {
raw_path.to_path_buf()
};
let clean = output::clean_path(&path);
if !path.exists() {
bail!("Path does not exist: {clean}");
}
if !crate::scanner::is_git_repo(&path) {
bail!(
"`{clean}` is not a Git repository.\n \
Run `git init` there first, then `devp config project {clean}` again."
);
}
let mut registry = Registry::load()?;
if !registry.repositories.contains_key(&path) {
output::print_info(&format!(
"{clean} is not yet registered with dev-prune. Registering now..."
));
registry.add_repo(path.clone());
registry.save()?;
}
let name = if team {
crate::constants::PROJECT_REPO_CONFIG_FILE
} else {
crate::constants::PER_REPO_CONFIG_FILE
};
let cfg_file = path.join(name);
if cfg_file.exists() && !force_update {
output::print_header(&format!("dev-prune Per-Repo Config for {clean}"));
match crate::config::RepoConfigLayers::load(&path) {
Ok(layers) => {
let addressed = if team {
layers.project_config()
} else {
layers.personal_config()
};
println!("{}", serde_json::to_string_pretty(&addressed)?);
output::print_info(&format!("File location: {name}"));
print_layer_provenance(&layers);
}
Err(err_msg) => {
output::print_error(&format!("Invalid configuration in {clean}:"));
for line in err_msg.lines() {
eprintln!(" {line}");
}
anyhow::bail!(
"Run `devp config project {clean} --update` to reset this file back to defaults \
(your current overrides in it are discarded)."
);
}
}
} else {
output::print_info(&format!("Initializing {name} for {clean}..."));
if team {
crate::config::write_project_starter(&path)?;
} else {
PerRepoConfig::default().save_to_repo(&path)?;
}
output::print_success(&format!("Created {name} in {clean}"));
if team {
output::print_info(
"It starts empty on purpose: every key it names overrules \
`.devprune.json`, so it should only name the ones your team decides.",
);
output::print_info(
"`prunable.directories` is the exception — the two files' lists add \
up, so naming one here never discards somebody's own.",
);
output::print_info(
"Commit it. Unlike `.devprune.json`, this file is not added to \
`.git/info/exclude` — being shared is the whole reason it exists.",
);
}
}
Ok(())
}
fn print_layer_provenance(layers: &crate::config::RepoConfigLayers) {
if layers.project_config().is_none() || layers.personal_config().is_none() {
return;
}
output::print_section("Effective values");
for (key, value, source) in layers.rows() {
println!(
" {} {} {}",
output::pad_display(key, 20),
output::pad_display(&value, 14),
source.label()
);
}
}
fn load_workspace_config_for_write(repo_path: &Path) -> Result<PerRepoConfig> {
match PerRepoConfig::load_personal_for_write(repo_path) {
Ok(Some(cfg)) => Ok(cfg),
Ok(None) => Ok(PerRepoConfig::default()),
Err(e) => bail!(
"{e}\n \
Fix that file, or run `devp config project {} --update` to reset it back to defaults \
(your current overrides in it are discarded).",
output::clean_path(repo_path)
),
}
}
pub fn run_daemon_toggle(path: Option<&str>, action: &str) -> Result<()> {
if let Some(p) = path {
let repo_path = resolve_workspace(p)?;
let mut cfg = load_workspace_config_for_write(&repo_path)?;
match parse_toggle(action)? {
Toggle::Enable => {
cfg.disable_daemon = false;
cfg.save_to_repo(&repo_path)?;
output::print_success(&format!(
"Enabled background daemon for workspace: {}",
output::clean_path(&repo_path)
));
}
Toggle::Disable => {
cfg.disable_daemon = true;
cfg.save_to_repo(&repo_path)?;
output::print_success(&format!(
"Disabled background daemon for workspace: {}",
output::clean_path(&repo_path)
));
}
Toggle::Status => {
let st = if cfg.disable_daemon {
"Disabled for workspace"
} else {
"Enabled for workspace"
};
output::print_info(&format!(
"Daemon Status ({}): {}",
output::clean_path(&repo_path),
st
));
}
}
} else {
match parse_toggle(action)? {
Toggle::Enable => crate::commands::daemon::run_install()?,
Toggle::Disable => crate::commands::daemon::run_uninstall()?,
Toggle::Status => crate::commands::daemon::run_status()?,
}
}
Ok(())
}
pub fn run_hook_toggle(path: Option<&str>, action: &str, chain: bool) -> Result<()> {
if let Some(p) = path {
if chain {
bail!(
"`--chain` changes the single global `core.hooksPath`, so it has no \
per-workspace form. Drop the path: `devp hook install --chain`."
);
}
let repo_path = resolve_workspace(p)?;
let mut cfg = load_workspace_config_for_write(&repo_path)?;
match parse_toggle(action)? {
Toggle::Enable => {
cfg.disable_hooks = false;
cfg.save_to_repo(&repo_path)?;
output::print_success(&format!(
"Enabled background Git hooks for workspace: {}",
output::clean_path(&repo_path)
));
}
Toggle::Disable => {
cfg.disable_hooks = true;
cfg.save_to_repo(&repo_path)?;
output::print_success(&format!(
"Disabled background Git hooks for workspace: {}",
output::clean_path(&repo_path)
));
}
Toggle::Status => {
let st = if cfg.disable_hooks {
"Disabled for workspace"
} else {
"Enabled for workspace"
};
output::print_info(&format!(
"Git Hook Status ({}): {}",
output::clean_path(&repo_path),
st
));
}
}
} else {
match parse_toggle(action)? {
Toggle::Enable => crate::commands::hook::run_install(chain)?,
Toggle::Disable => crate::commands::hook::run_uninstall()?,
Toggle::Status => crate::commands::hook::run_status()?,
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enable_synonyms_all_resolve_to_enable() {
for word in ["enable", "install", "on", "INSTALL", "On"] {
assert_eq!(parse_toggle(word).unwrap(), Toggle::Enable, "{word}");
}
}
#[test]
fn disable_synonyms_all_resolve_to_disable() {
for word in ["disable", "uninstall", "remove", "off", "Uninstall"] {
assert_eq!(parse_toggle(word).unwrap(), Toggle::Disable, "{word}");
}
}
#[test]
fn status_is_the_default_and_is_also_spellable() {
for word in ["", "status", "show"] {
assert_eq!(parse_toggle(word).unwrap(), Toggle::Status, "{word}");
}
}
#[test]
fn a_typo_is_an_error_rather_than_a_silent_status_report() {
let err = parse_toggle("enabel").unwrap_err().to_string();
assert!(err.contains("enabel"), "{err}");
assert!(err.contains("enable"), "{err}");
}
#[test]
fn a_workspace_toggle_refuses_to_write_over_a_broken_config() {
let tmp = tempfile::TempDir::new().unwrap();
let broken = r#"{ "project_name": "api", "override_idle_days": 90, }"#;
std::fs::write(
tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE),
broken,
)
.unwrap();
let err = load_workspace_config_for_write(tmp.path())
.unwrap_err()
.to_string();
assert!(err.contains("Syntax error"), "{err}");
assert!(err.contains("--update"), "{err}");
let on_disk =
std::fs::read_to_string(tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE))
.unwrap();
assert_eq!(on_disk, broken);
}
#[test]
fn the_suggested_cap_is_the_constant_it_claims_to_be() {
let caps = parse_cache_caps(crate::constants::RECOMMENDED_CACHE_CAP).unwrap();
assert_eq!(
caps,
std::collections::BTreeMap::from([(
crate::constants::CACHE_CAP_DEFAULT_KEY.to_string(),
crate::constants::RECOMMENDED_CACHE_MAX_GB,
)])
);
}
#[test]
fn a_ceiling_of_your_own_counts_as_having_taken_the_advice() {
let rec = recommendation("cache_max_gb").expect("the cap is recommended");
assert!(already_taken(rec, "npm=4"));
assert!(already_taken(rec, crate::constants::RECOMMENDED_CACHE_CAP));
assert!(!already_taken(rec, "(none)"));
let toggle = recommendation("enable_cargo").expect("cargo is recommended");
assert!(already_taken(toggle, "true"));
assert!(!already_taken(toggle, "false"));
}
#[test]
fn a_workspace_with_no_config_yet_starts_from_the_defaults() {
let tmp = tempfile::TempDir::new().unwrap();
assert_eq!(
load_workspace_config_for_write(tmp.path()).unwrap(),
PerRepoConfig::default()
);
}
#[test]
fn a_cache_cap_is_written_the_way_it_is_read_back() {
let caps = parse_cache_caps("uv=10,npm=4").unwrap();
assert_eq!(caps.get("uv"), Some(&10));
assert_eq!(caps.get("npm"), Some(&4));
let settings = Settings {
cache_max_gb: parse_cache_caps("UV = 10 , npm=4").unwrap(),
..Settings::default()
};
let printed = SETTINGS
.iter()
.find(|s| s.key == "cache_max_gb")
.map(|s| (s.get)(&settings))
.unwrap();
assert_eq!(printed, "npm=4,uv=10");
assert_eq!(parse_cache_caps(&printed).unwrap(), settings.cache_max_gb);
}
#[test]
fn clearing_the_caps_is_spelled_the_way_the_getter_prints_an_empty_map() {
for blank in ["", "-", "none", "(none)", "NONE"] {
assert!(
parse_cache_caps(blank).unwrap().is_empty(),
"`{blank}` should clear every cap"
);
}
}
#[test]
fn a_cap_on_something_that_is_not_a_cache_is_refused_with_the_list() {
let err = parse_cache_caps("venv=10").unwrap_err().to_string();
assert!(err.contains("venv"), "{err}");
assert!(err.contains("npm"), "the error lists what is valid: {err}");
}
#[test]
fn a_cap_has_to_be_a_whole_number_of_gibibytes() {
for bad in ["uv=10.5", "uv=ten", "uv=-1", "uv="] {
assert!(parse_cache_caps(bad).is_err(), "`{bad}` was accepted");
}
assert!(parse_cache_caps("uv").is_err());
}
#[test]
fn a_cap_of_zero_is_refused_rather_than_stored() {
let err = parse_cache_caps("uv=0").unwrap_err().to_string();
assert!(
err.contains("`-`"),
"the error names the way to clear it: {err}"
);
}
#[test]
fn every_setting_round_trips_through_its_own_getter() {
let mut settings = Settings::default();
for setting in SETTINGS {
let before = (setting.get)(&settings);
let probe = match setting.kind {
Kind::Toggle => if before == "true" { "false" } else { "true" }.to_string(),
Kind::Number => "7".to_string(),
Kind::Adapters => "cargo".to_string(),
Kind::AdapterDays => "cargo=45".to_string(),
Kind::CacheCaps => "cargo=10".to_string(),
Kind::Choice => "hi".to_string(),
};
(setting.set)(&mut settings, &probe)
.unwrap_or_else(|e| panic!("{} rejected `{probe}`: {e}", setting.key));
assert_eq!(
(setting.get)(&settings),
probe,
"{} reads back a different field than it writes",
setting.key
);
}
}
#[test]
fn every_setting_is_documented_and_uniquely_named() {
let mut seen = std::collections::HashSet::new();
for setting in SETTINGS {
assert!(seen.insert(setting.key), "duplicate key {}", setting.key);
assert!(!setting.help.is_empty(), "{} has no help", setting.key);
assert!(
!setting.plain.is_empty(),
"{} has no plain text",
setting.key
);
assert!(
setting.help.ends_with('.'),
"{} help should read as a sentence",
setting.key
);
assert!(
setting.plain.ends_with('.'),
"{} plain text should read as a sentence",
setting.key
);
assert_ne!(
setting.plain, setting.help,
"{} says the same thing twice",
setting.key
);
}
}
#[test]
fn the_settings_table_covers_every_field_of_settings() {
let json = serde_json::to_value(Settings::default()).unwrap();
let fields: Vec<String> = json.as_object().unwrap().keys().cloned().collect();
for field in fields {
assert!(
SETTINGS.iter().any(|s| s.key == field),
"`{field}` is a setting with no entry in SETTINGS, so `devp config set \
{field}` cannot reach it"
);
}
}
#[test]
fn a_rejected_value_leaves_the_previous_one_in_place() {
let mut settings = Settings::default();
assert!((find_setting("scan_depth").unwrap().set)(&mut settings, "0").is_err());
assert_eq!(settings.scan_depth, Settings::default().scan_depth);
assert!((find_setting("command_timeout_secs").unwrap().set)(&mut settings, "0").is_err());
assert!((find_setting("check_interval_days").unwrap().set)(&mut settings, "0").is_err());
assert!(
(find_setting("update_check_interval_days").unwrap().set)(&mut settings, "0").is_err()
);
}
#[test]
fn booleans_accept_the_words_people_actually_type() {
assert!(parse_bool("k", "yes").unwrap());
assert!(parse_bool("k", "ON").unwrap());
assert!(!parse_bool("k", "0").unwrap());
assert!(parse_bool("k", "maybe").is_err());
}
#[test]
fn an_unknown_key_lists_the_ones_that_exist() {
let err = match find_setting("idel_days") {
Ok(_) => panic!("`idel_days` is not a setting"),
Err(e) => e.to_string(),
};
assert!(err.contains("idle_days"), "{err}");
}
#[test]
fn a_path_is_never_mistaken_for_an_action() {
assert!(!is_toggle_word("~/Code/my-repo"));
assert!(!is_toggle_word("."));
assert!(!is_toggle_word(""));
assert!(is_toggle_word("install"));
}
}