1#![forbid(unsafe_code)]
9
10use std::path::Path;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Imported {
15 pub tool: String,
16 pub version: String,
17 pub source: String,
18}
19
20fn 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
34pub 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
68fn 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 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 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 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 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
118pub 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 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 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
198pub 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 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 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
262pub 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"); assert_eq!(imported[0].version, "20.11.0");
282 assert_eq!(imported[2].tool, "go"); }
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")); assert_eq!(get("ruby"), Some("3.3.0")); assert_eq!(get("terraform"), Some("1.9.0")); assert_eq!(get("go"), None); 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}