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 const ENV_NO_AUTO_SETUP: &str = "DEV_PRUNE_NO_AUTO_SETUP";
pub fn no_auto_setup_requested() -> bool {
std::env::var_os(ENV_NO_AUTO_SETUP).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}"));
}
}
}
}
}
fn managed_exe_path() -> Result<PathBuf> {
let name = if cfg!(windows) {
"dev-prune.exe"
} else {
"dev-prune"
};
Ok(Registry::config_dir()?.join("bin").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);
}
}
fn binary_version(exe: &std::path::Path) -> Option<(u64, u64, u64)> {
let output = std::process::Command::new(exe)
.arg("--version")
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8_lossy(&output.stdout)
.split_whitespace()
.find_map(parse_version)
}
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 {
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 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() => 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) => 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("SKILL.md", ensure_skill_file());
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
}
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)
}
pub fn auto_setup_if_due() {
if !setup_is_due() {
first_run_config_review();
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;
}
use std::io::IsTerminal;
if unattended_environment().is_some()
|| !std::io::stdin().is_terminal()
|| !std::io::stdout().is_terminal()
{
crate::commands::config::skip_config_review();
return;
}
if let Err(e) = crate::commands::config::run_wizard() {
output::print_warning(&format!("Could not run the first-run setup ({e:#})."));
crate::commands::config::skip_config_review();
}
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 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 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"
);
}
}