1use crate::{PawanError, Result};
33use std::path::PathBuf;
34use std::process::Command;
35
36#[derive(Debug, Clone, Default)]
38pub struct BootstrapOptions {
39 pub skip_mise: bool,
41 pub skip_native: bool,
43 pub include_deagle: bool,
48 pub force_reinstall: bool,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum BootstrapStepStatus {
56 AlreadyInstalled,
58 Installed,
60 Skipped(String),
62 Failed(String),
64}
65
66#[derive(Debug, Clone)]
68pub struct BootstrapStep {
69 pub name: String,
70 pub status: BootstrapStepStatus,
71}
72
73#[derive(Debug, Clone, Default)]
75pub struct BootstrapReport {
76 pub steps: Vec<BootstrapStep>,
77}
78
79impl BootstrapReport {
80 pub fn all_ok(&self) -> bool {
83 !self
84 .steps
85 .iter()
86 .any(|s| matches!(s.status, BootstrapStepStatus::Failed(_)))
87 }
88
89 pub fn installed_count(&self) -> usize {
92 self.steps
93 .iter()
94 .filter(|s| matches!(s.status, BootstrapStepStatus::Installed))
95 .count()
96 }
97
98 pub fn already_installed_count(&self) -> usize {
100 self.steps
101 .iter()
102 .filter(|s| matches!(s.status, BootstrapStepStatus::AlreadyInstalled))
103 .count()
104 }
105
106 pub fn summary(&self) -> String {
108 let installed = self.installed_count();
109 let existing = self.already_installed_count();
110 let failed = self
111 .steps
112 .iter()
113 .filter(|s| matches!(s.status, BootstrapStepStatus::Failed(_)))
114 .count();
115 if failed > 0 {
116 format!(
117 "{} installed, {} already present, {} failed",
118 installed, existing, failed
119 )
120 } else if installed == 0 {
121 format!("all {} deps already present", existing)
122 } else {
123 format!("{} installed, {} already present", installed, existing)
124 }
125 }
126}
127
128pub const NATIVE_TOOLS: &[&str] = &["rg", "fd", "sd", "ast-grep", "erd"];
131
132fn mise_package_name(binary: &str) -> &str {
135 match binary {
136 "erd" => "erdtree",
137 "rg" => "ripgrep",
138 "ast-grep" | "sg" => "ast-grep",
139 other => other,
140 }
141}
142
143pub fn binary_exists(name: &str) -> bool {
145 which::which(name).is_ok()
146}
147
148pub fn is_bootstrapped() -> bool {
152 binary_exists("mise") && NATIVE_TOOLS.iter().all(|t| binary_exists(t))
153}
154
155pub fn missing_deps() -> Vec<String> {
159 let mut missing = Vec::new();
160 if !binary_exists("mise") {
161 missing.push("mise".to_string());
162 }
163 for tool in NATIVE_TOOLS {
164 if !binary_exists(tool) {
165 missing.push((*tool).to_string());
166 }
167 }
168 missing
169}
170
171pub fn marker_path() -> PathBuf {
175 let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
176 PathBuf::from(home).join(".pawan").join(".bootstrapped")
177}
178
179pub fn ensure_deagle(force: bool) -> BootstrapStep {
183 if !force && binary_exists("deagle") {
184 return BootstrapStep {
185 name: "deagle".into(),
186 status: BootstrapStepStatus::AlreadyInstalled,
187 };
188 }
189
190 let output = Command::new("cargo")
191 .args(["install", "--locked", "deagle"])
192 .output();
193
194 let status = match output {
195 Ok(o) if o.status.success() => BootstrapStepStatus::Installed,
196 Ok(o) => {
197 let stderr = String::from_utf8_lossy(&o.stderr);
198 let brief: String = stderr.chars().take(200).collect();
199 BootstrapStepStatus::Failed(format!("cargo install deagle failed: {}", brief))
200 }
201 Err(e) => {
202 BootstrapStepStatus::Failed(format!("cargo install deagle spawn failed: {}", e))
203 }
204 };
205
206 BootstrapStep {
207 name: "deagle".into(),
208 status,
209 }
210}
211
212pub fn ensure_mise() -> BootstrapStep {
225 if binary_exists("mise") {
226 return BootstrapStep {
227 name: "mise".into(),
228 status: BootstrapStepStatus::AlreadyInstalled,
229 };
230 }
231 let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
234 let local = format!("{}/.local/bin/mise", home);
235 if std::path::Path::new(&local).exists() {
236 return BootstrapStep {
237 name: "mise".into(),
238 status: BootstrapStepStatus::AlreadyInstalled,
239 };
240 }
241
242 let output = Command::new("cargo")
243 .args(["install", "--locked", "mise"])
244 .output();
245
246 let status = match output {
247 Ok(o) if o.status.success() => BootstrapStepStatus::Installed,
248 Ok(o) => {
249 let stderr = String::from_utf8_lossy(&o.stderr);
250 let brief: String = stderr.chars().take(200).collect();
251 BootstrapStepStatus::Failed(format!("cargo install mise failed: {}", brief))
252 }
253 Err(e) => BootstrapStepStatus::Failed(format!("cargo install mise spawn failed: {}", e)),
254 };
255
256 BootstrapStep {
257 name: "mise".into(),
258 status,
259 }
260}
261
262pub fn ensure_native_tool(tool: &str) -> BootstrapStep {
266 if binary_exists(tool) {
267 return BootstrapStep {
268 name: tool.into(),
269 status: BootstrapStepStatus::AlreadyInstalled,
270 };
271 }
272
273 let mise_bin = if binary_exists("mise") {
274 "mise".to_string()
275 } else {
276 let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
277 let local = format!("{}/.local/bin/mise", home);
278 if std::path::Path::new(&local).exists() {
279 local
280 } else {
281 return BootstrapStep {
282 name: tool.into(),
283 status: BootstrapStepStatus::Skipped("mise not present".into()),
284 };
285 }
286 };
287
288 let pkg = mise_package_name(tool);
289 let install = Command::new(&mise_bin)
290 .args(["install", pkg, "-y"])
291 .output();
292
293 let status = match install {
294 Ok(o) if o.status.success() => {
295 let _ = Command::new(&mise_bin)
298 .args(["use", "--global", pkg])
299 .output();
300 BootstrapStepStatus::Installed
301 }
302 Ok(o) => {
303 let stderr = String::from_utf8_lossy(&o.stderr);
304 let brief: String = stderr.chars().take(200).collect();
305 BootstrapStepStatus::Failed(format!("mise install {} failed: {}", tool, brief))
306 }
307 Err(e) => {
308 BootstrapStepStatus::Failed(format!("mise install {} spawn failed: {}", tool, e))
309 }
310 };
311
312 BootstrapStep {
313 name: tool.into(),
314 status,
315 }
316}
317
318pub fn ensure_deps(opts: BootstrapOptions) -> BootstrapReport {
327 let mut report = BootstrapReport::default();
328
329 if !opts.skip_mise {
330 report.steps.push(ensure_mise());
331 }
332 if !opts.skip_native {
333 for tool in NATIVE_TOOLS {
334 report.steps.push(ensure_native_tool(tool));
335 }
336 }
337 if opts.include_deagle {
338 report.steps.push(ensure_deagle(opts.force_reinstall));
339 }
340
341 if report.all_ok() {
344 let path = marker_path();
345 if let Some(parent) = path.parent() {
346 let _ = std::fs::create_dir_all(parent);
347 }
348 let _ = std::fs::write(&path, chrono::Utc::now().to_rfc3339());
349 }
350
351 report
352}
353
354pub fn uninstall(purge_deagle: bool) -> Result<()> {
358 let path = marker_path();
359 if path.exists() {
360 std::fs::remove_file(&path)
361 .map_err(|e| PawanError::Config(format!("remove marker: {}", e)))?;
362 }
363
364 if purge_deagle && binary_exists("deagle") {
365 let output = Command::new("cargo")
366 .args(["uninstall", "deagle"])
367 .output()
368 .map_err(|e| PawanError::Config(format!("cargo uninstall spawn: {}", e)))?;
369 if !output.status.success() {
370 return Err(PawanError::Config(format!(
371 "cargo uninstall deagle failed: {}",
372 String::from_utf8_lossy(&output.stderr)
373 )));
374 }
375 }
376
377 Ok(())
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 #[test]
385 fn bootstrap_report_default_is_all_ok() {
386 let report = BootstrapReport::default();
389 assert!(report.all_ok());
390 assert_eq!(report.installed_count(), 0);
391 assert_eq!(report.already_installed_count(), 0);
392 }
393
394 #[test]
395 fn bootstrap_report_with_failed_step_is_not_ok() {
396 let report = BootstrapReport {
397 steps: vec![BootstrapStep {
398 name: "deagle".into(),
399 status: BootstrapStepStatus::Failed("network".into()),
400 }],
401 };
402 assert!(!report.all_ok());
403 assert_eq!(report.installed_count(), 0);
404 }
405
406 #[test]
407 fn bootstrap_report_skipped_step_is_not_a_failure() {
408 let report = BootstrapReport {
410 steps: vec![BootstrapStep {
411 name: "mise".into(),
412 status: BootstrapStepStatus::Skipped("caller skipped".into()),
413 }],
414 };
415 assert!(report.all_ok(), "skipped != failed");
416 }
417
418 #[test]
419 fn bootstrap_report_installed_count_excludes_already_installed() {
420 let report = BootstrapReport {
421 steps: vec![
422 BootstrapStep {
423 name: "a".into(),
424 status: BootstrapStepStatus::Installed,
425 },
426 BootstrapStep {
427 name: "b".into(),
428 status: BootstrapStepStatus::AlreadyInstalled,
429 },
430 BootstrapStep {
431 name: "c".into(),
432 status: BootstrapStepStatus::Installed,
433 },
434 ],
435 };
436 assert_eq!(report.installed_count(), 2);
437 assert_eq!(report.already_installed_count(), 1);
438 }
439
440 #[test]
441 fn bootstrap_report_summary_shows_counts() {
442 let report = BootstrapReport {
444 steps: vec![
445 BootstrapStep {
446 name: "mise".into(),
447 status: BootstrapStepStatus::AlreadyInstalled,
448 },
449 BootstrapStep {
450 name: "deagle".into(),
451 status: BootstrapStepStatus::Installed,
452 },
453 BootstrapStep {
454 name: "rg".into(),
455 status: BootstrapStepStatus::Failed("nope".into()),
456 },
457 ],
458 };
459 let s = report.summary();
460 assert!(s.contains("1 installed"));
461 assert!(s.contains("1 already present"));
462 assert!(s.contains("1 failed"));
463 }
464
465 #[test]
466 fn bootstrap_report_summary_all_present() {
467 let report = BootstrapReport {
468 steps: vec![
469 BootstrapStep {
470 name: "mise".into(),
471 status: BootstrapStepStatus::AlreadyInstalled,
472 },
473 BootstrapStep {
474 name: "deagle".into(),
475 status: BootstrapStepStatus::AlreadyInstalled,
476 },
477 ],
478 };
479 assert_eq!(report.summary(), "all 2 deps already present");
480 }
481
482 #[test]
483 fn native_tools_constant_is_5_well_known_tools() {
484 assert_eq!(NATIVE_TOOLS.len(), 5);
488 assert!(NATIVE_TOOLS.contains(&"rg"));
489 assert!(NATIVE_TOOLS.contains(&"fd"));
490 assert!(NATIVE_TOOLS.contains(&"sd"));
491 assert!(NATIVE_TOOLS.contains(&"ast-grep"));
492 assert!(NATIVE_TOOLS.contains(&"erd"));
493 }
494
495 #[test]
496 fn mise_package_name_handles_binary_name_mismatch() {
497 assert_eq!(mise_package_name("rg"), "ripgrep");
501 assert_eq!(mise_package_name("erd"), "erdtree");
502 assert_eq!(mise_package_name("fd"), "fd");
503 assert_eq!(mise_package_name("sd"), "sd");
504 assert_eq!(mise_package_name("ast-grep"), "ast-grep");
505 assert_eq!(mise_package_name("sg"), "ast-grep");
506 assert_eq!(mise_package_name("unknown-tool"), "unknown-tool");
508 }
509
510 #[test]
511 fn marker_path_is_under_home_dot_pawan() {
512 let path = marker_path();
515 let s = path.to_string_lossy();
516 assert!(s.ends_with(".pawan/.bootstrapped"));
517 }
518
519 #[test]
520 fn ensure_deagle_is_idempotent_when_already_on_path() {
521 if !binary_exists("deagle") {
524 return;
527 }
528 let step = ensure_deagle(false);
529 assert_eq!(step.name, "deagle");
530 assert_eq!(
531 step.status,
532 BootstrapStepStatus::AlreadyInstalled,
533 "second call must be a no-op when deagle is present"
534 );
535 }
536
537 #[test]
538 fn ensure_mise_is_idempotent_when_already_on_path() {
539 if !binary_exists("mise") {
540 let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
543 if !std::path::Path::new(&format!("{}/.local/bin/mise", home)).exists() {
544 return; }
546 }
547 let step = ensure_mise();
548 assert_eq!(step.name, "mise");
549 assert_eq!(step.status, BootstrapStepStatus::AlreadyInstalled);
550 }
551
552 #[test]
553 fn ensure_native_tool_is_idempotent_when_already_on_path() {
554 for tool in NATIVE_TOOLS {
556 if binary_exists(tool) {
557 let step = ensure_native_tool(tool);
558 assert_eq!(step.name, *tool);
559 assert_eq!(
560 step.status,
561 BootstrapStepStatus::AlreadyInstalled,
562 "ensure_native_tool({}) must be idempotent",
563 tool
564 );
565 return;
566 }
567 }
568 }
570
571 #[test]
572 fn missing_deps_is_empty_on_fully_bootstrapped_box() {
573 if !is_bootstrapped() {
574 return;
575 }
576 assert!(
577 missing_deps().is_empty(),
578 "is_bootstrapped() and missing_deps() must agree"
579 );
580 }
581
582 #[test]
583 fn ensure_deps_with_all_skips_writes_empty_report() {
584 let opts = BootstrapOptions {
588 skip_mise: true,
589 skip_native: true,
590 include_deagle: false,
591 force_reinstall: false,
592 };
593 let report = ensure_deps(opts);
594 assert_eq!(report.steps.len(), 0);
595 assert!(report.all_ok());
596 assert_eq!(report.installed_count(), 0);
597 }
598
599 #[test]
600 fn default_options_do_not_include_deagle() {
601 let opts = BootstrapOptions::default();
605 assert!(!opts.include_deagle, "default must exclude deagle install");
606 assert!(!opts.skip_mise, "default installs mise");
607 assert!(!opts.skip_native, "default installs native tools");
608 assert!(!opts.force_reinstall);
609 }
610
611 #[test]
612 fn is_bootstrapped_does_not_require_deagle() {
613 if binary_exists("mise") && NATIVE_TOOLS.iter().all(|t| binary_exists(t)) {
621 assert!(is_bootstrapped());
622 }
623 assert!(
625 !missing_deps().iter().any(|d| d == "deagle"),
626 "missing_deps must not mention deagle"
627 );
628 }
629
630 #[test]
631 fn uninstall_without_marker_file_is_ok() {
632 use std::sync::Mutex;
635 static LOCK: Mutex<()> = Mutex::new(());
636 let _guard = LOCK.lock().unwrap();
637
638 let tmp = tempfile::TempDir::new().unwrap();
640 let prev_home = std::env::var("HOME").ok();
641 std::env::set_var("HOME", tmp.path());
642
643 let result = uninstall(false);
644
645 if let Some(h) = prev_home {
646 std::env::set_var("HOME", h);
647 }
648
649 assert!(result.is_ok());
650 }
651}