use crate::{PawanError, Result};
use std::path::PathBuf;
use std::process::Command;
#[derive(Debug, Clone, Default)]
pub struct BootstrapOptions {
pub skip_mise: bool,
pub skip_native: bool,
pub include_deagle: bool,
pub force_reinstall: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BootstrapStepStatus {
AlreadyInstalled,
Installed,
Skipped(String),
Failed(String),
}
#[derive(Debug, Clone)]
pub struct BootstrapStep {
pub name: String,
pub status: BootstrapStepStatus,
}
#[derive(Debug, Clone, Default)]
pub struct BootstrapReport {
pub steps: Vec<BootstrapStep>,
}
impl BootstrapReport {
pub fn all_ok(&self) -> bool {
!self
.steps
.iter()
.any(|s| matches!(s.status, BootstrapStepStatus::Failed(_)))
}
pub fn installed_count(&self) -> usize {
self.steps
.iter()
.filter(|s| matches!(s.status, BootstrapStepStatus::Installed))
.count()
}
pub fn already_installed_count(&self) -> usize {
self.steps
.iter()
.filter(|s| matches!(s.status, BootstrapStepStatus::AlreadyInstalled))
.count()
}
pub fn summary(&self) -> String {
let installed = self.installed_count();
let existing = self.already_installed_count();
let failed = self
.steps
.iter()
.filter(|s| matches!(s.status, BootstrapStepStatus::Failed(_)))
.count();
if failed > 0 {
format!(
"{} installed, {} already present, {} failed",
installed, existing, failed
)
} else if installed == 0 {
format!("all {} deps already present", existing)
} else {
format!("{} installed, {} already present", installed, existing)
}
}
}
pub const NATIVE_TOOLS: &[&str] = &["rg", "fd", "sd", "ast-grep", "erd"];
fn mise_package_name(binary: &str) -> &str {
match binary {
"erd" => "erdtree",
"rg" => "ripgrep",
"ast-grep" | "sg" => "ast-grep",
other => other,
}
}
pub fn binary_exists(name: &str) -> bool {
which::which(name).is_ok()
}
pub fn is_bootstrapped() -> bool {
binary_exists("mise") && NATIVE_TOOLS.iter().all(|t| binary_exists(t))
}
pub fn missing_deps() -> Vec<String> {
let mut missing = Vec::new();
if !binary_exists("mise") {
missing.push("mise".to_string());
}
for tool in NATIVE_TOOLS {
if !binary_exists(tool) {
missing.push((*tool).to_string());
}
}
missing
}
pub fn marker_path() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
PathBuf::from(home).join(".pawan").join(".bootstrapped")
}
pub fn ensure_deagle(force: bool) -> BootstrapStep {
if !force && binary_exists("deagle") {
return BootstrapStep {
name: "deagle".into(),
status: BootstrapStepStatus::AlreadyInstalled,
};
}
let output = Command::new("cargo")
.args(["install", "--locked", "deagle"])
.output();
let status = match output {
Ok(o) if o.status.success() => BootstrapStepStatus::Installed,
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
let brief: String = stderr.chars().take(200).collect();
BootstrapStepStatus::Failed(format!("cargo install deagle failed: {}", brief))
}
Err(e) => {
BootstrapStepStatus::Failed(format!("cargo install deagle spawn failed: {}", e))
}
};
BootstrapStep {
name: "deagle".into(),
status,
}
}
pub fn ensure_mise() -> BootstrapStep {
if binary_exists("mise") {
return BootstrapStep {
name: "mise".into(),
status: BootstrapStepStatus::AlreadyInstalled,
};
}
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
let local = format!("{}/.local/bin/mise", home);
if std::path::Path::new(&local).exists() {
return BootstrapStep {
name: "mise".into(),
status: BootstrapStepStatus::AlreadyInstalled,
};
}
let output = Command::new("cargo")
.args(["install", "--locked", "mise"])
.output();
let status = match output {
Ok(o) if o.status.success() => BootstrapStepStatus::Installed,
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
let brief: String = stderr.chars().take(200).collect();
BootstrapStepStatus::Failed(format!("cargo install mise failed: {}", brief))
}
Err(e) => BootstrapStepStatus::Failed(format!("cargo install mise spawn failed: {}", e)),
};
BootstrapStep {
name: "mise".into(),
status,
}
}
pub fn ensure_native_tool(tool: &str) -> BootstrapStep {
if binary_exists(tool) {
return BootstrapStep {
name: tool.into(),
status: BootstrapStepStatus::AlreadyInstalled,
};
}
let mise_bin = if binary_exists("mise") {
"mise".to_string()
} else {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
let local = format!("{}/.local/bin/mise", home);
if std::path::Path::new(&local).exists() {
local
} else {
return BootstrapStep {
name: tool.into(),
status: BootstrapStepStatus::Skipped("mise not present".into()),
};
}
};
let pkg = mise_package_name(tool);
let install = Command::new(&mise_bin)
.args(["install", pkg, "-y"])
.output();
let status = match install {
Ok(o) if o.status.success() => {
let _ = Command::new(&mise_bin)
.args(["use", "--global", pkg])
.output();
BootstrapStepStatus::Installed
}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
let brief: String = stderr.chars().take(200).collect();
BootstrapStepStatus::Failed(format!("mise install {} failed: {}", tool, brief))
}
Err(e) => {
BootstrapStepStatus::Failed(format!("mise install {} spawn failed: {}", tool, e))
}
};
BootstrapStep {
name: tool.into(),
status,
}
}
pub fn ensure_deps(opts: BootstrapOptions) -> BootstrapReport {
let mut report = BootstrapReport::default();
if !opts.skip_mise {
report.steps.push(ensure_mise());
}
if !opts.skip_native {
for tool in NATIVE_TOOLS {
report.steps.push(ensure_native_tool(tool));
}
}
if opts.include_deagle {
report.steps.push(ensure_deagle(opts.force_reinstall));
}
if report.all_ok() {
let path = marker_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&path, chrono::Utc::now().to_rfc3339());
}
report
}
pub fn uninstall(purge_deagle: bool) -> Result<()> {
let path = marker_path();
if path.exists() {
std::fs::remove_file(&path)
.map_err(|e| PawanError::Config(format!("remove marker: {}", e)))?;
}
if purge_deagle && binary_exists("deagle") {
let output = Command::new("cargo")
.args(["uninstall", "deagle"])
.output()
.map_err(|e| PawanError::Config(format!("cargo uninstall spawn: {}", e)))?;
if !output.status.success() {
return Err(PawanError::Config(format!(
"cargo uninstall deagle failed: {}",
String::from_utf8_lossy(&output.stderr)
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bootstrap_report_default_is_all_ok() {
let report = BootstrapReport::default();
assert!(report.all_ok());
assert_eq!(report.installed_count(), 0);
assert_eq!(report.already_installed_count(), 0);
}
#[test]
fn bootstrap_report_with_failed_step_is_not_ok() {
let report = BootstrapReport {
steps: vec![BootstrapStep {
name: "deagle".into(),
status: BootstrapStepStatus::Failed("network".into()),
}],
};
assert!(!report.all_ok());
assert_eq!(report.installed_count(), 0);
}
#[test]
fn bootstrap_report_skipped_step_is_not_a_failure() {
let report = BootstrapReport {
steps: vec![BootstrapStep {
name: "mise".into(),
status: BootstrapStepStatus::Skipped("caller skipped".into()),
}],
};
assert!(report.all_ok(), "skipped != failed");
}
#[test]
fn bootstrap_report_installed_count_excludes_already_installed() {
let report = BootstrapReport {
steps: vec![
BootstrapStep {
name: "a".into(),
status: BootstrapStepStatus::Installed,
},
BootstrapStep {
name: "b".into(),
status: BootstrapStepStatus::AlreadyInstalled,
},
BootstrapStep {
name: "c".into(),
status: BootstrapStepStatus::Installed,
},
],
};
assert_eq!(report.installed_count(), 2);
assert_eq!(report.already_installed_count(), 1);
}
#[test]
fn bootstrap_report_summary_shows_counts() {
let report = BootstrapReport {
steps: vec![
BootstrapStep {
name: "mise".into(),
status: BootstrapStepStatus::AlreadyInstalled,
},
BootstrapStep {
name: "deagle".into(),
status: BootstrapStepStatus::Installed,
},
BootstrapStep {
name: "rg".into(),
status: BootstrapStepStatus::Failed("nope".into()),
},
],
};
let s = report.summary();
assert!(s.contains("1 installed"));
assert!(s.contains("1 already present"));
assert!(s.contains("1 failed"));
}
#[test]
fn bootstrap_report_summary_all_present() {
let report = BootstrapReport {
steps: vec![
BootstrapStep {
name: "mise".into(),
status: BootstrapStepStatus::AlreadyInstalled,
},
BootstrapStep {
name: "deagle".into(),
status: BootstrapStepStatus::AlreadyInstalled,
},
],
};
assert_eq!(report.summary(), "all 2 deps already present");
}
#[test]
fn native_tools_constant_is_5_well_known_tools() {
assert_eq!(NATIVE_TOOLS.len(), 5);
assert!(NATIVE_TOOLS.contains(&"rg"));
assert!(NATIVE_TOOLS.contains(&"fd"));
assert!(NATIVE_TOOLS.contains(&"sd"));
assert!(NATIVE_TOOLS.contains(&"ast-grep"));
assert!(NATIVE_TOOLS.contains(&"erd"));
}
#[test]
fn mise_package_name_handles_binary_name_mismatch() {
assert_eq!(mise_package_name("rg"), "ripgrep");
assert_eq!(mise_package_name("erd"), "erdtree");
assert_eq!(mise_package_name("fd"), "fd");
assert_eq!(mise_package_name("sd"), "sd");
assert_eq!(mise_package_name("ast-grep"), "ast-grep");
assert_eq!(mise_package_name("sg"), "ast-grep");
assert_eq!(mise_package_name("unknown-tool"), "unknown-tool");
}
#[test]
fn marker_path_is_under_home_dot_pawan() {
let path = marker_path();
let s = path.to_string_lossy();
assert!(s.ends_with(".pawan/.bootstrapped"));
}
#[test]
fn ensure_deagle_is_idempotent_when_already_on_path() {
if !binary_exists("deagle") {
return;
}
let step = ensure_deagle(false);
assert_eq!(step.name, "deagle");
assert_eq!(
step.status,
BootstrapStepStatus::AlreadyInstalled,
"second call must be a no-op when deagle is present"
);
}
#[test]
fn ensure_mise_is_idempotent_when_already_on_path() {
if !binary_exists("mise") {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
if !std::path::Path::new(&format!("{}/.local/bin/mise", home)).exists() {
return; }
}
let step = ensure_mise();
assert_eq!(step.name, "mise");
assert_eq!(step.status, BootstrapStepStatus::AlreadyInstalled);
}
#[test]
fn ensure_native_tool_is_idempotent_when_already_on_path() {
for tool in NATIVE_TOOLS {
if binary_exists(tool) {
let step = ensure_native_tool(tool);
assert_eq!(step.name, *tool);
assert_eq!(
step.status,
BootstrapStepStatus::AlreadyInstalled,
"ensure_native_tool({}) must be idempotent",
tool
);
return;
}
}
}
#[test]
fn missing_deps_is_empty_on_fully_bootstrapped_box() {
if !is_bootstrapped() {
return;
}
assert!(
missing_deps().is_empty(),
"is_bootstrapped() and missing_deps() must agree"
);
}
#[test]
fn ensure_deps_with_all_skips_writes_empty_report() {
let opts = BootstrapOptions {
skip_mise: true,
skip_native: true,
include_deagle: false,
force_reinstall: false,
};
let report = ensure_deps(opts);
assert_eq!(report.steps.len(), 0);
assert!(report.all_ok());
assert_eq!(report.installed_count(), 0);
}
#[test]
fn default_options_do_not_include_deagle() {
let opts = BootstrapOptions::default();
assert!(!opts.include_deagle, "default must exclude deagle install");
assert!(!opts.skip_mise, "default installs mise");
assert!(!opts.skip_native, "default installs native tools");
assert!(!opts.force_reinstall);
}
#[test]
fn is_bootstrapped_does_not_require_deagle() {
if binary_exists("mise") && NATIVE_TOOLS.iter().all(|t| binary_exists(t)) {
assert!(is_bootstrapped());
}
assert!(
!missing_deps().iter().any(|d| d == "deagle"),
"missing_deps must not mention deagle"
);
}
#[test]
fn uninstall_without_marker_file_is_ok() {
use std::sync::Mutex;
static LOCK: Mutex<()> = Mutex::new(());
let _guard = LOCK.lock().unwrap();
let tmp = tempfile::TempDir::new().unwrap();
let prev_home = std::env::var("HOME").ok();
std::env::set_var("HOME", tmp.path());
let result = uninstall(false);
if let Some(h) = prev_home {
std::env::set_var("HOME", h);
}
assert!(result.is_ok());
}
}