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) => {
202            BootstrapStepStatus::Failed(format!("cargo install deagle spawn failed: {}", e))
203        }
204    };
205
206    BootstrapStep {
207        name: "deagle".into(),
208        status,
209    }
210}
211
212/// Install mise via `cargo install --locked mise`. Idempotent — skipped
213/// if mise is already on PATH or at `~/.local/bin/mise` (mise's default
214/// install location, which may not yet be on PATH).
215///
216/// We prefer `cargo install` over the curl-pipe-shell installer because
217/// (a) cargo is already present for any user who got pawan via
218/// `cargo install pawan`, and (b) cargo install is a known,
219/// signed-binary path — no remote shell script trust required.
220///
221/// Note: `mise` itself is a binary-only crate on crates.io (no lib
222/// target), so pawan cannot `use mise::...` directly. Bootstrap is the
223/// only remaining integration surface.
224pub fn ensure_mise() -> BootstrapStep {
225    if binary_exists("mise") {
226        return BootstrapStep {
227            name: "mise".into(),
228            status: BootstrapStepStatus::AlreadyInstalled,
229        };
230    }
231    // Fallback: mise installs into ~/.local/bin/ which isn't always on
232    // PATH in non-interactive shells. Detect the raw file.
233    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
262/// Install a native tool via mise. Requires mise to already be on PATH
263/// (or at `~/.local/bin/mise`) — returns Skipped otherwise so the caller
264/// can decide how to surface that.
265pub 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            // Also run `mise use --global` so the tool is on PATH for
296            // subsequent processes.
297            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
318/// Run the full bootstrap sequence per the options. On success (`all_ok`
319/// returns true), writes a marker file at [`marker_path`] containing the
320/// install timestamp.
321///
322/// Default (all opts false): installs mise and native tools. Deagle is
323/// NOT installed by default — pawan embeds deagle-core + deagle-parse
324/// as library deps, so the standalone CLI is only needed for
325/// interactive shell use. Set `include_deagle = true` to opt in.
326pub 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    // Write the marker only if the run completed without any Failed step.
342    // Skipped steps are OK — they're caller-requested.
343    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
354/// Reverse the bootstrap: remove the marker file, and optionally run
355/// `cargo uninstall deagle`. Deliberately does NOT uninstall mise or
356/// mise-managed tools — those may be used by other programs.
357pub 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        // An empty report has no failures, so all_ok() must be true.
387        // Used when skip_mise=skip_deagle=skip_native=true.
388        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        // Skipped means "caller asked us not to do this" — not an error.
409        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        // All three categories exercised at once.
443        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        // Regression guard: if someone adds or removes a native tool,
485        // they must update BOTH this constant AND the registry entry in
486        // tools/mod.rs. This test catches drift.
487        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        // `rg` is the binary; `ripgrep` is the mise package. Same for erd
498        // and erdtree. If someone breaks this mapping, mise install fails
499        // silently from the user's POV.
500        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        // Unknown tools fall through unchanged
507        assert_eq!(mise_package_name("unknown-tool"), "unknown-tool");
508    }
509
510    #[test]
511    fn marker_path_is_under_home_dot_pawan() {
512        // Documents where the marker lives so `pawan uninstall` knows
513        // what to remove. If this moves, both locations must update.
514        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        // On boxes where deagle is installed, ensure_deagle(false) must
522        // NOT shell out to cargo. This is the idempotency contract.
523        if !binary_exists("deagle") {
524            // Skip on bare boxes — we can't test the branch without a
525            // pre-installed deagle.
526            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            // If mise is at ~/.local/bin/mise but not on PATH, the
541            // fallback branch also returns AlreadyInstalled.
542            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; // bare box — skip
545            }
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        // Pick whichever native tool is present on this box.
555        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        // All tools missing — nothing to test.
569    }
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        // skip_mise + skip_native = no steps attempted. include_deagle
585        // defaults to false (embedded), so no deagle step either.
586        // all_ok must still be true.
587        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        // Default bootstrap must NOT try to install deagle — it's embedded
602        // as a library now. This catches any future refactor that flips
603        // the default.
604        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        // The embedded library means is_bootstrapped() must NOT check
614        // for the deagle binary. This guards against a regression where
615        // someone re-adds the check.
616        // We can't fully test the "true" case without mocking `which`,
617        // but we can assert that the function's behavior doesn't depend
618        // on deagle binary presence: if mise + all native tools are on
619        // PATH, it must be true regardless of deagle.
620        if binary_exists("mise") && NATIVE_TOOLS.iter().all(|t| binary_exists(t)) {
621            assert!(is_bootstrapped());
622        }
623        // Also: missing_deps must not list deagle
624        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        // Calling uninstall on a box without a marker must NOT error —
633        // it's the "nothing to clean up" path.
634        use std::sync::Mutex;
635        static LOCK: Mutex<()> = Mutex::new(());
636        let _guard = LOCK.lock().unwrap();
637
638        // Temporarily redirect HOME so we don't touch the real marker.
639        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}