Skip to main content

vanta_migrate/
lib.rs

1//! `vanta-migrate` — import foreign version files into a Vanta `[tools]` set.
2//!
3//! Reads the common version files (`.tool-versions`, `.nvmrc`, `.python-version`,
4//! `rust-toolchain.toml`, …) and produces tool→version pairs plus the source they
5//! came from, which the CLI renders into a `vanta.toml`. See `docs/30-migration.md`.
6//! Pure-std parsing; no foreign tool is invoked.
7#![forbid(unsafe_code)]
8
9use std::path::Path;
10
11/// One imported tool pin and the file it came from.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Imported {
14    pub tool: String,
15    pub version: String,
16    pub source: String,
17}
18
19/// Translate a foreign tool name to Vanta's canonical name.
20fn canonical_name(name: &str) -> String {
21    match name {
22        "nodejs" => "node",
23        "golang" => "go",
24        other => other,
25    }
26    .to_string()
27}
28
29fn clean_version(v: &str) -> String {
30    v.trim().trim_start_matches('v').to_string()
31}
32
33/// Parse an asdf/mise `.tool-versions` body ("name version" per line).
34pub fn parse_tool_versions(body: &str, source: &str) -> Vec<Imported> {
35    let mut out = Vec::new();
36    for line in body.lines() {
37        let line = line.trim();
38        if line.is_empty() || line.starts_with('#') {
39            continue;
40        }
41        let mut parts = line.split_whitespace();
42        if let (Some(name), Some(version)) = (parts.next(), parts.next()) {
43            out.push(Imported {
44                tool: canonical_name(name),
45                version: clean_version(version),
46                source: source.to_string(),
47            });
48        }
49    }
50    out
51}
52
53fn single(tool: &str, body: &str, source: &str) -> Option<Imported> {
54    let version = clean_version(body);
55    let version = version.lines().next().unwrap_or("").trim().to_string();
56    if version.is_empty() {
57        None
58    } else {
59        Some(Imported {
60            tool: tool.to_string(),
61            version,
62            source: source.to_string(),
63        })
64    }
65}
66
67fn parse_rust_toolchain(body: &str, source: &str) -> Option<Imported> {
68    // Minimal: find `channel = "<x>"`; default to "stable".
69    let channel = body
70        .lines()
71        .find_map(|l| {
72            let l = l.trim();
73            l.strip_prefix("channel")
74                .and_then(|r| r.split('=').nth(1))
75                .map(|v| v.trim().trim_matches('"').to_string())
76        })
77        .unwrap_or_else(|| "stable".to_string());
78    Some(Imported {
79        tool: "rust".to_string(),
80        version: channel,
81        source: source.to_string(),
82    })
83}
84
85/// Scan a directory for known version files and import them. The first file to
86/// mention a tool wins (specific files are scanned before broad ones).
87pub fn import_dir(dir: &Path) -> Vec<Imported> {
88    let mut found: Vec<Imported> = Vec::new();
89    let mut seen = std::collections::BTreeSet::new();
90
91    let read = |name: &str| std::fs::read_to_string(dir.join(name)).ok();
92
93    let push =
94        |imp: Imported, acc: &mut Vec<Imported>, seen: &mut std::collections::BTreeSet<String>| {
95            if seen.insert(imp.tool.clone()) {
96                acc.push(imp);
97            }
98        };
99
100    // Specific single-tool files first (they express intent most precisely).
101    if let Some(b) = read(".nvmrc").or_else(|| read(".node-version")) {
102        if let Some(i) = single("node", &b, ".nvmrc") {
103            push(i, &mut found, &mut seen);
104        }
105    }
106    if let Some(b) = read(".python-version") {
107        if let Some(i) = single("python", &b, ".python-version") {
108            push(i, &mut found, &mut seen);
109        }
110    }
111    if let Some(b) = read(".ruby-version") {
112        if let Some(i) = single("ruby", &b, ".ruby-version") {
113            push(i, &mut found, &mut seen);
114        }
115    }
116    if let Some(b) = read(".go-version") {
117        if let Some(i) = single("go", &b, ".go-version") {
118            push(i, &mut found, &mut seen);
119        }
120    }
121    if let Some(b) = read("rust-toolchain.toml") {
122        if let Some(i) = parse_rust_toolchain(&b, "rust-toolchain.toml") {
123            push(i, &mut found, &mut seen);
124        }
125    }
126    // Broad multi-tool files last.
127    if let Some(b) = read(".tool-versions") {
128        for i in parse_tool_versions(&b, ".tool-versions") {
129            push(i, &mut found, &mut seen);
130        }
131    }
132
133    found
134}
135
136/// Render imported tools into a `vanta.toml` `[tools]` table.
137pub fn to_manifest_toml(tools: &[Imported]) -> String {
138    let mut s = String::from("[tools]\n");
139    for t in tools {
140        s.push_str(&format!("{} = \"{}\"\n", t.tool, t.version));
141    }
142    s
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn tool_versions_parsing_and_aliases() {
151        let body = "# comment\nnodejs 20.11.0\npython 3.12.2\n\ngolang 1.23.0\n";
152        let imported = parse_tool_versions(body, ".tool-versions");
153        assert_eq!(imported.len(), 3);
154        assert_eq!(imported[0].tool, "node"); // nodejs → node
155        assert_eq!(imported[0].version, "20.11.0");
156        assert_eq!(imported[2].tool, "go"); // golang → go
157    }
158
159    #[test]
160    fn single_file_strips_v_prefix() {
161        let i = single("node", "v20.11.0\n", ".nvmrc").unwrap();
162        assert_eq!(i.version, "20.11.0");
163    }
164
165    #[test]
166    fn rust_toolchain_channel() {
167        let i = parse_rust_toolchain("[toolchain]\nchannel = \"1.79.0\"\n", "rust-toolchain.toml")
168            .unwrap();
169        assert_eq!(i.tool, "rust");
170        assert_eq!(i.version, "1.79.0");
171    }
172
173    #[test]
174    fn renders_manifest() {
175        let tools = vec![Imported {
176            tool: "node".into(),
177            version: "24".into(),
178            source: "x".into(),
179        }];
180        assert_eq!(to_manifest_toml(&tools), "[tools]\nnode = \"24\"\n");
181    }
182}