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";
#[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 || managed.is_file() {
return managed;
}
let is_cli = current
.file_stem()
.and_then(|s| s.to_str())
.is_some_and(|stem| stem == "dev-prune" || stem == "devp");
if !is_cli {
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;
}
if fs::copy(¤t, &managed).is_ok() {
managed
} else {
current
}
}
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());
};
let alias_name = if cfg!(windows) { "devp.exe" } else { "devp" };
let alias_exe = parent_dir.join(alias_name);
if alias_exe == current_exe {
return Outcome::AlreadyPresent;
}
if alias_exe.exists() {
if same_contents(&alias_exe, ¤t_exe) {
return Outcome::AlreadyPresent;
}
if fs::remove_file(&alias_exe).is_err() {
return Outcome::Skipped(format!(
"`{alias_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(¤t_exe, &alias_exe).is_ok() {
return Outcome::Installed;
}
if alias_exe.exists() {
return Outcome::AlreadyPresent;
}
if fs::copy(¤t_exe, &alias_exe).is_ok() {
Outcome::Installed
} else {
Outcome::Failed(format!(
"could not create `{}`",
output::clean_path(&alias_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;
};
ma.len() == mb.len() && ma.modified().ok() == mb.modified().ok()
}
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) => 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 { .. }) => 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:#}")),
}
}
pub fn ensure_daemon(interval_days: u64) -> Outcome {
match daemon::daemon_status() {
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 {
std::env::var_os(ENV_NO_AUTO_SETUP).is_none()
&& 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("devp alias", 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"
);
}
#[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"
);
}
}