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 (`.mise.toml`/`mise.toml`, `.tool-versions`,
4//! `.nvmrc`, `.python-version`, `rust-toolchain.toml`, …) and produces
5//! tool→version pairs plus the source they
6//! came from, which the CLI renders into a `vanta.toml`. See `docs/30-migration.md`.
7//! Pure-std parsing; no foreign tool is invoked.
8#![forbid(unsafe_code)]
9
10use std::path::Path;
11
12/// One imported tool pin and the file it came from.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Imported {
15    pub tool: String,
16    pub version: String,
17    pub source: String,
18}
19
20/// Translate a foreign tool name to Vanta's canonical name.
21fn canonical_name(name: &str) -> String {
22    match name {
23        "nodejs" => "node",
24        "golang" => "go",
25        other => other,
26    }
27    .to_string()
28}
29
30fn clean_version(v: &str) -> String {
31    v.trim().trim_start_matches('v').to_string()
32}
33
34/// Parse an asdf/mise `.tool-versions` body ("name version" per line).
35pub fn parse_tool_versions(body: &str, source: &str) -> Vec<Imported> {
36    let mut out = Vec::new();
37    for line in body.lines() {
38        let line = line.trim();
39        if line.is_empty() || line.starts_with('#') {
40            continue;
41        }
42        let mut parts = line.split_whitespace();
43        if let (Some(name), Some(version)) = (parts.next(), parts.next()) {
44            out.push(Imported {
45                tool: canonical_name(name),
46                version: clean_version(version),
47                source: source.to_string(),
48            });
49        }
50    }
51    out
52}
53
54fn single(tool: &str, body: &str, source: &str) -> Option<Imported> {
55    let version = clean_version(body);
56    let version = version.lines().next().unwrap_or("").trim().to_string();
57    if version.is_empty() {
58        None
59    } else {
60        Some(Imported {
61            tool: tool.to_string(),
62            version,
63            source: source.to_string(),
64        })
65    }
66}
67
68/// Extract a version string from a mise `[tools]` value. Handles quoted strings
69/// (`"3.3.0"`), arrays (`["3.12", "3.11"]` → first), inline tables
70/// (`{ version = "3.3.0" }`), and bare tokens (`20`). Returns `None` for values
71/// with no usable version (e.g. `"system"`, an empty array).
72fn mise_value_to_version(raw: &str) -> Option<String> {
73    let v = raw.trim();
74    let unquote = |s: &str| -> String {
75        let s = s.trim();
76        s.trim_matches(|c| c == '"' || c == '\'').to_string()
77    };
78    let out = if let Some(rest) = v.strip_prefix('[') {
79        // Array: take the first element.
80        let inner = rest.split(']').next().unwrap_or("");
81        let first = inner.split(',').next().unwrap_or("").trim();
82        if first.is_empty() {
83            return None;
84        }
85        unquote(first)
86    } else if v.starts_with('{') {
87        // Inline table: find `version = "…"`.
88        let ver = v.split(',').find_map(|part| {
89            let part = part.trim_matches(|c| c == '{' || c == '}').trim();
90            let (k, val) = part.split_once('=')?;
91            if k.trim() == "version" {
92                Some(unquote(val))
93            } else {
94                None
95            }
96        })?;
97        ver
98    } else if let Some(q) = v.chars().next().filter(|c| *c == '"' || *c == '\'') {
99        // Quoted scalar: take the content up to the closing quote (ignoring any
100        // trailing inline comment).
101        let rest = &v[1..];
102        match rest.find(q) {
103            Some(i) => rest[..i].to_string(),
104            None => rest.to_string(),
105        }
106    } else {
107        // Bare scalar: strip an inline comment.
108        v.split('#').next().unwrap_or(v).trim().to_string()
109    };
110    let out = clean_version(&out);
111    if out.is_empty() || out == "system" || out == "latest" {
112        None
113    } else {
114        Some(out)
115    }
116}
117
118/// Parse a mise / rtx config (`.mise.toml`, `mise.toml`, `.config/mise/config.toml`)
119/// `[tools]` table. Supports the flat form (`node = "20"`, `python = ["3.12"]`,
120/// `ruby = { version = "3.3" }`) and the `[tools.<name>]` sub-table form
121/// (`version = "20"`). Pure-std line parsing — only the `[tools]` section is read.
122pub fn parse_mise_toml(body: &str, source: &str) -> Vec<Imported> {
123    #[derive(PartialEq)]
124    enum Sec {
125        Other,
126        Tools,
127        ToolNamed(String),
128    }
129    let mut sec = Sec::Other;
130    let mut out = Vec::new();
131
132    for line in body.lines() {
133        let line = line.trim();
134        if line.is_empty() || line.starts_with('#') {
135            continue;
136        }
137        if let Some(header) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
138            // Ignore array-of-tables double brackets by trimming a stray '['/']'.
139            let header = header.trim().trim_matches(|c| c == '[' || c == ']').trim();
140            sec = if header == "tools" {
141                Sec::Tools
142            } else if let Some(name) = header.strip_prefix("tools.") {
143                Sec::ToolNamed(name.trim().trim_matches('"').to_string())
144            } else {
145                Sec::Other
146            };
147            continue;
148        }
149        let Some((key, val)) = line.split_once('=') else {
150            continue;
151        };
152        match &sec {
153            Sec::Tools => {
154                let name = key.trim().trim_matches('"');
155                if let Some(version) = mise_value_to_version(val) {
156                    out.push(Imported {
157                        tool: canonical_name(name),
158                        version,
159                        source: source.to_string(),
160                    });
161                }
162            }
163            Sec::ToolNamed(name) => {
164                if key.trim() == "version" {
165                    if let Some(version) = mise_value_to_version(val) {
166                        out.push(Imported {
167                            tool: canonical_name(name),
168                            version,
169                            source: source.to_string(),
170                        });
171                    }
172                }
173            }
174            Sec::Other => {}
175        }
176    }
177    out
178}
179
180fn parse_rust_toolchain(body: &str, source: &str) -> Option<Imported> {
181    // Minimal: find `channel = "<x>"`; default to "stable".
182    let channel = body
183        .lines()
184        .find_map(|l| {
185            let l = l.trim();
186            l.strip_prefix("channel")
187                .and_then(|r| r.split('=').nth(1))
188                .map(|v| v.trim().trim_matches('"').to_string())
189        })
190        .unwrap_or_else(|| "stable".to_string());
191    Some(Imported {
192        tool: "rust".to_string(),
193        version: channel,
194        source: source.to_string(),
195    })
196}
197
198/// Scan a directory for known version files and import them. The first file to
199/// mention a tool wins (specific files are scanned before broad ones).
200pub fn import_dir(dir: &Path) -> Vec<Imported> {
201    let mut found: Vec<Imported> = Vec::new();
202    let mut seen = std::collections::BTreeSet::new();
203
204    let read = |name: &str| std::fs::read_to_string(dir.join(name)).ok();
205
206    let push =
207        |imp: Imported, acc: &mut Vec<Imported>, seen: &mut std::collections::BTreeSet<String>| {
208            if seen.insert(imp.tool.clone()) {
209                acc.push(imp);
210            }
211        };
212
213    // Specific single-tool files first (they express intent most precisely).
214    if let Some(b) = read(".nvmrc").or_else(|| read(".node-version")) {
215        if let Some(i) = single("node", &b, ".nvmrc") {
216            push(i, &mut found, &mut seen);
217        }
218    }
219    if let Some(b) = read(".python-version") {
220        if let Some(i) = single("python", &b, ".python-version") {
221            push(i, &mut found, &mut seen);
222        }
223    }
224    if let Some(b) = read(".ruby-version") {
225        if let Some(i) = single("ruby", &b, ".ruby-version") {
226            push(i, &mut found, &mut seen);
227        }
228    }
229    if let Some(b) = read(".go-version") {
230        if let Some(i) = single("go", &b, ".go-version") {
231            push(i, &mut found, &mut seen);
232        }
233    }
234    if let Some(b) = read("rust-toolchain.toml") {
235        if let Some(i) = parse_rust_toolchain(&b, "rust-toolchain.toml") {
236            push(i, &mut found, &mut seen);
237        }
238    }
239    // Broad multi-tool files last. mise/rtx config before the legacy
240    // asdf-style .tool-versions.
241    for (name, src) in [
242        (".mise.toml", ".mise.toml"),
243        ("mise.toml", "mise.toml"),
244        (".config/mise/config.toml", ".config/mise/config.toml"),
245        (".rtx.toml", ".rtx.toml"),
246    ] {
247        if let Some(b) = read(name) {
248            for i in parse_mise_toml(&b, src) {
249                push(i, &mut found, &mut seen);
250            }
251        }
252    }
253    if let Some(b) = read(".tool-versions") {
254        for i in parse_tool_versions(&b, ".tool-versions") {
255            push(i, &mut found, &mut seen);
256        }
257    }
258
259    found
260}
261
262/// Render imported tools into a `vanta.toml` `[tools]` table.
263pub fn to_manifest_toml(tools: &[Imported]) -> String {
264    let mut s = String::from("[tools]\n");
265    for t in tools {
266        s.push_str(&format!("{} = \"{}\"\n", t.tool, t.version));
267    }
268    s
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn tool_versions_parsing_and_aliases() {
277        let body = "# comment\nnodejs 20.11.0\npython 3.12.2\n\ngolang 1.23.0\n";
278        let imported = parse_tool_versions(body, ".tool-versions");
279        assert_eq!(imported.len(), 3);
280        assert_eq!(imported[0].tool, "node"); // nodejs → node
281        assert_eq!(imported[0].version, "20.11.0");
282        assert_eq!(imported[2].tool, "go"); // golang → go
283    }
284
285    #[test]
286    fn single_file_strips_v_prefix() {
287        let i = single("node", "v20.11.0\n", ".nvmrc").unwrap();
288        assert_eq!(i.version, "20.11.0");
289    }
290
291    #[test]
292    fn rust_toolchain_channel() {
293        let i = parse_rust_toolchain("[toolchain]\nchannel = \"1.79.0\"\n", "rust-toolchain.toml")
294            .unwrap();
295        assert_eq!(i.tool, "rust");
296        assert_eq!(i.version, "1.79.0");
297    }
298
299    #[test]
300    fn mise_toml_flat_array_inline_and_subtable() {
301        let body = "\
302[env]
303FOO = \"bar\"
304
305[tools]
306node = \"20.11.0\"
307python = [\"3.12\", \"3.11\"]
308ruby = { version = \"3.3.0\" }
309go = \"system\"           # should be skipped
310
311[tools.terraform]
312version = \"1.9.0\"
313
314[tasks.build]
315run = \"make\"
316";
317        let imported = parse_mise_toml(body, ".mise.toml");
318        let get = |t: &str| {
319            imported
320                .iter()
321                .find(|i| i.tool == t)
322                .map(|i| i.version.as_str())
323        };
324        assert_eq!(get("node"), Some("20.11.0"));
325        assert_eq!(get("python"), Some("3.12")); // first of the array
326        assert_eq!(get("ruby"), Some("3.3.0")); // inline table
327        assert_eq!(get("terraform"), Some("1.9.0")); // [tools.<name>] sub-table
328        assert_eq!(get("go"), None); // "system" ignored
329                                     // Keys outside [tools] must not leak in.
330        assert!(imported
331            .iter()
332            .all(|i| i.tool != "FOO" && i.tool != "build"));
333    }
334
335    #[test]
336    fn renders_manifest() {
337        let tools = vec![Imported {
338            tool: "node".into(),
339            version: "24".into(),
340            source: "x".into(),
341        }];
342        assert_eq!(to_manifest_toml(&tools), "[tools]\nnode = \"24\"\n");
343    }
344}