vanta-shim 0.0.20

The Vanta shim dispatcher: resolves the per-directory tool version and execs the real binary.
Documentation
//! `vanta-shim` — the per-tool dispatcher (see `docs/10-environments.md`).
//!
//! Installed under each tool's name in `~/.vanta/bin`. When invoked it finds the
//! tool that its `argv[0]` names in the active generation, locates the real
//! binary in the store, and `exec`s it. (Per-directory switching via a
//! resolution cache is a later refinement; this cut dispatches from the active
//! generation.)
#![forbid(unsafe_code)]

use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
use vanta_core::{Platform, StoreKey};
use vanta_lock::Lock;
use vanta_state::State;

/// Outcome of looking `name` up in the nearest project `vanta.lock`.
enum LockResolution {
    /// The tool is managed here and its binary is on disk.
    Bin(PathBuf),
    /// A `vanta.lock` pins this tool but the version isn't installed.
    PinnedButMissing(String),
    /// No `vanta.lock` up the tree manages this tool.
    NotManaged,
}

fn basename(p: &str) -> &str {
    p.rsplit(['/', '\\']).next().unwrap_or(p)
}

/// Walk up from the current directory; the first `vanta.lock` that manages
/// `name` (by tool name or a linked bin) decides the version. This is the
/// per-directory switching mechanism.
fn resolve_from_lock(home: &Path, name: &str) -> LockResolution {
    let plat = Platform::current().token();
    let mut dir = std::env::current_dir().ok();
    while let Some(d) = dir {
        let lock_path = d.join("vanta.lock");
        if lock_path.is_file() {
            if let Ok(lock) = Lock::load_file(&lock_path) {
                for tool in &lock.tools {
                    let pin = tool.platform.get(&plat);
                    let manages = tool.name == name
                        || pin.is_some_and(|p| p.bin.iter().any(|b| basename(b) == name));
                    if !manages {
                        continue;
                    }
                    // This lock owns the tool. If it isn't built for us, say so
                    // rather than silently falling back to a different version.
                    let Some(pin) = pin.filter(|p| !p.store_key.is_empty()) else {
                        return LockResolution::PinnedButMissing(tool.version.clone());
                    };
                    // Validate the key shape (L12/M7) before joining it onto the
                    // store path, so a hand-edited lock cannot traverse out.
                    let Ok(key) = StoreKey::new(pin.store_key.clone()) else {
                        return LockResolution::PinnedButMissing(tool.version.clone());
                    };
                    let rel = pin
                        .bin
                        .iter()
                        .find(|b| basename(b) == name)
                        .cloned()
                        .unwrap_or_else(|| name.to_string());
                    // The bin path must stay inside the store entry.
                    if Path::new(&rel).components().any(|c| {
                        matches!(
                            c,
                            std::path::Component::ParentDir
                                | std::path::Component::RootDir
                                | std::path::Component::Prefix(_)
                        )
                    }) {
                        return LockResolution::NotManaged;
                    }
                    let path = home.join("store").join(key.as_str()).join(&rel);
                    if path.is_file() {
                        return LockResolution::Bin(path);
                    }
                    return LockResolution::PinnedButMissing(tool.version.clone());
                }
            }
        }
        dir = d.parent().map(Path::to_path_buf);
    }
    LockResolution::NotManaged
}

/// Entry point: derive the tool name from `argv[0]`, dispatch, and map failures
/// to an error exit code. The binary's `main` is a thin wrapper over this.
#[must_use]
pub fn run() -> ExitCode {
    let invoked = std::env::args()
        .next()
        .and_then(|p| {
            Path::new(&p)
                .file_name()
                .map(|s| s.to_string_lossy().into_owned())
        })
        .unwrap_or_default();
    let name = invoked.strip_suffix(".exe").unwrap_or(&invoked).to_string();
    let args: Vec<String> = std::env::args().skip(1).collect();

    match dispatch(&name, &args) {
        Ok(code) => code,
        Err(msg) => {
            eprintln!("vanta-shim: {msg}");
            ExitCode::from(1)
        }
    }
}

/// Resolve `name` and `exec` its real binary with `args`. Resolution order:
///  1. **Per-directory**: the nearest `vanta.lock` walking up from the current
///     directory — this is what gives project-local tool versions (like mise).
///  2. **Fallback**: the global active generation.
///
/// On unix `exec` replaces the process; it only returns on failure.
pub fn dispatch(name: &str, args: &[String]) -> Result<ExitCode, String> {
    let home = home().ok_or("cannot determine VANTA_HOME")?;

    // (1) Project-local: the nearest vanta.lock that manages this tool wins.
    match resolve_from_lock(&home, name) {
        LockResolution::Bin(bin) => return exec(&bin, args),
        LockResolution::PinnedButMissing(version) => {
            return Err(format!(
                "`{name}` is pinned to {version} here but not installed — run `vanta sync`"
            ));
        }
        LockResolution::NotManaged => {} // fall through to the global generation
    }

    let state = State::open(&home.join("state.db")).map_err(|e| e.to_string())?;
    let id = state
        .current()
        .map_err(|e| e.to_string())?
        .ok_or("no active generation")?;
    let generation = state
        .get_generation(id)
        .map_err(|e| e.to_string())?
        .ok_or("active generation is missing")?;
    let (_, key) = generation
        .tools
        .iter()
        .find(|(tool, _)| tool == name)
        .ok_or_else(|| format!("`{name}` is not managed by vanta"))?;
    // L12/M7: validate the key shape before joining it onto the store path so a
    // malformed generation record cannot traverse out of the store.
    let key = StoreKey::new(key.clone()).map_err(|e| e.to_string())?;
    let entry = home.join("store").join(key.as_str());
    let bin = find_bin(&entry, name)
        .ok_or_else(|| format!("executable for `{name}` not found in {}", entry.display()))?;
    exec(&bin, args)
}

fn find_bin(entry: &Path, name: &str) -> Option<PathBuf> {
    let candidates = [
        entry.join("bin").join(name),
        entry.join(name),
        entry.join("bin").join(format!("{name}.exe")),
        entry.join(format!("{name}.exe")),
    ];
    candidates.into_iter().find(|c| c.is_file())
}

#[cfg(unix)]
fn exec(bin: &Path, args: &[String]) -> Result<ExitCode, String> {
    use std::os::unix::process::CommandExt;
    // `exec` replaces this process and only returns on failure.
    let err = Command::new(bin).args(args).exec();
    Err(format!("exec {}: {err}", bin.display()))
}

#[cfg(not(unix))]
fn exec(bin: &Path, args: &[String]) -> Result<ExitCode, String> {
    let status = Command::new(bin)
        .args(args)
        .status()
        .map_err(|e| format!("running {}: {e}", bin.display()))?;
    Ok(ExitCode::from(status.code().unwrap_or(1) as u8))
}

fn home() -> Option<PathBuf> {
    if let Ok(h) = std::env::var("VANTA_HOME") {
        return Some(PathBuf::from(h));
    }
    std::env::var("HOME")
        .or_else(|_| std::env::var("USERPROFILE"))
        .ok()
        .map(|base| PathBuf::from(base).join(".vanta"))
}