Skip to main content

majordomus_cli/environment/
toolchain.rs

1//! Toolchains: the ones this repository declares, and what is installed for them.
2//!
3//! Relevance is decided by the repository, not by the machine. A toolchain appears in the
4//! snapshot when a file in the checkout declares it — `Cargo.toml` for Rust, `package.json`
5//! for Node — and never because the executable happens to be on the path: a laptop with
6//! nine language runtimes installed would otherwise report all nine for every repository,
7//! and the one line that matters would be lost among them.
8//!
9//! Declared, installed and available are three different facts and are kept apart. A
10//! repository can ask for a version nobody has; a machine can have a version nobody asked
11//! for; and the interesting case — they disagree — is only visible when both are reported.
12//!
13//! Asking a version costs a process spawn each, so it happens in a full resolution only;
14//! a fast resolution takes the answers from the cache and says `unknown` when it has none.
15
16use std::path::Path;
17use std::process::Command;
18use std::time::Duration;
19
20use super::{ToolchainAvailability, ToolchainState};
21
22/// How a declared version is read out of the file that declares it. Small on purpose:
23/// three shapes cover every manifest here, and a fourth is a new variant rather than a
24/// parser.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Declares {
27    /// The whole file is the version, comments and blank lines skipped (`.nvmrc`).
28    WholeFile,
29    /// A `key = "value"` or `key: value` line at any indentation (`rust-version`).
30    KeyValue(&'static str),
31    /// A member of a JSON object, one level deep (`engines` → `node`).
32    JsonMember(&'static str, &'static str),
33    /// A `go 1.22` style directive at the start of a line.
34    Directive(&'static str),
35    /// The file marks the toolchain relevant but declares no version.
36    Nothing,
37}
38
39/// What a declared version asks of the installed one. A `.nvmrc` that says `22` pins a
40/// release line; a `rust-version` of `1.85` is a floor that 1.90 satisfies. Reading the
41/// second as the first reports every up-to-date machine as wrong.
42///
43/// The same pair of versions answers differently under each pin:
44///
45/// ```
46/// use majordomus_cli::environment::toolchain::{satisfies, Pin};
47/// // a newer release satisfies a floor but not a pinned release line
48/// assert_eq!(satisfies("1.85", "1.90.0", Pin::Minimum), Some(true));
49/// assert_eq!(satisfies("1.85", "1.90.0", Pin::Exact), Some(false));
50/// // a release inside the pinned line satisfies both
51/// assert_eq!(satisfies("22", "22.20.0", Pin::Exact), Some(true));
52/// assert_eq!(satisfies("22", "22.20.0", Pin::Minimum), Some(true));
53/// ```
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum Pin {
56    /// The installed version starts with the declared one, component by component.
57    Exact,
58    /// The installed version is at least the declared one.
59    Minimum,
60}
61
62/// One place a toolchain may be declared.
63#[derive(Debug, Clone, Copy)]
64pub struct Marker {
65    /// Repository-relative path. Exactly one segment may be `*`, resolved with a single
66    /// directory read; nothing here walks a tree.
67    pub path: &'static str,
68    /// How to read the version out of it.
69    pub declares: Declares,
70    /// What the version read out of it asks of the installed one.
71    pub pins: Pin,
72}
73
74/// One toolchain this executable knows how to recognise.
75#[derive(Debug, Clone, Copy)]
76pub struct Detector {
77    /// A stable id, `[a-z][a-z0-9-]*`.
78    pub id: &'static str,
79    /// The short name a person reads.
80    pub title: &'static str,
81    /// The executable whose version is asked, in a full resolution.
82    pub executable: &'static str,
83    /// The arguments that make it print its version.
84    pub version_args: &'static [&'static str],
85    /// Where it may be declared, most specific first; the first that exists decides.
86    pub markers: &'static [Marker],
87}
88
89/// Every toolchain this executable can recognise, in the order a snapshot reports them.
90///
91/// This table is the one place a marker file is associated with a toolchain. It is data,
92/// not a list of what to show: an entry costs one `exists()` when the repository does not
93/// declare it, and produces nothing.
94pub 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
217/// How long a version subprocess may take before it is abandoned. A toolchain shim that
218/// downloads a release on first use has been seen to take minutes; a snapshot reports
219/// `unknown` rather than waiting for it.
220pub const VERSION_TIMEOUT: Duration = Duration::from_millis(1500);
221
222/// The toolchains this repository declares, without asking any of them their version.
223/// Cheap enough for the hot path: one `exists()` per marker, one directory read for a
224/// marker with a `*`, and one small file read for each that matched.
225pub 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
244/// Ask every declared toolchain what is installed. One bounded subprocess each; a
245/// toolchain that is not on the path is [`ToolchainAvailability::Missing`], and one that
246/// does not answer within [`VERSION_TIMEOUT`] stays `Unknown`.
247pub 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
275/// The pin of the marker that declared a toolchain, found from the path it resolved to; a
276/// marker with a `*` matches exactly one directory name in that position.
277fn 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
293/// Whether an installed version satisfies a declared one, or `None` when that cannot be
294/// decided. Only plain dotted numbers are compared: a range (`>=22`), a channel name
295/// (`stable`) or an answer that is not a version stays undecided, because a finding that
296/// guesses fires on machines that are fine, and is then ignored when it is right.
297///
298/// ```
299/// use majordomus_cli::environment::toolchain::{satisfies, Pin};
300/// assert_eq!(satisfies("22", "22.20.0", Pin::Exact), Some(true));
301/// assert_eq!(satisfies("0.23.6", "0.23.4", Pin::Exact), Some(false));
302/// assert_eq!(satisfies("1.85", "1.90.0", Pin::Minimum), Some(true));
303/// assert_eq!(satisfies("1.22", "1.21.9", Pin::Minimum), Some(false));
304/// assert_eq!(satisfies(">=22", "22.20.0", Pin::Exact), None);
305/// assert_eq!(satisfies("stable", "1.90.0", Pin::Exact), None);
306/// ```
307pub 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            // equal-length comparison, so `1.85` and `1.85.0` are the same floor
314            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
325/// `v1.90.0` -> `[1, 90, 0]`; anything that is not dot-separated digits -> `None`.
326fn 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
342/// The version one toolchain reports, or `None` when it is not there or did not answer.
343fn 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    // `rustc 1.90.0 (1159e78c4 2025-09-14)` -> `1.90.0`; `v22.20.0` -> `22.20.0`;
354    // anything without a recognisable version number is reported as it came.
355    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
368/// The first marker of a detector that exists, with the path it resolved to.
369fn 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                // exactly one `*`, standing for one directory name: one read_dir, sorted,
379                // so the answer does not depend on the order the filesystem hands them back
380                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
400/// Read the declared version out of one marker file.
401///
402/// ```
403/// use majordomus_cli::environment::toolchain::{read_text, Declares};
404/// assert_eq!(read_text("# a comment\n\n22.20.0\n", Declares::WholeFile).as_deref(), Some("22.20.0"));
405/// assert_eq!(read_text("rust-version = \"1.85\"\n", Declares::KeyValue("rust-version")).as_deref(), Some("1.85"));
406/// assert_eq!(read_text("{\"engines\":{\"node\":\">=22\"}}", Declares::JsonMember("engines", "node")).as_deref(), Some(">=22"));
407/// assert_eq!(read_text("module x\n\ngo 1.22\n", Declares::Directive("go")).as_deref(), Some("1.22"));
408/// assert_eq!(read_text("anything", Declares::Nothing), None);
409/// ```
410pub 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    /// The point of the whole module: a toolchain the repository does not declare does not
462    /// appear, however many of them the machine has installed.
463    #[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        // Whether Go is installed on this machine is not the assertion; that the answer is
517        // decided either way, and never left as `unknown` after a full resolution, is.
518        assert_ne!(found[0].availability, ToolchainAvailability::Unknown);
519    }
520
521    /// ADR 0064: a pinned release line the machine does not have is a mismatch, and a floor
522    /// the machine is above is not.
523    #[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        // The shapes actually seen: `rustc 1.90.0 (hash date)`, `v22.20.0`, `just 1.58.0`.
565        let detector = DETECTORS.iter().find(|d| d.id == "rust").expect("rust");
566        // Only the parsing is under test here; the subprocess is exercised above.
567        assert_eq!(detector.version_args, &["--version"]);
568    }
569}