use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::process::EnvMode;
pub(crate) const ENABLE_ENV: &str = "HARN_RUN_TOOLCHAIN_PATH";
const RUBY_VERSION_FILE: &str = ".ruby-version";
const NODE_VERSION_FILE: &str = ".nvmrc";
const TOOL_VERSIONS_FILE: &str = ".tool-versions";
const MISE_TOML_FILE: &str = ".mise.toml";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Declaration {
pub(crate) tool: String,
pub(crate) version: String,
pub(crate) source_file: String,
}
pub(crate) trait Env {
fn read_file(&self, path: &Path) -> Option<String>;
fn resolver_available(&self, resolver: &str) -> bool;
fn resolve_bin_dir(&self, resolver: &str, decl: &Declaration) -> Option<PathBuf>;
fn var(&self, key: &str) -> Option<String>;
}
pub(crate) fn enabled(env: &dyn Env) -> bool {
match env.var(ENABLE_ENV) {
Some(value) => matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "on" | "true" | "auto" | "yes"
),
None => false,
}
}
fn find_declaration_root(env: &dyn Env, start: &Path) -> Option<PathBuf> {
let mut current = Some(start);
while let Some(dir) = current {
for file in [
TOOL_VERSIONS_FILE,
MISE_TOML_FILE,
RUBY_VERSION_FILE,
NODE_VERSION_FILE,
] {
if env.read_file(&dir.join(file)).is_some() {
return Some(dir.to_path_buf());
}
}
current = dir.parent();
}
None
}
fn parse_declarations(env: &dyn Env, root: &Path) -> Vec<Declaration> {
let mut out: Vec<Declaration> = Vec::new();
let mut seen_tools: Vec<String> = Vec::new();
let mut push = |tool: String, version: String, source: &str| {
if tool.is_empty() || version.is_empty() {
return;
}
if seen_tools.iter().any(|t| t == &tool) {
return;
}
seen_tools.push(tool.clone());
out.push(Declaration {
tool,
version,
source_file: source.to_string(),
});
};
if let Some(contents) = env.read_file(&root.join(TOOL_VERSIONS_FILE)) {
for line in contents.lines() {
let line = line.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let mut parts = line.split_whitespace();
if let (Some(tool), Some(version)) = (parts.next(), parts.next()) {
push(tool.to_string(), version.to_string(), TOOL_VERSIONS_FILE);
}
}
}
if let Some(contents) = env.read_file(&root.join(MISE_TOML_FILE)) {
let mut in_tools = false;
for line in contents.lines() {
let trimmed = line.split('#').next().unwrap_or("").trim();
if trimmed.is_empty() {
continue;
}
if trimmed.starts_with('[') {
in_tools = trimmed == "[tools]";
continue;
}
if !in_tools {
continue;
}
if let Some((tool, rest)) = trimmed.split_once('=') {
let tool = tool.trim();
let version = rest.trim().trim_matches(['"', '\'']).trim();
push(tool.to_string(), version.to_string(), MISE_TOML_FILE);
}
}
}
if let Some(contents) = env.read_file(&root.join(RUBY_VERSION_FILE)) {
let version = first_bare_version(&contents);
push("ruby".to_string(), version, RUBY_VERSION_FILE);
}
if let Some(contents) = env.read_file(&root.join(NODE_VERSION_FILE)) {
let version = first_bare_version(&contents);
let version = version.trim_start_matches('v').to_string();
push("node".to_string(), version, NODE_VERSION_FILE);
}
out
}
fn first_bare_version(contents: &str) -> String {
contents
.lines()
.map(|line| line.split('#').next().unwrap_or("").trim())
.find(|line| !line.is_empty())
.map(|line| line.split_whitespace().next().unwrap_or("").to_string())
.unwrap_or_default()
}
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct Normalization {
pub(crate) prepend_dirs: Vec<PathBuf>,
pub(crate) unresolved: Vec<Declaration>,
}
impl Normalization {
pub(crate) fn is_noop(&self) -> bool {
self.prepend_dirs.is_empty()
}
}
pub(crate) fn compute(env: &dyn Env, cwd: &Path) -> Normalization {
let mut result = Normalization::default();
if !enabled(env) {
return result;
}
let Some(root) = find_declaration_root(env, cwd) else {
return result;
};
let declarations = parse_declarations(env, &root);
if declarations.is_empty() {
return result;
}
let resolver = if env.resolver_available("mise") {
Some("mise")
} else if env.resolver_available("asdf") {
Some("asdf")
} else {
None
};
let Some(resolver) = resolver else {
for decl in &declarations {
tracing::info!(
tool = %decl.tool,
version = %decl.version,
source = %decl.source_file,
"run() toolchain-path: {} {} declared in {} but no mise/asdf on PATH to resolve it; leaving PATH untouched",
decl.tool,
decl.version,
decl.source_file,
);
}
result.unresolved = declarations;
return result;
};
for decl in declarations {
match env.resolve_bin_dir(resolver, &decl) {
Some(bin_dir) => {
tracing::info!(
tool = %decl.tool,
version = %decl.version,
source = %decl.source_file,
bin_dir = %bin_dir.display(),
"run() toolchain-path: using {} {} from {} ({} bin {})",
decl.tool,
decl.version,
decl.source_file,
resolver,
bin_dir.display(),
);
result.prepend_dirs.push(bin_dir);
}
None => {
tracing::info!(
tool = %decl.tool,
version = %decl.version,
source = %decl.source_file,
"run() toolchain-path: {} {} declared in {} but {} could not resolve an existing install; leaving PATH untouched",
decl.tool,
decl.version,
decl.source_file,
resolver,
);
result.unresolved.push(decl);
}
}
}
result
}
pub(crate) fn apply_to_env(
env_map: &mut BTreeMap<String, String>,
env_mode: EnvMode,
parent_path: Option<&str>,
normalization: &Normalization,
) {
if normalization.is_noop() {
return;
}
let prefix = normalization
.prepend_dirs
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(path_separator());
let caller_path = env_map.get("PATH").cloned();
let base = match env_mode {
EnvMode::Replace => caller_path,
EnvMode::InheritClean | EnvMode::Patch => {
caller_path.or_else(|| parent_path.map(str::to_string))
}
};
let new_path = match base {
Some(base) if !base.is_empty() => format!("{prefix}{}{base}", path_separator()),
_ => prefix,
};
env_map.insert("PATH".to_string(), new_path);
}
fn path_separator() -> &'static str {
if cfg!(windows) {
";"
} else {
":"
}
}
pub(crate) struct RealEnv;
impl Env for RealEnv {
fn read_file(&self, path: &Path) -> Option<String> {
std::fs::read_to_string(path).ok()
}
fn resolver_available(&self, resolver: &str) -> bool {
which_on_path(resolver).is_some()
}
fn resolve_bin_dir(&self, resolver: &str, decl: &Declaration) -> Option<PathBuf> {
let resolver_bin = which_on_path(resolver)?;
let tool_arg = format!("{}@{}", decl.tool, decl.version);
let output = match resolver {
"mise" => std::process::Command::new(&resolver_bin)
.arg("where")
.arg(&tool_arg)
.output()
.ok()?,
"asdf" => std::process::Command::new(&resolver_bin)
.arg("where")
.arg(&decl.tool)
.arg(&decl.version)
.output()
.ok()?,
_ => return None,
};
if !output.status.success() {
return None;
}
let install_dir = String::from_utf8_lossy(&output.stdout).trim().to_string();
if install_dir.is_empty() {
return None;
}
let bin_dir = PathBuf::from(install_dir).join("bin");
if bin_dir.is_dir() {
Some(bin_dir)
} else {
None
}
}
fn var(&self, key: &str) -> Option<String> {
std::env::var(key).ok()
}
}
fn which_on_path(name: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let candidate = dir.join(name);
if candidate.is_file() {
return Some(candidate);
}
#[cfg(windows)]
{
let exe = dir.join(format!("{name}.exe"));
if exe.is_file() {
return Some(exe);
}
}
}
None
}
pub(crate) fn normalize_child_env(
cwd: &Path,
env_map: &mut BTreeMap<String, String>,
env_mode: EnvMode,
) {
let env = RealEnv;
if !enabled(&env) {
return;
}
let normalization = compute(&env, cwd);
let parent_path = std::env::var("PATH").ok();
apply_to_env(env_map, env_mode, parent_path.as_deref(), &normalization);
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::collections::HashMap;
#[derive(Default)]
struct FakeEnv {
files: HashMap<PathBuf, String>,
resolvers: Vec<String>,
bins: HashMap<String, PathBuf>,
vars: HashMap<String, String>,
resolve_calls: RefCell<Vec<String>>,
}
impl FakeEnv {
fn with_gate_on() -> Self {
let mut env = FakeEnv::default();
env.vars.insert(ENABLE_ENV.to_string(), "1".to_string());
env
}
fn file(mut self, path: &str, contents: &str) -> Self {
self.files.insert(PathBuf::from(path), contents.to_string());
self
}
fn resolver(mut self, name: &str) -> Self {
self.resolvers.push(name.to_string());
self
}
fn bin(mut self, key: &str, dir: &str) -> Self {
self.bins.insert(key.to_string(), PathBuf::from(dir));
self
}
}
impl Env for FakeEnv {
fn read_file(&self, path: &Path) -> Option<String> {
self.files.get(path).cloned()
}
fn resolver_available(&self, resolver: &str) -> bool {
self.resolvers.iter().any(|r| r == resolver)
}
fn resolve_bin_dir(&self, resolver: &str, decl: &Declaration) -> Option<PathBuf> {
let key = format!("{resolver}:{}@{}", decl.tool, decl.version);
self.resolve_calls.borrow_mut().push(key.clone());
self.bins.get(&key).cloned()
}
fn var(&self, key: &str) -> Option<String> {
self.vars.get(key).cloned()
}
}
#[test]
fn no_declaration_is_a_byte_for_byte_noop() {
let env = FakeEnv::with_gate_on().resolver("mise");
let norm = compute(&env, Path::new("/work/clean-repo"));
assert!(norm.is_noop());
assert!(norm.prepend_dirs.is_empty());
assert!(norm.unresolved.is_empty());
let mut env_map: BTreeMap<String, String> = BTreeMap::new();
env_map.insert("FOO".to_string(), "bar".to_string());
let before = env_map.clone();
apply_to_env(
&mut env_map,
EnvMode::InheritClean,
Some("/usr/bin:/bin"),
&norm,
);
assert_eq!(env_map, before, "no declaration must not touch PATH");
assert!(!env_map.contains_key("PATH"));
}
#[test]
fn disabled_gate_is_a_noop_even_with_declaration() {
let env = FakeEnv::default()
.file("/work/app/.tool-versions", "ruby 3.2.2\n")
.resolver("mise")
.bin("mise:ruby@3.2.2", "/opt/mise/ruby/3.2.2");
let norm = compute(&env, Path::new("/work/app"));
assert!(norm.is_noop());
assert!(env.resolve_calls.borrow().is_empty());
}
#[test]
fn declared_and_installed_prepends_resolved_bin_dir() {
let env = FakeEnv::with_gate_on()
.file("/work/app/.tool-versions", "ruby 3.2.2\nnodejs 20.11.0\n")
.resolver("mise")
.bin("mise:ruby@3.2.2", "/opt/mise/installs/ruby/3.2.2")
.bin("mise:nodejs@20.11.0", "/opt/mise/installs/node/20.11.0");
let norm = compute(&env, Path::new("/work/app"));
assert_eq!(
norm.prepend_dirs,
vec![
PathBuf::from("/opt/mise/installs/ruby/3.2.2"),
PathBuf::from("/opt/mise/installs/node/20.11.0"),
]
);
assert!(norm.unresolved.is_empty());
let mut env_map = BTreeMap::new();
apply_to_env(
&mut env_map,
EnvMode::InheritClean,
Some("/usr/bin:/bin"),
&norm,
);
assert_eq!(
env_map.get("PATH").map(String::as_str),
Some("/opt/mise/installs/ruby/3.2.2:/opt/mise/installs/node/20.11.0:/usr/bin:/bin")
);
}
#[test]
fn declared_but_not_installed_is_noop_with_diagnostic() {
let env = FakeEnv::with_gate_on()
.file("/work/app/.ruby-version", "3.2.2\n")
.resolver("mise");
let norm = compute(&env, Path::new("/work/app"));
assert!(norm.is_noop(), "phantom path must not be prepended");
assert_eq!(
norm.unresolved,
vec![Declaration {
tool: "ruby".to_string(),
version: "3.2.2".to_string(),
source_file: ".ruby-version".to_string(),
}]
);
assert_eq!(
env.resolve_calls.borrow().as_slice(),
&["mise:ruby@3.2.2".to_string()]
);
let mut env_map = BTreeMap::new();
apply_to_env(&mut env_map, EnvMode::Patch, Some("/usr/bin"), &norm);
assert!(!env_map.contains_key("PATH"));
}
#[test]
fn no_resolver_installed_is_noop_with_diagnostic() {
let env = FakeEnv::with_gate_on().file("/work/app/.nvmrc", "v20.11.0\n");
let norm = compute(&env, Path::new("/work/app"));
assert!(norm.is_noop());
assert_eq!(norm.unresolved.len(), 1);
assert_eq!(norm.unresolved[0].tool, "node");
assert_eq!(norm.unresolved[0].version, "20.11.0", "nvmrc `v` stripped");
assert!(env.resolve_calls.borrow().is_empty());
}
#[test]
fn walks_up_to_find_declaration_root() {
let env = FakeEnv::with_gate_on()
.file("/work/app/.tool-versions", "ruby 3.2.2\n")
.resolver("mise")
.bin("mise:ruby@3.2.2", "/opt/ruby/3.2.2");
let norm = compute(&env, Path::new("/work/app/lib/deep/nested"));
assert_eq!(norm.prepend_dirs, vec![PathBuf::from("/opt/ruby/3.2.2")]);
}
#[test]
fn asdf_used_when_mise_absent() {
let env = FakeEnv::with_gate_on()
.file("/work/app/.tool-versions", "ruby 3.2.2\n")
.resolver("asdf")
.bin("asdf:ruby@3.2.2", "/home/u/.asdf/installs/ruby/3.2.2");
let norm = compute(&env, Path::new("/work/app"));
assert_eq!(
norm.prepend_dirs,
vec![PathBuf::from("/home/u/.asdf/installs/ruby/3.2.2")]
);
assert_eq!(
env.resolve_calls.borrow().as_slice(),
&["asdf:ruby@3.2.2".to_string()]
);
}
#[test]
fn mise_toml_tools_table_is_parsed() {
let toml =
"[settings]\nexperimental = true\n\n[tools]\nruby = \"3.2.2\"\nnode = '20.11.0'\n";
let env = FakeEnv::with_gate_on()
.file("/work/app/.mise.toml", toml)
.resolver("mise")
.bin("mise:ruby@3.2.2", "/opt/ruby/3.2.2")
.bin("mise:node@20.11.0", "/opt/node/20.11.0");
let norm = compute(&env, Path::new("/work/app"));
assert_eq!(
norm.prepend_dirs,
vec![
PathBuf::from("/opt/ruby/3.2.2"),
PathBuf::from("/opt/node/20.11.0"),
]
);
}
#[test]
fn replace_mode_with_caller_path_prepends() {
let norm = Normalization {
prepend_dirs: vec![PathBuf::from("/opt/ruby/bin")],
unresolved: vec![],
};
let mut env_map = BTreeMap::new();
env_map.insert("PATH".to_string(), "/sbin".to_string());
apply_to_env(
&mut env_map,
EnvMode::Replace,
Some("/parent/ignored"),
&norm,
);
assert_eq!(
env_map.get("PATH").map(String::as_str),
Some("/opt/ruby/bin:/sbin")
);
}
#[test]
fn replace_mode_without_caller_path_sets_only_toolchain() {
let norm = Normalization {
prepend_dirs: vec![PathBuf::from("/opt/ruby/bin")],
unresolved: vec![],
};
let mut env_map = BTreeMap::new();
apply_to_env(
&mut env_map,
EnvMode::Replace,
Some("/parent/ignored"),
&norm,
);
assert_eq!(
env_map.get("PATH").map(String::as_str),
Some("/opt/ruby/bin")
);
}
#[test]
fn caller_path_override_wins_over_parent_in_inherit_mode() {
let norm = Normalization {
prepend_dirs: vec![PathBuf::from("/opt/ruby/bin")],
unresolved: vec![],
};
let mut env_map = BTreeMap::new();
env_map.insert("PATH".to_string(), "/caller/path".to_string());
apply_to_env(&mut env_map, EnvMode::Patch, Some("/parent/path"), &norm);
assert_eq!(
env_map.get("PATH").map(String::as_str),
Some("/opt/ruby/bin:/caller/path")
);
}
#[test]
fn tool_versions_comments_and_blank_lines_ignored() {
let contents = "# comment\n\nruby 3.2.2 # inline\n \npython 3.11.0\n";
let env = FakeEnv::with_gate_on()
.file("/r/.tool-versions", contents)
.resolver("mise")
.bin("mise:ruby@3.2.2", "/opt/ruby/3.2.2")
.bin("mise:python@3.11.0", "/opt/python/3.11.0");
let norm = compute(&env, Path::new("/r"));
assert_eq!(
norm.prepend_dirs,
vec![
PathBuf::from("/opt/ruby/3.2.2"),
PathBuf::from("/opt/python/3.11.0"),
]
);
}
}