Skip to main content

vanta_env/
lib.rs

1//! `vanta-env` — compose environment views and generate shell activation hooks.
2//!
3//! An environment is a `envs/<env-id>/bin` directory of links into the store; the
4//! `env-id` is a hash of the resolved tool set, so identical sets share a view.
5//! Activation hooks (per shell) put that bin directory on `PATH` and switch it
6//! per directory. See `docs/10-environments.md`.
7#![forbid(unsafe_code)]
8
9use std::path::{Path, PathBuf};
10use vanta_core::{StoreKey, VtaResult};
11use vanta_store::{hash_bytes, link_best, Store};
12
13/// One tool's contribution to an environment: its store key and the executables
14/// to expose (paths relative to the store entry).
15#[derive(Debug, Clone, PartialEq)]
16pub struct EnvTool {
17    pub tool: String,
18    pub key: StoreKey,
19    pub bins: Vec<String>,
20}
21
22/// A stable identifier for a resolved tool set (the hash of its sorted members).
23pub fn env_id(tools: &[EnvTool]) -> String {
24    let mut members: Vec<String> = tools
25        .iter()
26        .map(|t| format!("{}={}", t.tool, t.key.as_str()))
27        .collect();
28    members.sort();
29    let blob = members.join("\n");
30    // `hash_bytes` returns `blake3-<hex>`; use the hex as a compact directory id.
31    hash_bytes(blob.as_bytes())
32        .strip_prefix("blake3-")
33        .unwrap_or("env")
34        .chars()
35        .take(32)
36        .collect()
37}
38
39/// Compose (or refresh) the environment view for `tools`, returning its `bin`
40/// directory. Executables are linked from the store with the cheapest available
41/// strategy (`docs/09-store.md`).
42pub fn compose(store: &Store, home: &Path, tools: &[EnvTool]) -> VtaResult<PathBuf> {
43    let id = env_id(tools);
44    let bin_dir = home.join("envs").join(&id).join("bin");
45    std::fs::create_dir_all(&bin_dir).map_err(|e| {
46        vanta_core::VtaError::new(
47            vanta_core::Area::Env,
48            1,
49            format!("creating env dir {}: {e}", bin_dir.display()),
50        )
51    })?;
52    for tool in tools {
53        let entry = store.entry_path(&tool.key);
54        for bin in &tool.bins {
55            let src = entry.join(bin);
56            let dst = bin_dir.join(basename(bin));
57            link_best(&src, &dst)?;
58        }
59    }
60    Ok(bin_dir)
61}
62
63/// The activation hook for a shell, for `eval "$(vanta activate <shell>)"`.
64/// Returns `None` for an unsupported shell.
65pub fn activate_hook(shell: &str) -> Option<String> {
66    let hook = match shell {
67        "bash" => BASH,
68        "zsh" => ZSH,
69        "fish" => FISH,
70        "pwsh" | "powershell" => PWSH,
71        _ => return None,
72    };
73    Some(hook.to_string())
74}
75
76fn basename(p: &str) -> String {
77    p.rsplit(['/', '\\']).next().unwrap_or(p).to_string()
78}
79
80// Activation hooks place `~/.vanta/bin` on PATH idempotently (installed tools are
81// linked there). Per-directory switching is described in `docs/10-environments.md`.
82
83const BASH: &str = r#"# vanta shell hook (bash)
84case ":$PATH:" in *":$HOME/.vanta/bin:"*) ;; *) export PATH="$HOME/.vanta/bin:$PATH";; esac
85"#;
86
87const ZSH: &str = r#"# vanta shell hook (zsh)
88case ":$PATH:" in *":$HOME/.vanta/bin:"*) ;; *) export PATH="$HOME/.vanta/bin:$PATH";; esac
89"#;
90
91const FISH: &str = r#"# vanta shell hook (fish)
92if not contains "$HOME/.vanta/bin" $PATH
93    set -gx PATH "$HOME/.vanta/bin" $PATH
94end
95"#;
96
97const PWSH: &str = r#"# vanta shell hook (PowerShell)
98if ($env:PATH -notlike "*$HOME\.vanta\bin*") { $env:PATH = "$HOME\.vanta\bin;$env:PATH" }
99"#;
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn env_id_is_order_independent_and_stable() {
107        let a = EnvTool {
108            tool: "node".into(),
109            key: StoreKey::new(format!("blake3-{}", "a".repeat(64))).unwrap(),
110            bins: vec![],
111        };
112        let b = EnvTool {
113            tool: "go".into(),
114            key: StoreKey::new(format!("blake3-{}", "b".repeat(64))).unwrap(),
115            bins: vec![],
116        };
117        let id1 = env_id(&[a.clone(), b.clone()]);
118        let id2 = env_id(&[b, a]);
119        assert_eq!(id1, id2);
120        assert!(!id1.is_empty());
121    }
122
123    #[test]
124    fn activate_known_and_unknown() {
125        assert!(activate_hook("zsh").unwrap().contains("vanta"));
126        assert!(activate_hook("bash").is_some());
127        assert!(activate_hook("tcsh").is_none());
128    }
129
130    #[test]
131    fn compose_links_bins() {
132        let home = std::env::temp_dir().join(format!("vanta-env-{}", std::process::id()));
133        let _ = std::fs::remove_dir_all(&home);
134        let store = Store::open(&home).unwrap();
135        let staged = store.new_staging().unwrap();
136        std::fs::create_dir_all(staged.join("bin")).unwrap();
137        std::fs::write(staged.join("bin/node"), b"#!/bin/true").unwrap();
138        let key = store.publish_tree(&staged).unwrap();
139
140        let tools = vec![EnvTool {
141            tool: "node".into(),
142            key,
143            bins: vec!["bin/node".into()],
144        }];
145        let bin_dir = compose(&store, &home, &tools).unwrap();
146        assert!(bin_dir.join("node").exists());
147        let _ = std::fs::remove_dir_all(&home);
148    }
149}