Skip to main content

pawan/
bootstrap.rs

1//! External dependency bootstrap — install the binaries pawan shells out to.
2//!
3//! Pawan depends on a few external tools that aren't pulled in by
4//! `cargo install pawan`:
5//!
6//! - `mise` — polyglot tool/runtime manager (needed to install the rest)
7//! - `rg`, `fd`, `sd`, `ast-grep`, `erd` — native search/replace/tree tools
8//!   (auto-installed via mise on first tool use, but only if mise is present)
9//!
10//! As of the Option B rewrite, `deagle` is NO LONGER an external dep:
11//! `deagle-core` and `deagle-parse` are embedded directly into pawan as
12//! library crates, so all 5 deagle tools work out of the box. The
13//! [`ensure_deagle`] function still exists for backwards compatibility
14//! (and for users who want the standalone `deagle` CLI on PATH for
15//! interactive use), but it's opt-in via `--include-deagle` and not
16//! part of the default bootstrap.
17//!
18//! This module provides an idempotent, reversible install path so that
19//! `cargo install pawan && pawan bootstrap` is enough to get a working
20//! setup — no manual tool wrangling. Each step is non-destructive: it
21//! checks `which <binary>` first and skips if the binary is already on
22//! PATH, unless `force_reinstall` is set.
23//!
24//! ## Reversibility
25//!
26//! [`uninstall`] removes the marker file and optionally runs
27//! `cargo uninstall deagle` (only if `--purge-deagle` is passed, and
28//! only if the user had opted in to installing it). It deliberately
29//! does NOT touch mise or mise-managed tools because those may be used
30//! by other programs on the system.
31
32use crate::{PawanError, Result};
33use std::path::PathBuf;
34use std::process::Command;
35
36/// Options for a bootstrap run.
37#[derive(Debug, Clone, Default)]
38pub struct BootstrapOptions {
39    /// Skip installing mise (caller will handle it themselves).
40    pub skip_mise: bool,
41    /// Skip installing the mise-managed native tools (rg/fd/sd/ast-grep/erd).
42    pub skip_native: bool,
43    /// ALSO install the standalone `deagle` CLI binary via
44    /// `cargo install --locked deagle`. Opt-in because pawan already
45    /// embeds `deagle-core` + `deagle-parse` as library deps, so the
46    /// standalone CLI is only useful for interactive shell use.
47    pub include_deagle: bool,
48    /// Reinstall even if the binary is already on PATH. Off by default —
49    /// bootstrap is meant to be safe to run repeatedly.
50    pub force_reinstall: bool,
51}
52
53/// The outcome of installing (or trying to install) a single dependency.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum BootstrapStepStatus {
56    /// Binary was already on PATH; no install attempted.
57    AlreadyInstalled,
58    /// Install ran successfully (binary is now on PATH).
59    Installed,
60    /// Install was skipped for a reason (e.g. mise not present).
61    Skipped(String),
62    /// Install attempt failed with an error message.
63    Failed(String),
64}
65
66/// One line of a bootstrap report.
67#[derive(Debug, Clone)]
68pub struct BootstrapStep {
69    pub name: String,
70    pub status: BootstrapStepStatus,
71}
72
73/// Summary of a bootstrap run — one [`BootstrapStep`] per dependency.
74#[derive(Debug, Clone, Default)]
75pub struct BootstrapReport {
76    pub steps: Vec<BootstrapStep>,
77}
78
79impl BootstrapReport {
80    /// `true` if no step is in the `Failed` state. `Skipped` steps do not
81    /// break the contract — they're a caller choice, not an error.
82    pub fn all_ok(&self) -> bool {
83        !self
84            .steps
85            .iter()
86            .any(|s| matches!(s.status, BootstrapStepStatus::Failed(_)))
87    }
88
89    /// Number of steps that actually ran an install in this invocation.
90    /// Used to decide whether to print a "N tool(s) installed" summary.
91    pub fn installed_count(&self) -> usize {
92        self.steps
93            .iter()
94            .filter(|s| matches!(s.status, BootstrapStepStatus::Installed))
95            .count()
96    }
97
98    /// Number of steps that were already satisfied (idempotency signal).
99    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    /// Human-readable one-line summary for the end of a bootstrap run.
107    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
128/// The native tools managed by mise. Must match the tool names pawan
129/// uses at runtime in `tools/native.rs`.
130pub const NATIVE_TOOLS: &[&str] = &["rg", "fd", "sd", "ast-grep", "erd"];
131
132/// Map a native binary name to its mise package name. Mirrors the
133/// mapping in `tools/native.rs` — keep both in sync.
134fn 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
143/// Check if a binary is available on PATH.
144pub fn binary_exists(name: &str) -> bool {
145    which::which(name).is_ok()
146}
147
148/// True if every REQUIRED external dep is on PATH. Deagle is excluded
149/// because pawan embeds deagle-core + deagle-parse as library deps —
150/// the standalone binary is no longer required.
151pub fn is_bootstrapped() -> bool {
152    binary_exists("mise") && NATIVE_TOOLS.iter().all(|t| binary_exists(t))
153}
154
155/// List of REQUIRED dep names that are NOT on PATH. Deagle is excluded
156/// because pawan embeds it directly — see [`is_bootstrapped`]. Used by
157/// `pawan doctor` and the CLI `bootstrap --dry-run` flag.
158pub 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
171/// Path to the "this pawan has been bootstrapped" marker file. The
172/// presence of this file is how startup knows to skip the auto-bootstrap
173/// prompt on subsequent runs.
174pub 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
179/// Install deagle via `cargo install --locked deagle`. Idempotent — if
180/// deagle is already on PATH and `force` is false, returns
181/// `AlreadyInstalled` without shelling out.
182pub 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) => BootstrapStepStatus::Failed(format!("cargo install deagle spawn failed: {}", e)),
202    };
203
204    BootstrapStep {
205        name: "deagle".into(),
206        status,
207    }
208}
209
210/// Install mise via `cargo install --locked mise`. Idempotent — skipped
211/// if mise is already on PATH or at `~/.local/bin/mise` (mise's default
212/// install location, which may not yet be on PATH).
213///
214/// We prefer `cargo install` over the curl-pipe-shell installer because
215/// (a) cargo is already present for any user who got pawan via
216/// `cargo install pawan`, and (b) cargo install is a known,
217/// signed-binary path — no remote shell script trust required.
218///
219/// Note: `mise` itself is a binary-only crate on crates.io (no lib
220/// target), so pawan cannot `use mise::...` directly. Bootstrap is the
221/// only remaining integration surface.
222pub fn ensure_mise() -> BootstrapStep {
223    if binary_exists("mise") {
224        return BootstrapStep {
225            name: "mise".into(),
226            status: BootstrapStepStatus::AlreadyInstalled,
227        };
228    }
229    // Fallback: mise installs into ~/.local/bin/ which isn't always on
230    // PATH in non-interactive shells. Detect the raw file.
231    let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
232    let local = format!("{}/.local/bin/mise", home);
233    if std::path::Path::new(&local).exists() {
234        return BootstrapStep {
235            name: "mise".into(),
236            status: BootstrapStepStatus::AlreadyInstalled,
237        };
238    }
239
240    let output = Command::new("cargo")
241        .args(["install", "--locked", "mise"])
242        .output();
243
244    let status = match output {
245        Ok(o) if o.status.success() => BootstrapStepStatus::Installed,
246        Ok(o) => {
247            let stderr = String::from_utf8_lossy(&o.stderr);
248            let brief: String = stderr.chars().take(200).collect();
249            BootstrapStepStatus::Failed(format!("cargo install mise failed: {}", brief))
250        }
251        Err(e) => BootstrapStepStatus::Failed(format!("cargo install mise spawn failed: {}", e)),
252    };
253
254    BootstrapStep {
255        name: "mise".into(),
256        status,
257    }
258}
259
260/// Install a native tool via mise. Requires mise to already be on PATH
261/// (or at `~/.local/bin/mise`) — returns Skipped otherwise so the caller
262/// can decide how to surface that.
263pub fn ensure_native_tool(tool: &str) -> BootstrapStep {
264    if binary_exists(tool) {
265        return BootstrapStep {
266            name: tool.into(),
267            status: BootstrapStepStatus::AlreadyInstalled,
268        };
269    }
270
271    let mise_bin = if binary_exists("mise") {
272        "mise".to_string()
273    } else {
274        let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
275        let local = format!("{}/.local/bin/mise", home);
276        if std::path::Path::new(&local).exists() {
277            local
278        } else {
279            return BootstrapStep {
280                name: tool.into(),
281                status: BootstrapStepStatus::Skipped("mise not present".into()),
282            };
283        }
284    };
285
286    let pkg = mise_package_name(tool);
287    let install = Command::new(&mise_bin)
288        .args(["install", pkg, "-y"])
289        .output();
290
291    let status = match install {
292        Ok(o) if o.status.success() => {
293            // Also run `mise use --global` so the tool is on PATH for
294            // subsequent processes.
295            let _ = Command::new(&mise_bin)
296                .args(["use", "--global", pkg])
297                .output();
298            BootstrapStepStatus::Installed
299        }
300        Ok(o) => {
301            let stderr = String::from_utf8_lossy(&o.stderr);
302            let brief: String = stderr.chars().take(200).collect();
303            BootstrapStepStatus::Failed(format!("mise install {} failed: {}", tool, brief))
304        }
305        Err(e) => BootstrapStepStatus::Failed(format!("mise install {} spawn failed: {}", tool, e)),
306    };
307
308    BootstrapStep {
309        name: tool.into(),
310        status,
311    }
312}
313
314/// Run the full bootstrap sequence per the options. On success (`all_ok`
315/// returns true), writes a marker file at [`marker_path`] containing the
316/// install timestamp.
317///
318/// Default (all opts false): installs mise and native tools. Deagle is
319/// NOT installed by default — pawan embeds deagle-core + deagle-parse
320/// as library deps, so the standalone CLI is only needed for
321/// interactive shell use. Set `include_deagle = true` to opt in.
322pub fn ensure_deps(opts: BootstrapOptions) -> BootstrapReport {
323    let mut report = BootstrapReport::default();
324
325    if !opts.skip_mise {
326        report.steps.push(ensure_mise());
327    }
328    if !opts.skip_native {
329        for tool in NATIVE_TOOLS {
330            report.steps.push(ensure_native_tool(tool));
331        }
332    }
333    if opts.include_deagle {
334        report.steps.push(ensure_deagle(opts.force_reinstall));
335    }
336
337    // Write the marker only if the run completed without any Failed step.
338    // Skipped steps are OK — they're caller-requested.
339    if report.all_ok() {
340        let path = marker_path();
341        if let Some(parent) = path.parent() {
342            let _ = std::fs::create_dir_all(parent);
343        }
344        let _ = std::fs::write(&path, chrono::Utc::now().to_rfc3339());
345    }
346
347    report
348}
349
350/// Reverse the bootstrap: remove the marker file, and optionally run
351/// `cargo uninstall deagle`. Deliberately does NOT uninstall mise or
352/// mise-managed tools — those may be used by other programs.
353pub fn uninstall(purge_deagle: bool) -> Result<()> {
354    let path = marker_path();
355    if path.exists() {
356        std::fs::remove_file(&path)
357            .map_err(|e| PawanError::Config(format!("remove marker: {}", e)))?;
358    }
359
360    if purge_deagle && binary_exists("deagle") {
361        let output = Command::new("cargo")
362            .args(["uninstall", "deagle"])
363            .output()
364            .map_err(|e| PawanError::Config(format!("cargo uninstall spawn: {}", e)))?;
365        if !output.status.success() {
366            return Err(PawanError::Config(format!(
367                "cargo uninstall deagle failed: {}",
368                String::from_utf8_lossy(&output.stderr)
369            )));
370        }
371    }
372
373    Ok(())
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn bootstrap_report_default_is_all_ok() {
382        // An empty report has no failures, so all_ok() must be true.
383        // Used when skip_mise=skip_deagle=skip_native=true.
384        let report = BootstrapReport::default();
385        assert!(report.all_ok());
386        assert_eq!(report.installed_count(), 0);
387        assert_eq!(report.already_installed_count(), 0);
388    }
389
390    #[test]
391    fn bootstrap_report_with_failed_step_is_not_ok() {
392        let report = BootstrapReport {
393            steps: vec![BootstrapStep {
394                name: "deagle".into(),
395                status: BootstrapStepStatus::Failed("network".into()),
396            }],
397        };
398        assert!(!report.all_ok());
399        assert_eq!(report.installed_count(), 0);
400    }
401
402    #[test]
403    fn bootstrap_report_skipped_step_is_not_a_failure() {
404        // Skipped means "caller asked us not to do this" — not an error.
405        let report = BootstrapReport {
406            steps: vec![BootstrapStep {
407                name: "mise".into(),
408                status: BootstrapStepStatus::Skipped("caller skipped".into()),
409            }],
410        };
411        assert!(report.all_ok(), "skipped != failed");
412    }
413
414    #[test]
415    fn bootstrap_report_installed_count_excludes_already_installed() {
416        let report = BootstrapReport {
417            steps: vec![
418                BootstrapStep {
419                    name: "a".into(),
420                    status: BootstrapStepStatus::Installed,
421                },
422                BootstrapStep {
423                    name: "b".into(),
424                    status: BootstrapStepStatus::AlreadyInstalled,
425                },
426                BootstrapStep {
427                    name: "c".into(),
428                    status: BootstrapStepStatus::Installed,
429                },
430            ],
431        };
432        assert_eq!(report.installed_count(), 2);
433        assert_eq!(report.already_installed_count(), 1);
434    }
435
436    #[test]
437    fn bootstrap_report_summary_shows_counts() {
438        // All three categories exercised at once.
439        let report = BootstrapReport {
440            steps: vec![
441                BootstrapStep {
442                    name: "mise".into(),
443                    status: BootstrapStepStatus::AlreadyInstalled,
444                },
445                BootstrapStep {
446                    name: "deagle".into(),
447                    status: BootstrapStepStatus::Installed,
448                },
449                BootstrapStep {
450                    name: "rg".into(),
451                    status: BootstrapStepStatus::Failed("nope".into()),
452                },
453            ],
454        };
455        let s = report.summary();
456        assert!(s.contains("1 installed"));
457        assert!(s.contains("1 already present"));
458        assert!(s.contains("1 failed"));
459    }
460
461    #[test]
462    fn bootstrap_report_summary_all_present() {
463        let report = BootstrapReport {
464            steps: vec![
465                BootstrapStep {
466                    name: "mise".into(),
467                    status: BootstrapStepStatus::AlreadyInstalled,
468                },
469                BootstrapStep {
470                    name: "deagle".into(),
471                    status: BootstrapStepStatus::AlreadyInstalled,
472                },
473            ],
474        };
475        assert_eq!(report.summary(), "all 2 deps already present");
476    }
477
478    #[test]
479    fn native_tools_constant_is_5_well_known_tools() {
480        // Regression guard: if someone adds or removes a native tool,
481        // they must update BOTH this constant AND the registry entry in
482        // tools/mod.rs. This test catches drift.
483        assert_eq!(NATIVE_TOOLS.len(), 5);
484        assert!(NATIVE_TOOLS.contains(&"rg"));
485        assert!(NATIVE_TOOLS.contains(&"fd"));
486        assert!(NATIVE_TOOLS.contains(&"sd"));
487        assert!(NATIVE_TOOLS.contains(&"ast-grep"));
488        assert!(NATIVE_TOOLS.contains(&"erd"));
489    }
490
491    #[test]
492    fn mise_package_name_handles_binary_name_mismatch() {
493        // `rg` is the binary; `ripgrep` is the mise package. Same for erd
494        // and erdtree. If someone breaks this mapping, mise install fails
495        // silently from the user's POV.
496        assert_eq!(mise_package_name("rg"), "ripgrep");
497        assert_eq!(mise_package_name("erd"), "erdtree");
498        assert_eq!(mise_package_name("fd"), "fd");
499        assert_eq!(mise_package_name("sd"), "sd");
500        assert_eq!(mise_package_name("ast-grep"), "ast-grep");
501        assert_eq!(mise_package_name("sg"), "ast-grep");
502        // Unknown tools fall through unchanged
503        assert_eq!(mise_package_name("unknown-tool"), "unknown-tool");
504    }
505
506    #[test]
507    fn marker_path_is_under_home_dot_pawan() {
508        // Documents where the marker lives so `pawan uninstall` knows
509        // what to remove. If this moves, both locations must update.
510        let path = marker_path();
511        let s = path.to_string_lossy();
512        assert!(s.ends_with(".pawan/.bootstrapped"));
513    }
514
515    #[test]
516    fn ensure_deagle_is_idempotent_when_already_on_path() {
517        // On boxes where deagle is installed, ensure_deagle(false) must
518        // NOT shell out to cargo. This is the idempotency contract.
519        if !binary_exists("deagle") {
520            // Skip on bare boxes — we can't test the branch without a
521            // pre-installed deagle.
522            return;
523        }
524        let step = ensure_deagle(false);
525        assert_eq!(step.name, "deagle");
526        assert_eq!(
527            step.status,
528            BootstrapStepStatus::AlreadyInstalled,
529            "second call must be a no-op when deagle is present"
530        );
531    }
532
533    #[test]
534    fn ensure_mise_is_idempotent_when_already_on_path() {
535        if !binary_exists("mise") {
536            // If mise is at ~/.local/bin/mise but not on PATH, the
537            // fallback branch also returns AlreadyInstalled.
538            let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
539            if !std::path::Path::new(&format!("{}/.local/bin/mise", home)).exists() {
540                return; // bare box — skip
541            }
542        }
543        let step = ensure_mise();
544        assert_eq!(step.name, "mise");
545        assert_eq!(step.status, BootstrapStepStatus::AlreadyInstalled);
546    }
547
548    #[test]
549    fn ensure_native_tool_is_idempotent_when_already_on_path() {
550        // Pick whichever native tool is present on this box.
551        for tool in NATIVE_TOOLS {
552            if binary_exists(tool) {
553                let step = ensure_native_tool(tool);
554                assert_eq!(step.name, *tool);
555                assert_eq!(
556                    step.status,
557                    BootstrapStepStatus::AlreadyInstalled,
558                    "ensure_native_tool({}) must be idempotent",
559                    tool
560                );
561                return;
562            }
563        }
564        // All tools missing — nothing to test.
565    }
566
567    #[serial_test::serial(pawan_session_tests)]
568    #[test]
569    fn missing_deps_is_empty_on_fully_bootstrapped_box() {
570        if !is_bootstrapped() {
571            return;
572        }
573        assert!(
574            missing_deps().is_empty(),
575            "is_bootstrapped() and missing_deps() must agree"
576        );
577    }
578
579    #[test]
580    fn ensure_deps_with_all_skips_writes_empty_report() {
581        // skip_mise + skip_native = no steps attempted. include_deagle
582        // defaults to false (embedded), so no deagle step either.
583        // all_ok must still be true.
584        let opts = BootstrapOptions {
585            skip_mise: true,
586            skip_native: true,
587            include_deagle: false,
588            force_reinstall: false,
589        };
590        let report = ensure_deps(opts);
591        assert_eq!(report.steps.len(), 0);
592        assert!(report.all_ok());
593        assert_eq!(report.installed_count(), 0);
594    }
595
596    #[test]
597    fn default_options_do_not_include_deagle() {
598        // Default bootstrap must NOT try to install deagle — it's embedded
599        // as a library now. This catches any future refactor that flips
600        // the default.
601        let opts = BootstrapOptions::default();
602        assert!(!opts.include_deagle, "default must exclude deagle install");
603        assert!(!opts.skip_mise, "default installs mise");
604        assert!(!opts.skip_native, "default installs native tools");
605        assert!(!opts.force_reinstall);
606    }
607
608    #[test]
609    fn is_bootstrapped_does_not_require_deagle() {
610        // The embedded library means is_bootstrapped() must NOT check
611        // for the deagle binary. This guards against a regression where
612        // someone re-adds the check.
613        // We can't fully test the "true" case without mocking `which`,
614        // but we can assert that the function's behavior doesn't depend
615        // on deagle binary presence: if mise + all native tools are on
616        // PATH, it must be true regardless of deagle.
617        if binary_exists("mise") && NATIVE_TOOLS.iter().all(|t| binary_exists(t)) {
618            assert!(is_bootstrapped());
619        }
620        // Also: missing_deps must not list deagle
621        assert!(
622            !missing_deps().iter().any(|d| d == "deagle"),
623            "missing_deps must not mention deagle"
624        );
625    }
626
627    #[serial_test::serial(pawan_session_tests)]
628    #[test]
629    fn uninstall_without_marker_file_is_ok() {
630        // Calling uninstall on a box without a marker must NOT error —
631        // it's the "nothing to clean up" path.
632        use std::sync::Mutex;
633        static LOCK: Mutex<()> = Mutex::new(());
634        let _guard = LOCK.lock().unwrap();
635
636        // Temporarily redirect HOME so we don't touch the real marker.
637        let tmp = tempfile::TempDir::new().unwrap();
638        let prev_home = std::env::var("HOME").ok();
639        std::env::set_var("HOME", tmp.path());
640
641        let result = uninstall(false);
642
643        if let Some(h) = prev_home {
644            std::env::set_var("HOME", h);
645        }
646
647        assert!(result.is_ok());
648    }
649
650    #[test]
651    fn bootstrap_report_summary_with_installs_only() {
652        let report = BootstrapReport {
653            steps: vec![
654                BootstrapStep {
655                    name: "mise".into(),
656                    status: BootstrapStepStatus::Installed,
657                },
658                BootstrapStep {
659                    name: "rg".into(),
660                    status: BootstrapStepStatus::Installed,
661                },
662            ],
663        };
664        let s = report.summary();
665        assert!(s.contains("2 installed"));
666        assert!(s.contains("0 already present"));
667    }
668
669    #[test]
670    fn missing_deps_lists_missing_mise() {
671        use std::sync::Mutex;
672        static LOCK: Mutex<()> = Mutex::new(());
673        let _guard = LOCK.lock().unwrap();
674
675        let tmp = tempfile::TempDir::new().unwrap();
676        let prev_path = std::env::var("PATH").ok();
677        std::env::set_var("PATH", tmp.path());
678
679        let missing = missing_deps();
680
681        if let Some(p) = prev_path {
682            std::env::set_var("PATH", p);
683        }
684
685        assert!(missing.contains(&"mise".to_string()));
686    }
687
688    #[test]
689    fn missing_deps_lists_missing_native_tools() {
690        use std::sync::Mutex;
691        static LOCK: Mutex<()> = Mutex::new(());
692        let _guard = LOCK.lock().unwrap();
693
694        let tmp = tempfile::TempDir::new().unwrap();
695        let prev_path = std::env::var("PATH").ok();
696        std::env::set_var("PATH", tmp.path());
697
698        let missing = missing_deps();
699
700        if let Some(p) = prev_path {
701            std::env::set_var("PATH", p);
702        }
703
704        for tool in NATIVE_TOOLS {
705            assert!(missing.contains(&tool.to_string()));
706        }
707    }
708
709    #[test]
710    fn ensure_deagle_with_force_reinstall_attempts_install() {
711        use std::sync::Mutex;
712        static LOCK: Mutex<()> = Mutex::new(());
713        let _guard = LOCK.lock().unwrap();
714
715        let tmp = tempfile::TempDir::new().unwrap();
716        let prev_path = std::env::var("PATH").ok();
717        std::env::set_var("PATH", tmp.path());
718
719        let step = ensure_deagle(true);
720
721        if let Some(p) = prev_path {
722            std::env::set_var("PATH", p);
723        }
724
725        assert_eq!(step.name, "deagle");
726        // Will fail in test env, but we verify the attempt was made
727        assert!(matches!(
728            step.status,
729            BootstrapStepStatus::Installed | BootstrapStepStatus::Failed(_)
730        ));
731    }
732
733    #[test]
734    fn ensure_mise_falls_back_to_local_bin() {
735        use std::sync::Mutex;
736        static LOCK: Mutex<()> = Mutex::new(());
737        let _guard = LOCK.lock().unwrap();
738
739        let tmp = tempfile::TempDir::new().unwrap();
740        let prev_path = std::env::var("PATH").ok();
741        std::env::set_var("PATH", tmp.path());
742
743        let home = tmp.path();
744        let local_bin = home.join(".local/bin");
745        std::fs::create_dir_all(&local_bin).unwrap();
746        std::fs::write(local_bin.join("mise"), "#!/bin/sh\necho mise").unwrap();
747
748        let prev_home = std::env::var("HOME").ok();
749        std::env::set_var("HOME", home);
750
751        let step = ensure_mise();
752
753        if let Some(h) = prev_home {
754            std::env::set_var("HOME", h);
755        }
756        if let Some(p) = prev_path {
757            std::env::set_var("PATH", p);
758        }
759
760        assert_eq!(step.name, "mise");
761        assert_eq!(step.status, BootstrapStepStatus::AlreadyInstalled);
762    }
763
764    #[test]
765    fn ensure_native_tool_skips_when_mise_missing() {
766        use std::sync::Mutex;
767        static LOCK: Mutex<()> = Mutex::new(());
768        let _guard = LOCK.lock().unwrap();
769
770        let tmp = tempfile::TempDir::new().unwrap();
771        let prev_path = std::env::var("PATH").ok();
772        std::env::set_var("PATH", tmp.path());
773
774        let home = tmp.path();
775        let prev_home = std::env::var("HOME").ok();
776        std::env::set_var("HOME", home);
777
778        let step = ensure_native_tool("rg");
779
780        if let Some(h) = prev_home {
781            std::env::set_var("HOME", h);
782        }
783        if let Some(p) = prev_path {
784            std::env::set_var("PATH", p);
785        }
786
787        assert_eq!(step.name, "rg");
788        assert!(matches!(step.status, BootstrapStepStatus::Skipped(_)));
789    }
790
791    #[test]
792    fn ensure_native_tool_uses_local_mise() {
793        use std::sync::Mutex;
794        static LOCK: Mutex<()> = Mutex::new(());
795        let _guard = LOCK.lock().unwrap();
796
797        let tmp = tempfile::TempDir::new().unwrap();
798        let prev_path = std::env::var("PATH").ok();
799        std::env::set_var("PATH", tmp.path());
800
801        let home = tmp.path();
802        let local_bin = home.join(".local/bin");
803        std::fs::create_dir_all(&local_bin).unwrap();
804        std::fs::write(local_bin.join("mise"), "#!/bin/sh\necho mise").unwrap();
805
806        let prev_home = std::env::var("HOME").ok();
807        std::env::set_var("HOME", home);
808
809        let step = ensure_native_tool("rg");
810
811        if let Some(h) = prev_home {
812            std::env::set_var("HOME", h);
813        }
814        if let Some(p) = prev_path {
815            std::env::set_var("PATH", p);
816        }
817
818        assert_eq!(step.name, "rg");
819        // Will fail in test env, but we verify it didn't skip
820        assert!(matches!(
821            step.status,
822            BootstrapStepStatus::Installed | BootstrapStepStatus::Failed(_)
823        ));
824    }
825
826    #[serial_test::serial(pawan_session_tests)]
827    #[test]
828    fn ensure_deps_writes_marker_on_success() {
829        use std::sync::Mutex;
830        static LOCK: Mutex<()> = Mutex::new(());
831        let _guard = LOCK.lock().unwrap();
832
833        let tmp = tempfile::TempDir::new().unwrap();
834        let prev_home = std::env::var("HOME").ok();
835        std::env::set_var("HOME", tmp.path());
836
837        let opts = BootstrapOptions {
838            skip_mise: true,
839            skip_native: true,
840            include_deagle: false,
841            force_reinstall: false,
842        };
843        let report = ensure_deps(opts);
844
845        if let Some(h) = prev_home {
846            std::env::set_var("HOME", h);
847        }
848
849        assert!(report.all_ok());
850        let marker = tmp.path().join(".pawan/.bootstrapped");
851        assert!(marker.exists(), "marker must be written on success");
852    }
853
854    #[serial_test::serial(pawan_session_tests)]
855    #[test]
856    fn ensure_deps_includes_deagle_when_requested() {
857        use std::sync::Mutex;
858        static LOCK: Mutex<()> = Mutex::new(());
859        let _guard = LOCK.lock().unwrap();
860
861        let tmp = tempfile::TempDir::new().unwrap();
862        let prev_home = std::env::var("HOME").ok();
863        std::env::set_var("HOME", tmp.path());
864
865        let opts = BootstrapOptions {
866            skip_mise: true,
867            skip_native: true,
868            include_deagle: true,
869            force_reinstall: false,
870        };
871        let report = ensure_deps(opts);
872
873        if let Some(h) = prev_home {
874            std::env::set_var("HOME", h);
875        }
876
877        assert_eq!(report.steps.len(), 1);
878        assert_eq!(report.steps[0].name, "deagle");
879    }
880
881    #[serial_test::serial(pawan_session_tests)]
882    #[test]
883    fn ensure_deps_with_force_reinstall() {
884        use std::sync::Mutex;
885        static LOCK: Mutex<()> = Mutex::new(());
886        let _guard = LOCK.lock().unwrap();
887
888        let tmp = tempfile::TempDir::new().unwrap();
889        let prev_home = std::env::var("HOME").ok();
890        std::env::set_var("HOME", tmp.path());
891
892        let opts = BootstrapOptions {
893            skip_mise: true,
894            skip_native: true,
895            include_deagle: true,
896            force_reinstall: true,
897        };
898        let report = ensure_deps(opts);
899
900        if let Some(h) = prev_home {
901            std::env::set_var("HOME", h);
902        }
903
904        assert_eq!(report.steps.len(), 1);
905        assert_eq!(report.steps[0].name, "deagle");
906    }
907
908    #[serial_test::serial(pawan_session_tests)]
909    #[test]
910    fn uninstall_removes_marker_file() {
911        use std::sync::Mutex;
912        static LOCK: Mutex<()> = Mutex::new(());
913        let _guard = LOCK.lock().unwrap();
914
915        let tmp = tempfile::TempDir::new().unwrap();
916        let prev_home = std::env::var("HOME").ok();
917        std::env::set_var("HOME", tmp.path());
918
919        let marker = tmp.path().join(".pawan/.bootstrapped");
920        std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
921        std::fs::write(&marker, "2024-01-01T00:00:00Z").unwrap();
922
923        let result = uninstall(false);
924
925        if let Some(h) = prev_home {
926            std::env::set_var("HOME", h);
927        }
928
929        assert!(result.is_ok());
930        assert!(!marker.exists(), "marker must be removed");
931    }
932
933    #[serial_test::serial(pawan_session_tests)]
934    #[test]
935    fn uninstall_with_purge_deagle_attempts_uninstall() {
936        use std::sync::Mutex;
937        static LOCK: Mutex<()> = Mutex::new(());
938        let _guard = LOCK.lock().unwrap();
939
940        let tmp = tempfile::TempDir::new().unwrap();
941        let prev_home = std::env::var("HOME").ok();
942        std::env::set_var("HOME", tmp.path());
943
944        let marker = tmp.path().join(".pawan/.bootstrapped");
945        std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
946        std::fs::write(&marker, "2024-01-01T00:00:00Z").unwrap();
947
948        let result = uninstall(true);
949
950        if let Some(h) = prev_home {
951            std::env::set_var("HOME", h);
952        }
953
954        // Will fail if deagle not installed, but we verify the attempt
955        assert!(result.is_ok() || result.is_err());
956        assert!(!marker.exists(), "marker must be removed regardless");
957    }
958}