1use std::path::Path;
17use std::process::Command;
18use std::time::Duration;
19
20use super::{ToolchainAvailability, ToolchainState};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Declares {
27 WholeFile,
29 KeyValue(&'static str),
31 JsonMember(&'static str, &'static str),
33 Directive(&'static str),
35 Nothing,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum Pin {
56 Exact,
58 Minimum,
60}
61
62#[derive(Debug, Clone, Copy)]
64pub struct Marker {
65 pub path: &'static str,
68 pub declares: Declares,
70 pub pins: Pin,
72}
73
74#[derive(Debug, Clone, Copy)]
76pub struct Detector {
77 pub id: &'static str,
79 pub title: &'static str,
81 pub executable: &'static str,
83 pub version_args: &'static [&'static str],
85 pub markers: &'static [Marker],
87}
88
89pub const DETECTORS: &[Detector] = &[
95 Detector {
96 id: "rust",
97 title: "Rust",
98 executable: "rustc",
99 version_args: &["--version"],
100 markers: &[
101 Marker {
102 path: "rust-toolchain.toml",
103 declares: Declares::KeyValue("channel"),
104 pins: Pin::Exact,
105 },
106 Marker {
107 path: "rust-toolchain",
108 declares: Declares::WholeFile,
109 pins: Pin::Exact,
110 },
111 Marker {
112 path: "apps/*/Cargo.toml",
113 declares: Declares::KeyValue("rust-version"),
114 pins: Pin::Minimum,
115 },
116 Marker {
117 path: "Cargo.toml",
118 declares: Declares::KeyValue("rust-version"),
119 pins: Pin::Minimum,
120 },
121 ],
122 },
123 Detector {
124 id: "node",
125 title: "Node",
126 executable: "node",
127 version_args: &["--version"],
128 markers: &[
129 Marker {
130 path: ".nvmrc",
131 declares: Declares::WholeFile,
132 pins: Pin::Exact,
133 },
134 Marker {
135 path: ".node-version",
136 declares: Declares::WholeFile,
137 pins: Pin::Exact,
138 },
139 Marker {
140 path: "package.json",
141 declares: Declares::JsonMember("engines", "node"),
142 pins: Pin::Exact,
143 },
144 ],
145 },
146 Detector {
147 id: "just",
148 title: "just",
149 executable: "just",
150 version_args: &["--version"],
151 markers: &[
152 Marker {
153 path: "justfile",
154 declares: Declares::Nothing,
155 pins: Pin::Exact,
156 },
157 Marker {
158 path: ".justfile",
159 declares: Declares::Nothing,
160 pins: Pin::Exact,
161 },
162 ],
163 },
164 Detector {
165 id: "python",
166 title: "Python",
167 executable: "python3",
168 version_args: &["--version"],
169 markers: &[
170 Marker {
171 path: ".python-version",
172 declares: Declares::WholeFile,
173 pins: Pin::Exact,
174 },
175 Marker {
176 path: "pyproject.toml",
177 declares: Declares::KeyValue("requires-python"),
178 pins: Pin::Minimum,
179 },
180 ],
181 },
182 Detector {
183 id: "go",
184 title: "Go",
185 executable: "go",
186 version_args: &["version"],
187 markers: &[Marker {
188 path: "go.mod",
189 declares: Declares::Directive("go"),
190 pins: Pin::Minimum,
191 }],
192 },
193 Detector {
194 id: "elixir",
195 title: "Elixir",
196 executable: "elixir",
197 version_args: &["--version"],
198 markers: &[Marker {
199 path: "mix.exs",
200 declares: Declares::Nothing,
201 pins: Pin::Exact,
202 }],
203 },
204 Detector {
205 id: "deno",
206 title: "Deno",
207 executable: "deno",
208 version_args: &["--version"],
209 markers: &[Marker {
210 path: "deno.json",
211 declares: Declares::Nothing,
212 pins: Pin::Exact,
213 }],
214 },
215];
216
217pub const VERSION_TIMEOUT: Duration = Duration::from_millis(1500);
221
222pub fn declared(root: &Path) -> Vec<ToolchainState> {
226 let mut out = Vec::new();
227 for detector in DETECTORS {
228 let Some((path, declares)) = first_marker(root, detector) else {
229 continue;
230 };
231 let declared = read_declared(&root.join(&path), declares);
232 out.push(ToolchainState {
233 id: detector.id.into(),
234 title: detector.title.into(),
235 declared,
236 declared_by: path,
237 installed: None,
238 availability: ToolchainAvailability::Unknown,
239 });
240 }
241 out
242}
243
244pub fn with_installed(mut toolchains: Vec<ToolchainState>) -> Vec<ToolchainState> {
248 for state in &mut toolchains {
249 let Some(detector) = DETECTORS.iter().find(|d| d.id == state.id) else {
250 continue;
251 };
252 match installed_version(detector) {
253 Some(version) => {
254 let unsatisfied = state
255 .declared
256 .as_deref()
257 .zip(pin_for(detector, &state.declared_by))
258 .and_then(|(declared, pin)| satisfies(declared, &version, pin))
259 == Some(false);
260 state.availability = if unsatisfied {
261 ToolchainAvailability::Mismatch
262 } else {
263 ToolchainAvailability::Installed
264 };
265 state.installed = Some(version);
266 }
267 None => {
268 state.availability = ToolchainAvailability::Missing;
269 }
270 }
271 }
272 toolchains
273}
274
275fn pin_for(detector: &Detector, declared_by: &str) -> Option<Pin> {
278 detector
279 .markers
280 .iter()
281 .find(|marker| match marker.path.split_once('*') {
282 None => marker.path == declared_by,
283 Some((before, after)) => {
284 declared_by.len() > before.len() + after.len()
285 && declared_by.starts_with(before)
286 && declared_by.ends_with(after)
287 && !declared_by[before.len()..declared_by.len() - after.len()].contains('/')
288 }
289 })
290 .map(|marker| marker.pins)
291}
292
293pub fn satisfies(declared: &str, installed: &str, pin: Pin) -> Option<bool> {
308 let want = plain_version(declared)?;
309 let have = plain_version(installed)?;
310 Some(match pin {
311 Pin::Exact => have.len() >= want.len() && have[..want.len()] == want[..],
312 Pin::Minimum => {
313 let width = want.len().max(have.len());
315 let pad = |v: &[u64]| {
316 let mut v = v.to_vec();
317 v.resize(width, 0);
318 v
319 };
320 pad(&have) >= pad(&want)
321 }
322 })
323}
324
325fn plain_version(text: &str) -> Option<Vec<u64>> {
327 let text = text.trim().trim_start_matches('v');
328 if text.is_empty() {
329 return None;
330 }
331 text.split('.')
332 .map(|part| {
333 if !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()) {
334 part.parse().ok()
335 } else {
336 None
337 }
338 })
339 .collect()
340}
341
342fn installed_version(detector: &Detector) -> Option<String> {
344 let out = crate::environment::probe::bounded_output(
345 Command::new(detector.executable).args(detector.version_args),
346 VERSION_TIMEOUT,
347 )?;
348 if !out.status.success() {
349 return None;
350 }
351 let text = String::from_utf8_lossy(&out.stdout);
352 let line = text.lines().find(|l| !l.trim().is_empty())?.trim();
353 Some(
356 line.split_whitespace()
357 .find_map(|word| {
358 let candidate = word.trim_start_matches('v');
359 let looks_like_a_version = candidate.split('.').all(|part| {
360 !part.is_empty() && part.chars().next().is_some_and(|c| c.is_ascii_digit())
361 }) && candidate.contains('.');
362 looks_like_a_version.then(|| candidate.to_string())
363 })
364 .unwrap_or_else(|| line.to_string()),
365 )
366}
367
368fn first_marker(root: &Path, detector: &Detector) -> Option<(String, Declares)> {
370 for marker in detector.markers {
371 match marker.path.split_once('*') {
372 None => {
373 if root.join(marker.path).is_file() {
374 return Some((marker.path.to_string(), marker.declares));
375 }
376 }
377 Some((before, after)) => {
378 let dir = root.join(before.trim_end_matches('/'));
381 let mut names: Vec<String> = std::fs::read_dir(&dir)
382 .into_iter()
383 .flatten()
384 .flatten()
385 .filter_map(|e| e.file_name().into_string().ok())
386 .collect();
387 names.sort();
388 for name in names {
389 let rel = format!("{before}{name}{after}");
390 if root.join(&rel).is_file() {
391 return Some((rel, marker.declares));
392 }
393 }
394 }
395 }
396 }
397 None
398}
399
400pub fn read_text(text: &str, declares: Declares) -> Option<String> {
411 match declares {
412 Declares::Nothing => None,
413 Declares::WholeFile => text
414 .lines()
415 .map(str::trim)
416 .find(|l| !l.is_empty() && !l.starts_with('#'))
417 .map(str::to_string),
418 Declares::KeyValue(key) => text.lines().find_map(|line| {
419 let line = line.trim();
420 let rest = line.strip_prefix(key)?;
421 let rest = rest.trim_start();
422 let rest = rest.strip_prefix('=').or_else(|| rest.strip_prefix(':'))?;
423 let value = rest.trim().trim_matches(['"', '\'']).trim();
424 (!value.is_empty()).then(|| value.to_string())
425 }),
426 Declares::JsonMember(object, member) => {
427 let value: serde_json::Value = serde_json::from_str(text).ok()?;
428 value.get(object)?.get(member)?.as_str().map(str::to_string)
429 }
430 Declares::Directive(word) => text.lines().find_map(|line| {
431 let rest = line.strip_prefix(word)?;
432 let value = rest.trim();
433 (!value.is_empty() && value.chars().next().is_some_and(|c| c.is_ascii_digit()))
434 .then(|| value.to_string())
435 }),
436 }
437}
438
439fn read_declared(path: &Path, declares: Declares) -> Option<String> {
440 if matches!(declares, Declares::Nothing) {
441 return None;
442 }
443 let text = std::fs::read_to_string(path).ok()?;
444 read_text(&text, declares)
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450
451 fn tree(files: &[(&str, &str)]) -> tempfile::TempDir {
452 let dir = tempfile::tempdir().expect("a temporary directory");
453 for (path, content) in files {
454 let full = dir.path().join(path);
455 std::fs::create_dir_all(full.parent().expect("a parent")).expect("a directory");
456 std::fs::write(full, content).expect("a file");
457 }
458 dir
459 }
460
461 #[test]
464 fn only_declared_toolchains_appear() {
465 let dir = tree(&[("package.json", "{}")]);
466 let found = declared(dir.path());
467 let ids: Vec<&str> = found.iter().map(|t| t.id.as_str()).collect();
468 assert_eq!(ids, ["node"], "a repository with only a package.json");
469 assert!(
470 !ids.contains(&"rust"),
471 "rustc is installed on this machine and must still not appear"
472 );
473 }
474
475 #[test]
476 fn the_most_specific_marker_wins() {
477 let dir = tree(&[
478 ("rust-toolchain.toml", "[toolchain]\nchannel = \"1.90.0\"\n"),
479 ("Cargo.toml", "[package]\nrust-version = \"1.85\"\n"),
480 ]);
481 let found = declared(dir.path());
482 assert_eq!(found[0].declared.as_deref(), Some("1.90.0"));
483 assert_eq!(found[0].declared_by, "rust-toolchain.toml");
484 }
485
486 #[test]
487 fn a_marker_with_a_star_resolves_one_directory_deep_in_a_stable_order() {
488 let dir = tree(&[
489 (
490 "apps/zeta/Cargo.toml",
491 "[package]\nrust-version = \"1.80\"\n",
492 ),
493 (
494 "apps/alpha/Cargo.toml",
495 "[package]\nrust-version = \"1.85\"\n",
496 ),
497 ]);
498 let found = declared(dir.path());
499 assert_eq!(found[0].declared_by, "apps/alpha/Cargo.toml");
500 assert_eq!(found[0].declared.as_deref(), Some("1.85"));
501 }
502
503 #[test]
504 fn a_declared_toolchain_starts_unknown_rather_than_missing() {
505 let dir = tree(&[("justfile", "default:\n @echo\n")]);
506 let found = declared(dir.path());
507 assert_eq!(found[0].availability, ToolchainAvailability::Unknown);
508 assert_eq!(found[0].installed, None);
509 }
510
511 #[test]
512 fn asking_for_versions_decides_installed_or_missing() {
513 let dir = tree(&[("go.mod", "module x\n\ngo 1.22\n")]);
514 let found = with_installed(declared(dir.path()));
515 assert_eq!(found[0].declared.as_deref(), Some("1.22"));
516 assert_ne!(found[0].availability, ToolchainAvailability::Unknown);
519 }
520
521 #[test]
524 fn an_exact_pin_and_a_floor_are_judged_differently() {
525 assert_eq!(satisfies("0.23.6", "0.23.4", Pin::Exact), Some(false));
526 assert_eq!(satisfies("0.23.6", "0.23.6", Pin::Exact), Some(true));
527 assert_eq!(satisfies("22", "22.20.0", Pin::Exact), Some(true));
528 assert_eq!(satisfies("22", "20.11.1", Pin::Exact), Some(false));
529 assert_eq!(satisfies("1.85", "1.90.0", Pin::Minimum), Some(true));
530 assert_eq!(satisfies("1.85", "1.85.0", Pin::Minimum), Some(true));
531 assert_eq!(satisfies("1.85.1", "1.85", Pin::Minimum), Some(false));
532 assert_eq!(satisfies("3.12", "3.11.9", Pin::Minimum), Some(false));
533 }
534
535 #[test]
536 fn what_is_not_a_plain_version_is_left_undecided() {
537 assert_eq!(satisfies(">=22", "22.20.0", Pin::Exact), None);
538 assert_eq!(satisfies("stable", "1.90.0", Pin::Exact), None);
539 assert_eq!(
540 satisfies("1.22", "go version go1.22.3 darwin/arm64", Pin::Minimum),
541 None
542 );
543 assert_eq!(satisfies("", "1.0", Pin::Exact), None);
544 assert_eq!(satisfies("1..2", "1.2", Pin::Exact), None);
545 }
546
547 #[test]
548 fn the_pin_follows_the_marker_that_declared_it() {
549 let rust = DETECTORS.iter().find(|d| d.id == "rust").expect("rust");
550 assert_eq!(pin_for(rust, "rust-toolchain.toml"), Some(Pin::Exact));
551 assert_eq!(pin_for(rust, "Cargo.toml"), Some(Pin::Minimum));
552 assert_eq!(pin_for(rust, "apps/alpha/Cargo.toml"), Some(Pin::Minimum));
553 assert_eq!(
554 pin_for(rust, "apps/a/b/Cargo.toml"),
555 None,
556 "a star is one directory"
557 );
558 let go = DETECTORS.iter().find(|d| d.id == "go").expect("go");
559 assert_eq!(pin_for(go, "go.mod"), Some(Pin::Minimum));
560 }
561
562 #[test]
563 fn a_version_line_is_reduced_to_the_version() {
564 let detector = DETECTORS.iter().find(|d| d.id == "rust").expect("rust");
566 assert_eq!(detector.version_args, &["--version"]);
568 }
569}