Skip to main content

aube_runtime/
spec.rs

1//! Node version request parsing: exact versions, semver ranges, and
2//! the alias vocabulary `pnpm runtime` / nvm users expect (`lts`,
3//! `latest`, LTS codenames like `jod` / `lts/jod`).
4
5use crate::error::Error;
6
7/// A parsed Node version request.
8#[derive(Debug, Clone, PartialEq)]
9pub enum NodeSpec {
10    Exact(node_semver::Version),
11    Range(node_semver::Range),
12    /// Newest LTS release (`lts`, `lts/*`).
13    Lts,
14    /// Newest release of any kind (`latest`, `current`, `node`).
15    Latest,
16    /// A named LTS line (`jod`, `lts/jod`, `lts/iron`). Stored
17    /// lowercased; validated against the dist index at resolve time.
18    LtsCodename(String),
19}
20
21impl NodeSpec {
22    /// Parse a user-written request. Accepts a leading `v` on
23    /// versions and either `lts/<name>` or a bare codename.
24    pub fn parse(raw: &str) -> Result<NodeSpec, Error> {
25        let s = raw.trim();
26        let lowered = s.to_ascii_lowercase();
27        match lowered.as_str() {
28            "lts" | "lts/*" => return Ok(NodeSpec::Lts),
29            "latest" | "current" | "node" | "*" => return Ok(NodeSpec::Latest),
30            _ => {}
31        }
32        if let Some(name) = lowered.strip_prefix("lts/") {
33            if !name.is_empty() && name.chars().all(|c| c.is_ascii_alphabetic()) {
34                return Ok(NodeSpec::LtsCodename(name.to_string()));
35            }
36            return Err(Error::NoMatchingVersion {
37                requested: raw.to_string(),
38                platform_note: String::new(),
39            });
40        }
41        let unprefixed = s.strip_prefix('v').unwrap_or(s);
42        if let Ok(v) = node_semver::Version::parse(unprefixed) {
43            return Ok(NodeSpec::Exact(v));
44        }
45        if let Ok(r) = node_semver::Range::parse(unprefixed) {
46            return Ok(NodeSpec::Range(r));
47        }
48        // Bare alphabetic word → treat as an LTS codename (`jod`,
49        // `iron`); resolution fails cleanly if the index has no such
50        // LTS line.
51        if !lowered.is_empty() && lowered.chars().all(|c| c.is_ascii_alphabetic()) {
52            return Ok(NodeSpec::LtsCodename(lowered));
53        }
54        Err(Error::NoMatchingVersion {
55            requested: raw.to_string(),
56            platform_note: String::new(),
57        })
58    }
59
60    /// Whether `version` satisfies this request, for the parts of the
61    /// vocabulary that are decidable without the dist index. `Lts` /
62    /// `Latest` / codenames return `None` — satisfaction depends on
63    /// index data the caller may not have.
64    pub fn satisfied_by(&self, version: &node_semver::Version) -> Option<bool> {
65        match self {
66            NodeSpec::Exact(v) => Some(v == version),
67            NodeSpec::Range(r) => Some(version.satisfies(r)),
68            NodeSpec::Lts | NodeSpec::Latest | NodeSpec::LtsCodename(_) => None,
69        }
70    }
71
72    /// The request as the user would write it (used for messages and
73    /// for the lockfile `specifier`).
74    pub fn display(&self) -> String {
75        match self {
76            NodeSpec::Exact(v) => v.to_string(),
77            NodeSpec::Range(r) => r.to_string(),
78            NodeSpec::Lts => "lts".to_string(),
79            NodeSpec::Latest => "latest".to_string(),
80            NodeSpec::LtsCodename(name) => format!("lts/{name}"),
81        }
82    }
83}
84
85impl std::str::FromStr for NodeSpec {
86    type Err = Error;
87
88    fn from_str(s: &str) -> Result<Self, Self::Err> {
89        NodeSpec::parse(s)
90    }
91}
92
93impl std::fmt::Display for NodeSpec {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        f.write_str(&self.display())
96    }
97}
98
99/// Where a node version request came from, in precedence order.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum RequestSource {
102    /// `package.json` `devEngines.runtime` (name == node).
103    DevEngines,
104    /// A `.node-version` file.
105    NodeVersionFile,
106    /// A `.nvmrc` file.
107    Nvmrc,
108}
109
110impl RequestSource {
111    pub fn label(self) -> &'static str {
112        match self {
113            RequestSource::DevEngines => "devEngines.runtime",
114            RequestSource::NodeVersionFile => ".node-version",
115            RequestSource::Nvmrc => ".nvmrc",
116        }
117    }
118}
119
120/// A fully-formed request: what version, what to do when it can't be
121/// satisfied locally, and where the requirement came from.
122#[derive(Debug, Clone)]
123pub struct NodeRequest {
124    pub spec: NodeSpec,
125    /// The request exactly as the user wrote it (`"22"`, `"^24.4.0"`,
126    /// `"lts/jod"`). Lockfile specifiers and display both use this —
127    /// `NodeSpec::display()` normalizes ranges, and a normalized form
128    /// would never string-match the verbatim range pnpm records.
129    pub raw: String,
130    pub on_fail: aube_manifest::OnFail,
131    pub source: RequestSource,
132    /// Path of the file the request was read from (version file or
133    /// package.json), for diagnostics.
134    pub origin: std::path::PathBuf,
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    fn parse(s: &str) -> NodeSpec {
142        NodeSpec::parse(s).unwrap()
143    }
144
145    #[test]
146    fn parses_exact_versions() {
147        assert!(matches!(parse("22.1.0"), NodeSpec::Exact(_)));
148        assert!(matches!(parse("v22.1.0"), NodeSpec::Exact(_)));
149        assert!(matches!(parse("18.0.0-rc.1"), NodeSpec::Exact(_)));
150    }
151
152    #[test]
153    fn parses_ranges() {
154        assert!(matches!(parse("^22"), NodeSpec::Range(_)));
155        assert!(matches!(parse(">=18 <21"), NodeSpec::Range(_)));
156        assert!(matches!(parse("22"), NodeSpec::Range(_)));
157        assert!(matches!(parse("22.x"), NodeSpec::Range(_)));
158        assert!(matches!(parse("~18.12"), NodeSpec::Range(_)));
159    }
160
161    #[test]
162    fn parses_aliases() {
163        assert_eq!(parse("lts"), NodeSpec::Lts);
164        assert_eq!(parse("LTS"), NodeSpec::Lts);
165        assert_eq!(parse("lts/*"), NodeSpec::Lts);
166        assert_eq!(parse("latest"), NodeSpec::Latest);
167        assert_eq!(parse("current"), NodeSpec::Latest);
168        assert_eq!(parse("node"), NodeSpec::Latest);
169        assert_eq!(parse("*"), NodeSpec::Latest);
170        assert_eq!(parse("lts/jod"), NodeSpec::LtsCodename("jod".into()));
171        assert_eq!(parse("lts/Jod"), NodeSpec::LtsCodename("jod".into()));
172        assert_eq!(parse("jod"), NodeSpec::LtsCodename("jod".into()));
173    }
174
175    #[test]
176    fn rejects_garbage() {
177        assert!(NodeSpec::parse("lts/").is_err());
178        assert!(NodeSpec::parse("not a spec !!").is_err());
179        assert!(NodeSpec::parse("").is_err());
180    }
181
182    #[test]
183    fn local_satisfaction() {
184        let v: node_semver::Version = "22.3.0".parse().unwrap();
185        assert_eq!(parse("^22").satisfied_by(&v), Some(true));
186        assert_eq!(parse("^20").satisfied_by(&v), Some(false));
187        assert_eq!(parse("22.3.0").satisfied_by(&v), Some(true));
188        assert_eq!(parse("lts").satisfied_by(&v), None);
189        assert_eq!(parse("jod").satisfied_by(&v), None);
190    }
191
192    #[test]
193    fn bare_number_is_a_range() {
194        // "22" must match every 22.x.y, not just 22.0.0.
195        let v: node_semver::Version = "22.9.1".parse().unwrap();
196        assert_eq!(parse("22").satisfied_by(&v), Some(true));
197    }
198}