use std::fs;
use std::path::PathBuf;
use anyhow::Result;
use crate::commands::hook::{self, HookState};
use crate::commands::skill::EMBEDDED_SKILL_MD;
use crate::config::Registry;
use crate::constants;
use crate::daemon;
use crate::output;
const STAMP_FILE: &str = "setup-stamp";
pub use crate::constants::ENV_NO_AUTO_SETUP;
pub fn no_auto_setup_requested() -> bool {
std::env::var_os(ENV_NO_AUTO_SETUP).is_some()
}
pub fn offline_requested() -> bool {
std::env::var_os(crate::constants::ENV_OFFLINE).is_some()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Installed,
AlreadyPresent,
Skipped(String),
Failed(String),
}
#[derive(Debug, Default)]
pub struct SetupReport {
items: Vec<(&'static str, Outcome)>,
}
impl SetupReport {
fn push(&mut self, name: &'static str, outcome: Outcome) {
self.items.push((name, outcome));
}
pub fn changed_anything(&self) -> bool {
self.items
.iter()
.any(|(_, o)| matches!(o, Outcome::Installed))
}
pub fn needs_attention(&self) -> bool {
self.items
.iter()
.any(|(_, o)| matches!(o, Outcome::Skipped(_) | Outcome::Failed(_)))
}
pub fn print(&self, verbose: bool) {
for (name, outcome) in &self.items {
match outcome {
Outcome::Installed => output::print_success(&format!("{name}: installed.")),
Outcome::AlreadyPresent if verbose => {
output::print_info(&format!("{name}: already installed."));
}
Outcome::AlreadyPresent => {}
Outcome::Skipped(why) => {
output::print_warning(&format!("{name}: skipped — {why}"));
}
Outcome::Failed(why) => {
output::print_error(&format!("{name}: failed — {why}"));
}
}
}
}
}
pub fn managed_bin_dir() -> Result<PathBuf> {
Ok(Registry::config_dir()?.join("bin"))
}
pub(crate) fn managed_exe_path() -> Result<PathBuf> {
let name = if cfg!(windows) {
"dev-prune.exe"
} else {
"dev-prune"
};
Ok(managed_bin_dir()?.join(name))
}
pub fn stable_exe_path() -> PathBuf {
let current = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("dev-prune"));
let Ok(managed) = managed_exe_path() else {
return current;
};
if managed == current {
return managed;
}
if managed.is_file() {
refresh_managed_copy_if_stale(¤t, &managed);
return managed;
}
if !is_this_cli(¤t) {
return current;
}
let Some(parent) = managed.parent() else {
return current;
};
if fs::create_dir_all(parent).is_err() {
return current;
}
if fs::hard_link(¤t, &managed).is_ok() {
return managed;
}
if managed.is_file() {
return managed;
}
let staging = managed.with_extension("new");
if fs::copy(¤t, &staging).is_ok() && fs::rename(&staging, &managed).is_ok() {
return managed;
}
let _ = fs::remove_file(&staging);
if managed.is_file() { managed } else { current }
}
fn is_this_cli(path: &std::path::Path) -> bool {
path.file_stem()
.and_then(|s| s.to_str())
.is_some_and(|stem| stem == "dev-prune" || stem == "devp")
}
fn refresh_managed_copy_if_stale(current: &std::path::Path, managed: &std::path::Path) {
if !is_this_cli(current) || same_contents(managed, current) {
return;
}
match (binary_version(managed), parse_version(constants::VERSION)) {
(Some(theirs), Some(ours)) if theirs >= ours => return,
_ => {}
}
let staging = managed.with_extension("new");
if fs::copy(current, &staging).is_ok() && fs::rename(&staging, managed).is_err() {
let _ = fs::remove_file(&staging);
}
}
pub(crate) fn binary_version(exe: &std::path::Path) -> Option<(u64, u64, u64)> {
let output = crate::spawn::command(exe).arg("--version").output().ok()?;
if !output.status.success() {
return None;
}
version_in_output(&String::from_utf8_lossy(&output.stdout))
}
fn version_in_output(text: &str) -> Option<(u64, u64, u64)> {
text.split_whitespace()
.find_map(|token| parse_version(token.strip_prefix('v').unwrap_or(token)))
}
pub(crate) fn parse_version(text: &str) -> Option<(u64, u64, u64)> {
let mut parts = text.split('.');
let triple = (
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
);
parts.next().is_none().then_some(triple)
}
pub fn ensure_alias() -> Outcome {
if crate::channel::Channel::detect().replaces_its_directory() {
return Outcome::AlreadyPresent;
}
let Ok(current_exe) = std::env::current_exe() else {
return Outcome::Failed("could not locate the running executable".to_string());
};
let Some(parent_dir) = current_exe.parent() else {
return Outcome::Failed("the running executable has no parent directory".to_string());
};
ensure_twin_of(¤t_exe, parent_dir)
}
fn ensure_twin_of(current_exe: &std::path::Path, parent_dir: &std::path::Path) -> Outcome {
let running_as_alias = current_exe
.file_stem()
.and_then(|s| s.to_str())
.is_some_and(|stem| stem == "devp");
let (twin_name, may_refresh) = if running_as_alias {
(
if cfg!(windows) {
"dev-prune.exe"
} else {
"dev-prune"
},
false,
)
} else {
(if cfg!(windows) { "devp.exe" } else { "devp" }, true)
};
let twin_exe = parent_dir.join(twin_name);
if twin_exe.exists() {
if !may_refresh || same_contents(&twin_exe, current_exe) {
return Outcome::AlreadyPresent;
}
if fs::remove_file(&twin_exe).is_err() {
return Outcome::Skipped(format!(
"`{twin_name}` is in use and could not be refreshed — re-run `devp setup` \
from a terminal that is not running it"
));
}
}
if fs::hard_link(current_exe, &twin_exe).is_ok() {
return Outcome::Installed;
}
if twin_exe.exists() {
return Outcome::AlreadyPresent;
}
let staging = twin_exe.with_extension("new");
if fs::copy(current_exe, &staging).is_ok() && fs::rename(&staging, &twin_exe).is_ok() {
return Outcome::Installed;
}
let _ = fs::remove_file(&staging);
if twin_exe.exists() {
return Outcome::AlreadyPresent;
}
Outcome::Failed(format!(
"could not create `{}`",
output::clean_path(&twin_exe)
))
}
fn same_contents(a: &std::path::Path, b: &std::path::Path) -> bool {
let (Ok(ma), Ok(mb)) = (fs::metadata(a), fs::metadata(b)) else {
return false;
};
if ma.len() != mb.len() {
return false;
}
if ma.modified().ok() == mb.modified().ok() {
return true;
}
match (fs::read(a), fs::read(b)) {
(Ok(ca), Ok(cb)) => ca == cb,
_ => false,
}
}
pub fn skill_path() -> Result<PathBuf> {
Ok(Registry::config_dir()?.join("SKILL.md"))
}
pub fn ensure_skill_file() -> Outcome {
match Registry::config_dir() {
Ok(dir) => ensure_skill_file_in(&dir),
Err(_) => Outcome::Failed("could not determine the config directory".to_string()),
}
}
fn ensure_skill_file_in(config_dir: &std::path::Path) -> Outcome {
let target = config_dir.join("SKILL.md");
if fs::read_to_string(&target).is_ok_and(|current| current == EMBEDDED_SKILL_MD) {
return Outcome::AlreadyPresent;
}
let _ = fs::create_dir_all(config_dir);
match fs::write(&target, EMBEDDED_SKILL_MD) {
Ok(()) => Outcome::Installed,
Err(e) => Outcome::Failed(format!(
"could not write {}: {e}",
output::clean_path(&target)
)),
}
}
fn agent_skill_roots_under(home: &std::path::Path) -> Vec<PathBuf> {
let mut roots = Vec::new();
let claude = home.join(constants::CLAUDE_HOME_DIR);
if claude.is_dir() {
roots.push(
claude
.join(constants::AGENT_SKILLS_SUBDIR)
.join(constants::APP_NAME),
);
}
roots
}
pub fn agent_skill_roots() -> Vec<PathBuf> {
dirs::home_dir()
.map(|home| agent_skill_roots_under(&home))
.unwrap_or_default()
}
pub fn ensure_agent_skills() -> Outcome {
ensure_agent_skills_at(&agent_skill_roots())
}
fn ensure_agent_skills_at(roots: &[PathBuf]) -> Outcome {
if roots.is_empty() {
return Outcome::Skipped(
"no AI agent skills directory was found — `devp skill` prints import prompts instead"
.to_string(),
);
}
let mut installed = false;
for root in roots {
match ensure_skill_file_in(root) {
Outcome::Installed => installed = true,
Outcome::AlreadyPresent => {}
other => return other,
}
}
if installed {
Outcome::Installed
} else {
Outcome::AlreadyPresent
}
}
pub fn ensure_command_on_path() -> Outcome {
let managed = stable_exe_path();
let is_managed_copy =
managed_exe_path().is_ok_and(|expected| expected == managed) && managed.is_file();
if !is_managed_copy {
return Outcome::Skipped("no managed copy of the binary exists to put on PATH".to_string());
}
let Some(bin_dir) = managed.parent() else {
return Outcome::Failed("the managed binary has no parent directory".to_string());
};
if let Outcome::Failed(why) = ensure_twin_of(&managed, bin_dir) {
return Outcome::Failed(why);
}
crate::pathenv::ensure_reachable(bin_dir)
}
fn ensure_icons() -> Outcome {
if crate::commands::icon::is_registered() {
return Outcome::AlreadyPresent;
}
match crate::commands::icon::sync_app_directory() {
Ok(()) => Outcome::Installed,
Err(e) => Outcome::Failed(format!("{e:#}")),
}
}
pub fn ensure_hooks(chain: bool) -> Outcome {
if !hook::git_available() {
return Outcome::Skipped(format!(
"\n {}",
hook::GIT_MISSING_HELP.replace('\n', "\n ")
));
}
match hook::state() {
Ok(HookState::Active) if hook_target_is_dead() || hook::shims_incomplete() => {
match hook::install() {
Ok(()) => Outcome::Installed,
Err(e) => Outcome::Failed(format!("{e:#}")),
}
}
Ok(HookState::Active) => Outcome::AlreadyPresent,
Ok(HookState::Chained { drifted, .. }) if !drifted.is_empty() => {
match hook::install_with(true) {
Ok(()) => Outcome::Installed,
Err(e) => Outcome::Failed(format!("{e:#}")),
}
}
Ok(HookState::Chained { .. }) if hook_target_is_dead() => match hook::install_with(true) {
Ok(()) => Outcome::Installed,
Err(e) => Outcome::Failed(format!("{e:#}")),
},
Ok(HookState::Chained { .. }) => Outcome::AlreadyPresent,
Ok(HookState::Foreign(_)) if chain => match hook::install_with(true) {
Ok(()) => Outcome::Installed,
Err(e) => Outcome::Failed(format!("{e:#}")),
},
Ok(HookState::Foreign(existing)) => Outcome::Skipped(format!(
"`core.hooksPath` is already set to `{existing}`, which belongs to another tool.\n \
Git allows only one hooks directory, so dev-prune will not take the slot.\n \
`devp hook install --chain` installs in front of it instead — dev-prune registers \
the repo, then hands every hook on to `{existing}`, and `devp hook uninstall` puts \
the original setting back (`devp config set auto_hooks_chain true` makes that \
the standing answer). Or skip it: `devp link .` does the same job by hand."
)),
Ok(HookState::Absent) => match hook::install() {
Ok(()) => Outcome::Installed,
Err(e) => Outcome::Failed(format!("{e:#}")),
},
Err(e) => Outcome::Failed(format!("{e:#}")),
}
}
fn hook_target_is_dead() -> bool {
hook::registered_exe_path().is_some_and(|exe| !exe.exists())
}
pub fn ensure_daemon(interval_days: u64) -> Outcome {
match daemon::daemon_status() {
Ok(daemon::DaemonStatus::Installed)
if daemon::registered_exe_path().is_some_and(|exe| !exe.exists()) =>
{
match daemon::install_daemon(interval_days) {
Ok(()) => Outcome::Installed,
Err(e) => Outcome::Failed(format!("{e:#}")),
}
}
Ok(daemon::DaemonStatus::Installed) if daemon::wants_hidden_upgrade() => {
match daemon::install_daemon(interval_days) {
Ok(()) => Outcome::Installed,
Err(e) => Outcome::Failed(format!("{e:#}")),
}
}
Ok(daemon::DaemonStatus::Installed) => {
daemon::refresh_hidden_twin();
Outcome::AlreadyPresent
}
Ok(daemon::DaemonStatus::NotInstalled) => match daemon::install_daemon(interval_days) {
Ok(()) => Outcome::Installed,
Err(e) => Outcome::Failed(format!("{e:#}")),
},
Ok(daemon::DaemonStatus::Unknown(why)) => {
Outcome::Skipped(format!("scheduler state could not be read — {why}"))
}
Err(e) => Outcome::Failed(format!("{e:#}")),
}
}
pub fn auto_setup_enabled(registry: &Registry) -> bool {
!no_auto_setup_requested() && registry.settings.auto_setup && unattended_environment().is_none()
}
pub fn unattended_environment() -> Option<&'static str> {
for var in [
"CI",
"CONTINUOUS_INTEGRATION",
"BUILD_NUMBER",
"GITHUB_ACTIONS",
] {
if let Some(value) = std::env::var_os(var) {
let value = value.to_string_lossy();
if !value.is_empty() && !value.eq_ignore_ascii_case("false") {
return Some("this looks like a CI runner");
}
}
}
#[cfg(unix)]
if std::path::Path::new("/.dockerenv").exists() {
return Some("this looks like a container");
}
if std::env::var_os("container").is_some() {
return Some("this looks like a container");
}
None
}
pub fn ensure_integrations_if_enabled(registry: &Registry) -> Option<SetupReport> {
auto_setup_enabled(registry).then(|| ensure_integrations(registry))
}
pub fn ensure_integrations(registry: &Registry) -> SetupReport {
let mut report = SetupReport::default();
report.push("dev-prune/devp pair", ensure_alias());
report.push("Command on PATH", ensure_command_on_path());
report.push("SKILL.md", ensure_skill_file());
if !agent_skill_roots().is_empty() {
report.push("AI agent skills", ensure_agent_skills());
}
report.push("File icons", ensure_icons());
if registry.settings.auto_hooks {
report.push(
"Git hooks",
ensure_hooks(registry.settings.auto_hooks_chain),
);
} else {
report.push(
"Git hooks",
Outcome::Skipped("`auto_hooks` is false — enable with `devp hook install`".to_string()),
);
}
if registry.settings.auto_daemon {
report.push(
"Background scheduler",
ensure_daemon(registry.settings.check_interval_days),
);
} else {
report.push(
"Background scheduler",
Outcome::Skipped(
"`auto_daemon` is false — enable with `devp daemon install`".to_string(),
),
);
}
report
}
const VSCODE_OFFER_STAMP: &str = "vscode-ext-offered";
struct EditorCli {
cli: String,
label: &'static str,
}
fn detect_vscode_editors() -> Vec<EditorCli> {
const CANDIDATES: &[(&str, &str)] = &[
("code", "VS Code"),
("code-insiders", "VS Code Insiders"),
("codium", "VSCodium"),
("codium-insiders", "VSCodium Insiders"),
("cursor", "Cursor"),
("windsurf", "Windsurf"),
("positron", "Positron"),
("kiro", "Kiro"),
];
CANDIDATES
.iter()
.filter_map(|(name, label)| {
let cli = if cfg!(windows) {
format!("{name}.cmd")
} else {
(*name).to_string()
};
let responds = crate::spawn::command(&cli)
.arg("--version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
responds.then_some(EditorCli { cli, label })
})
.collect()
}
fn vscode_extension_installed(cli: &str) -> bool {
crate::spawn::command(cli)
.arg("--list-extensions")
.stdin(std::process::Stdio::null())
.output()
.map(|out| {
String::from_utf8_lossy(&out.stdout).lines().any(|line| {
line.trim()
.eq_ignore_ascii_case(constants::VSCODE_EXTENSION_ID)
})
})
.unwrap_or(false)
}
fn download_release_vsix() -> Option<std::path::PathBuf> {
use std::time::Duration;
if offline_requested() {
return None;
}
let fetch = |url: &str| {
ureq::get(url)
.header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
.header("Accept", "application/vnd.github+json")
.config()
.timeout_global(Some(Duration::from_secs(30)))
.build()
.call()
};
let body = fetch(constants::LATEST_RELEASE_API_URL)
.ok()?
.body_mut()
.read_to_string()
.ok()?;
let json: serde_json::Value = serde_json::from_str(&body).ok()?;
let asset = json.get("assets")?.as_array()?.iter().find_map(|asset| {
let name = asset.get("name")?.as_str()?;
if !name.ends_with(".vsix") {
return None;
}
let url = asset.get("browser_download_url")?.as_str()?;
Some((name.to_string(), url.to_string()))
})?;
let bytes = fetch(&asset.1).ok()?.body_mut().read_to_vec().ok()?;
let dir = Registry::config_dir().ok()?;
fs::create_dir_all(&dir).ok()?;
let path = dir.join(&asset.0);
fs::write(&path, bytes).ok()?;
Some(path)
}
fn run_install(cli: &str, arg: &str) -> bool {
crate::spawn::command(cli)
.args(["--install-extension", arg])
.stdin(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub fn offer_vscode_extension() {
use std::io::{IsTerminal, Write};
let Ok(config_dir) = Registry::config_dir() else {
return;
};
if config_dir.join(VSCODE_OFFER_STAMP).exists() {
return;
}
if no_auto_setup_requested()
|| unattended_environment().is_some()
|| !std::io::stdin().is_terminal()
|| !std::io::stdout().is_terminal()
{
return;
}
let editors = detect_vscode_editors();
if editors.is_empty() {
return;
}
let write_marker = || {
let _ = fs::create_dir_all(&config_dir);
let _ = fs::write(config_dir.join(VSCODE_OFFER_STAMP), "");
};
let missing: Vec<&EditorCli> = editors
.iter()
.filter(|e| !vscode_extension_installed(&e.cli))
.collect();
if missing.is_empty() {
write_marker();
return;
}
let names = missing
.iter()
.map(|e| e.label)
.collect::<Vec<_>>()
.join(", ");
println!();
println!("{names} detected — install the dev-prune extension?");
println!(" It validates .devprune.json and shows reclaimable space in the status bar.");
println!(" Marketplace: {}", constants::VSCODE_MARKETPLACE_URL);
println!(" Open VSX: {}", constants::OPENVSX_URL);
println!(" Source: {}", constants::REPO_URL);
print!(" Install it? [Y/n] ");
let _ = std::io::stdout().flush();
let mut answer = String::new();
if std::io::stdin().read_line(&mut answer).is_err() {
return;
}
write_marker();
if !matches!(answer.trim().to_lowercase().as_str(), "" | "y" | "yes") {
output::print_info(&format!(
"Skipped. Install it any time with `{} --install-extension {}`, or from {}.",
missing[0].cli,
constants::VSCODE_EXTENSION_ID,
constants::VSCODE_MARKETPLACE_URL
));
return;
}
let mut release_vsix: Option<Option<std::path::PathBuf>> = None;
for editor in &missing {
if run_install(&editor.cli, constants::VSCODE_EXTENSION_ID) {
output::print_success(&format!("{}: extension installed.", editor.label));
continue;
}
let vsix = release_vsix.get_or_insert_with(download_release_vsix);
match vsix {
Some(path) if run_install(&editor.cli, &path.to_string_lossy()) => {
output::print_success(&format!(
"{}: extension installed from the GitHub release .vsix.",
editor.label
));
}
_ => {
output::print_warning(&format!(
"{}: could not install it from here. Search the Extensions view for \"dev-prune\", or run `{} --install-extension {}` yourself.",
editor.label,
editor.cli,
constants::VSCODE_EXTENSION_ID
));
}
}
}
if let Some(Some(path)) = &release_vsix {
let _ = fs::remove_file(path);
}
}
fn write_stamp_in(config_dir: &std::path::Path) {
let _ = fs::create_dir_all(config_dir);
let _ = fs::write(config_dir.join(STAMP_FILE), constants::VERSION);
}
fn write_stamp() {
if let Ok(dir) = Registry::config_dir() {
write_stamp_in(&dir);
}
}
fn setup_is_due_in(config_dir: &std::path::Path) -> bool {
!fs::read_to_string(config_dir.join(STAMP_FILE))
.is_ok_and(|stamp| stamp.trim() == constants::VERSION)
}
pub fn setup_is_due() -> bool {
Registry::config_dir()
.map(|dir| setup_is_due_in(&dir))
.unwrap_or(false)
}
fn a_person_is_present() -> bool {
use std::io::IsTerminal;
unattended_environment().is_none()
&& std::io::stdin().is_terminal()
&& std::io::stdout().is_terminal()
}
pub fn auto_setup_if_due() {
if !setup_is_due() {
first_run_config_review();
return;
}
if !a_person_is_present() {
return;
}
let Ok(registry) = Registry::load() else {
return;
};
let Some(report) = ensure_integrations_if_enabled(®istry) else {
write_stamp();
crate::commands::config::skip_config_review();
return;
};
if report.changed_anything() || report.needs_attention() {
output::print_header("dev-prune setup");
report.print(false);
if report.changed_anything() {
output::print_info(
"Run `devp setup --status` to review these, or `devp uninstall` to remove them.",
);
}
println!();
}
write_stamp();
first_run_config_review();
}
fn first_run_config_review() {
if !crate::commands::config::config_review_is_due() {
return;
}
if !a_person_is_present() {
crate::commands::config::skip_config_review();
return;
}
if let Err(e) = crate::commands::config::run_wizard(false) {
output::print_warning(&format!("Could not run the first-run setup ({e:#})."));
}
crate::commands::config::skip_config_review();
offer_vscode_extension();
println!();
}
pub fn suppress_next_auto_setup() {
write_stamp();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_report_with_only_present_items_is_silent() {
let mut report = SetupReport::default();
report.push("a", Outcome::AlreadyPresent);
assert!(!report.changed_anything());
assert!(!report.needs_attention());
}
#[test]
fn skipped_and_failed_both_ask_for_attention() {
let mut skipped = SetupReport::default();
skipped.push("a", Outcome::Skipped("no git".into()));
assert!(skipped.needs_attention());
assert!(!skipped.changed_anything());
let mut failed = SetupReport::default();
failed.push("a", Outcome::Failed("boom".into()));
assert!(failed.needs_attention());
}
#[test]
fn an_install_counts_as_a_change() {
let mut report = SetupReport::default();
report.push("a", Outcome::Installed);
assert!(report.changed_anything());
}
#[test]
fn the_skill_export_lands_in_the_config_directory() {
let dir = tempfile::TempDir::new().unwrap();
assert_eq!(ensure_skill_file_in(dir.path()), Outcome::Installed);
assert_eq!(ensure_skill_file_in(dir.path()), Outcome::AlreadyPresent);
let written = fs::read_to_string(dir.path().join("SKILL.md")).unwrap();
assert_eq!(written, EMBEDDED_SKILL_MD);
}
#[test]
fn a_stale_skill_export_is_rewritten() {
let dir = tempfile::TempDir::new().unwrap();
fs::write(dir.path().join("SKILL.md"), "# an older version").unwrap();
assert_eq!(ensure_skill_file_in(dir.path()), Outcome::Installed);
let written = fs::read_to_string(dir.path().join("SKILL.md")).unwrap();
assert_eq!(written, EMBEDDED_SKILL_MD);
}
#[test]
fn agent_skills_install_only_into_agent_homes_that_exist() {
let home = tempfile::TempDir::new().unwrap();
assert!(
agent_skill_roots_under(home.path()).is_empty(),
"a machine without an agent must detect nothing"
);
fs::create_dir_all(home.path().join(constants::CLAUDE_HOME_DIR)).unwrap();
let roots = agent_skill_roots_under(home.path());
assert_eq!(roots.len(), 1);
assert_eq!(ensure_agent_skills_at(&roots), Outcome::Installed);
let installed = home
.path()
.join(constants::CLAUDE_HOME_DIR)
.join(constants::AGENT_SKILLS_SUBDIR)
.join(constants::APP_NAME)
.join("SKILL.md");
assert_eq!(fs::read_to_string(&installed).unwrap(), EMBEDDED_SKILL_MD);
assert_eq!(ensure_agent_skills_at(&roots), Outcome::AlreadyPresent);
}
#[test]
fn no_detected_agent_is_a_skip_not_a_failure() {
assert!(matches!(ensure_agent_skills_at(&[]), Outcome::Skipped(_)));
}
#[test]
fn the_stamp_gates_the_unattended_pass() {
let dir = tempfile::TempDir::new().unwrap();
assert!(setup_is_due_in(dir.path()), "a fresh install is due");
write_stamp_in(dir.path());
assert!(
!setup_is_due_in(dir.path()),
"the same version is not due twice"
);
fs::write(dir.path().join(STAMP_FILE), "0.0.1").unwrap();
assert!(setup_is_due_in(dir.path()), "an upgrade is due again");
}
#[test]
fn refreshing_an_alias_that_is_a_hard_link_does_not_empty_the_binary() {
let dir = tempfile::TempDir::new().unwrap();
let binary = dir.path().join("dev-prune");
let alias = dir.path().join("devp");
fs::write(&binary, vec![b'M'; 4096]).unwrap();
if fs::hard_link(&binary, &alias).is_err() {
return; }
assert!(fs::hard_link(&binary, &alias).is_err(), "EEXIST expected");
assert!(alias.exists(), "the guard's condition");
assert_eq!(
fs::metadata(&binary).unwrap().len(),
4096,
"the running binary was truncated by refreshing its own alias"
);
}
fn exe_name(stem: &str) -> String {
if cfg!(windows) {
format!("{stem}.exe")
} else {
stem.to_string()
}
}
#[test]
fn dev_prune_creates_devp_beside_it() {
let dir = tempfile::TempDir::new().unwrap();
let canonical = dir.path().join(exe_name("dev-prune"));
fs::write(&canonical, "the binary").unwrap();
assert_eq!(ensure_twin_of(&canonical, dir.path()), Outcome::Installed);
let alias = dir.path().join(exe_name("devp"));
assert!(alias.is_file(), "`devp` was not created");
assert_eq!(fs::read_to_string(&alias).unwrap(), "the binary");
}
#[test]
fn devp_restores_a_missing_dev_prune() {
let dir = tempfile::TempDir::new().unwrap();
let alias = dir.path().join(exe_name("devp"));
fs::write(&alias, "the binary").unwrap();
assert_eq!(ensure_twin_of(&alias, dir.path()), Outcome::Installed);
let canonical = dir.path().join(exe_name("dev-prune"));
assert!(canonical.is_file(), "`dev-prune` was not put back");
assert_eq!(fs::read_to_string(&canonical).unwrap(), "the binary");
}
#[test]
fn devp_does_not_overwrite_an_existing_dev_prune() {
let dir = tempfile::TempDir::new().unwrap();
let alias = dir.path().join(exe_name("devp"));
let canonical = dir.path().join(exe_name("dev-prune"));
fs::write(&alias, "the previous version").unwrap();
fs::write(&canonical, "the version just upgraded to").unwrap();
assert_eq!(
ensure_twin_of(&alias, dir.path()),
Outcome::AlreadyPresent,
"`devp` must leave an existing `dev-prune` alone"
);
assert_eq!(
fs::read_to_string(&canonical).unwrap(),
"the version just upgraded to",
"`devp` downgraded the binary it was supposed to leave alone"
);
}
#[test]
fn versions_parse_strictly_or_not_at_all() {
assert_eq!(parse_version("1.2.3"), Some((1, 2, 3)));
assert_eq!(parse_version("10.0.0"), Some((10, 0, 0)));
assert_eq!(parse_version("1.2"), None);
assert_eq!(parse_version("1.2.3.4"), None);
assert_eq!(parse_version("1.2.3-rc1"), None);
assert_eq!(parse_version("dev-prune"), None);
assert!(parse_version(constants::VERSION).is_some());
}
#[test]
fn the_version_this_cli_prints_is_one_this_cli_can_read_back() {
let real = format!(
"|_____| v{v}
dev-prune (devp) v{v}
Compiler: Rust 1.88+ (edition 2024)
",
v = constants::VERSION
);
assert_eq!(
version_in_output(&real),
parse_version(constants::VERSION),
"binary_version could not read this binary's own --version output"
);
assert_eq!(version_in_output("git version 2.51.0.windows.1"), None);
assert_eq!(version_in_output("some other tool"), None);
}
#[test]
fn ordering_of_version_triples_matches_semver() {
assert!(parse_version("1.1.0") > parse_version("1.0.9"));
assert!(parse_version("2.0.0") > parse_version("1.99.99"));
assert!(parse_version("1.0.10") > parse_version("1.0.9"));
}
#[test]
fn the_exported_skill_is_the_one_the_binary_was_built_with() {
assert!(EMBEDDED_SKILL_MD.starts_with("---"), "needs frontmatter");
assert!(
!EMBEDDED_SKILL_MD.contains("file:///"),
"SKILL.md is written to every user's machine — it must not contain \
absolute paths from the author's checkout"
);
}
}