1#![forbid(unsafe_code)]
8
9use std::path::Path;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Imported {
14 pub tool: String,
15 pub version: String,
16 pub source: String,
17}
18
19fn 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
33pub 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 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
85pub 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 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 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
136pub 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"); assert_eq!(imported[0].version, "20.11.0");
156 assert_eq!(imported[2].tool, "go"); }
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}