#![forbid(unsafe_code)]
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Imported {
pub tool: String,
pub version: String,
pub source: String,
}
fn canonical_name(name: &str) -> String {
match name {
"nodejs" => "node",
"golang" => "go",
other => other,
}
.to_string()
}
fn clean_version(v: &str) -> String {
v.trim().trim_start_matches('v').to_string()
}
pub fn parse_tool_versions(body: &str, source: &str) -> Vec<Imported> {
let mut out = Vec::new();
for line in body.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut parts = line.split_whitespace();
if let (Some(name), Some(version)) = (parts.next(), parts.next()) {
out.push(Imported {
tool: canonical_name(name),
version: clean_version(version),
source: source.to_string(),
});
}
}
out
}
fn single(tool: &str, body: &str, source: &str) -> Option<Imported> {
let version = clean_version(body);
let version = version.lines().next().unwrap_or("").trim().to_string();
if version.is_empty() {
None
} else {
Some(Imported {
tool: tool.to_string(),
version,
source: source.to_string(),
})
}
}
fn mise_value_to_version(raw: &str) -> Option<String> {
let v = raw.trim();
let unquote = |s: &str| -> String {
let s = s.trim();
s.trim_matches(|c| c == '"' || c == '\'').to_string()
};
let out = if let Some(rest) = v.strip_prefix('[') {
let inner = rest.split(']').next().unwrap_or("");
let first = inner.split(',').next().unwrap_or("").trim();
if first.is_empty() {
return None;
}
unquote(first)
} else if v.starts_with('{') {
let ver = v.split(',').find_map(|part| {
let part = part.trim_matches(|c| c == '{' || c == '}').trim();
let (k, val) = part.split_once('=')?;
if k.trim() == "version" {
Some(unquote(val))
} else {
None
}
})?;
ver
} else if let Some(q) = v.chars().next().filter(|c| *c == '"' || *c == '\'') {
let rest = &v[1..];
match rest.find(q) {
Some(i) => rest[..i].to_string(),
None => rest.to_string(),
}
} else {
v.split('#').next().unwrap_or(v).trim().to_string()
};
let out = clean_version(&out);
if out.is_empty() || out == "system" || out == "latest" {
None
} else {
Some(out)
}
}
pub fn parse_mise_toml(body: &str, source: &str) -> Vec<Imported> {
#[derive(PartialEq)]
enum Sec {
Other,
Tools,
ToolNamed(String),
}
let mut sec = Sec::Other;
let mut out = Vec::new();
for line in body.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(header) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
let header = header.trim().trim_matches(|c| c == '[' || c == ']').trim();
sec = if header == "tools" {
Sec::Tools
} else if let Some(name) = header.strip_prefix("tools.") {
Sec::ToolNamed(name.trim().trim_matches('"').to_string())
} else {
Sec::Other
};
continue;
}
let Some((key, val)) = line.split_once('=') else {
continue;
};
match &sec {
Sec::Tools => {
let name = key.trim().trim_matches('"');
if let Some(version) = mise_value_to_version(val) {
out.push(Imported {
tool: canonical_name(name),
version,
source: source.to_string(),
});
}
}
Sec::ToolNamed(name) => {
if key.trim() == "version" {
if let Some(version) = mise_value_to_version(val) {
out.push(Imported {
tool: canonical_name(name),
version,
source: source.to_string(),
});
}
}
}
Sec::Other => {}
}
}
out
}
fn parse_rust_toolchain(body: &str, source: &str) -> Option<Imported> {
let channel = body
.lines()
.find_map(|l| {
let l = l.trim();
l.strip_prefix("channel")
.and_then(|r| r.split('=').nth(1))
.map(|v| v.trim().trim_matches('"').to_string())
})
.unwrap_or_else(|| "stable".to_string());
Some(Imported {
tool: "rust".to_string(),
version: channel,
source: source.to_string(),
})
}
pub fn import_dir(dir: &Path) -> Vec<Imported> {
let mut found: Vec<Imported> = Vec::new();
let mut seen = std::collections::BTreeSet::new();
let read = |name: &str| std::fs::read_to_string(dir.join(name)).ok();
let push =
|imp: Imported, acc: &mut Vec<Imported>, seen: &mut std::collections::BTreeSet<String>| {
if seen.insert(imp.tool.clone()) {
acc.push(imp);
}
};
if let Some(b) = read(".nvmrc").or_else(|| read(".node-version")) {
if let Some(i) = single("node", &b, ".nvmrc") {
push(i, &mut found, &mut seen);
}
}
if let Some(b) = read(".python-version") {
if let Some(i) = single("python", &b, ".python-version") {
push(i, &mut found, &mut seen);
}
}
if let Some(b) = read(".ruby-version") {
if let Some(i) = single("ruby", &b, ".ruby-version") {
push(i, &mut found, &mut seen);
}
}
if let Some(b) = read(".go-version") {
if let Some(i) = single("go", &b, ".go-version") {
push(i, &mut found, &mut seen);
}
}
if let Some(b) = read("rust-toolchain.toml") {
if let Some(i) = parse_rust_toolchain(&b, "rust-toolchain.toml") {
push(i, &mut found, &mut seen);
}
}
for (name, src) in [
(".mise.toml", ".mise.toml"),
("mise.toml", "mise.toml"),
(".config/mise/config.toml", ".config/mise/config.toml"),
(".rtx.toml", ".rtx.toml"),
] {
if let Some(b) = read(name) {
for i in parse_mise_toml(&b, src) {
push(i, &mut found, &mut seen);
}
}
}
if let Some(b) = read(".tool-versions") {
for i in parse_tool_versions(&b, ".tool-versions") {
push(i, &mut found, &mut seen);
}
}
found
}
pub fn to_manifest_toml(tools: &[Imported]) -> String {
let mut s = String::from("[tools]\n");
for t in tools {
s.push_str(&format!("{} = \"{}\"\n", t.tool, t.version));
}
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_versions_parsing_and_aliases() {
let body = "# comment\nnodejs 20.11.0\npython 3.12.2\n\ngolang 1.23.0\n";
let imported = parse_tool_versions(body, ".tool-versions");
assert_eq!(imported.len(), 3);
assert_eq!(imported[0].tool, "node"); assert_eq!(imported[0].version, "20.11.0");
assert_eq!(imported[2].tool, "go"); }
#[test]
fn single_file_strips_v_prefix() {
let i = single("node", "v20.11.0\n", ".nvmrc").unwrap();
assert_eq!(i.version, "20.11.0");
}
#[test]
fn rust_toolchain_channel() {
let i = parse_rust_toolchain("[toolchain]\nchannel = \"1.79.0\"\n", "rust-toolchain.toml")
.unwrap();
assert_eq!(i.tool, "rust");
assert_eq!(i.version, "1.79.0");
}
#[test]
fn mise_toml_flat_array_inline_and_subtable() {
let body = "\
[env]
FOO = \"bar\"
[tools]
node = \"20.11.0\"
python = [\"3.12\", \"3.11\"]
ruby = { version = \"3.3.0\" }
go = \"system\" # should be skipped
[tools.terraform]
version = \"1.9.0\"
[tasks.build]
run = \"make\"
";
let imported = parse_mise_toml(body, ".mise.toml");
let get = |t: &str| {
imported
.iter()
.find(|i| i.tool == t)
.map(|i| i.version.as_str())
};
assert_eq!(get("node"), Some("20.11.0"));
assert_eq!(get("python"), Some("3.12")); assert_eq!(get("ruby"), Some("3.3.0")); assert_eq!(get("terraform"), Some("1.9.0")); assert_eq!(get("go"), None); assert!(imported
.iter()
.all(|i| i.tool != "FOO" && i.tool != "build"));
}
#[test]
fn renders_manifest() {
let tools = vec![Imported {
tool: "node".into(),
version: "24".into(),
source: "x".into(),
}];
assert_eq!(to_manifest_toml(&tools), "[tools]\nnode = \"24\"\n");
}
}