Skip to main content

aube_runtime/
sources.rs

1//! Version-file discovery: `.node-version` and `.nvmrc`, searched
2//! upward from the project directory. `devEngines.runtime` is *not*
3//! read here — the caller already has parsed manifests and merges it
4//! in at higher precedence via [`effective_request`].
5
6use crate::error::Error;
7use crate::spec::{NodeRequest, NodeSpec, RequestSource};
8use std::path::Path;
9
10/// Walk upward from `start_dir` looking for `.node-version` then
11/// `.nvmrc` (same-directory precedence: `.node-version` wins; nearer
12/// directory beats farther). The walk stops after checking the home
13/// directory (inclusive) or the filesystem root, whichever comes
14/// first — predictable, and matches what nvm/fnm users expect in
15/// monorepos where the version file sits above the invocation dir.
16///
17/// A file that exists but doesn't parse as a version request is
18/// logged and treated as absent rather than failing the command — a
19/// stray `.nvmrc` containing prose shouldn't break `aubr test`.
20pub fn find_version_file(start_dir: &Path) -> Option<NodeRequest> {
21    let home = aube_util::env::home_dir();
22    let mut dir = Some(start_dir);
23    while let Some(d) = dir {
24        for (file, source) in [
25            (".node-version", RequestSource::NodeVersionFile),
26            (".nvmrc", RequestSource::Nvmrc),
27        ] {
28            let path = d.join(file);
29            let Ok(raw) = std::fs::read_to_string(&path) else {
30                continue;
31            };
32            let trimmed = first_meaningful_line(&raw);
33            if trimmed.is_empty() {
34                continue;
35            }
36            match NodeSpec::parse(trimmed) {
37                Ok(spec) => {
38                    return Some(NodeRequest {
39                        spec,
40                        raw: trimmed.to_string(),
41                        // Version files have no onFail vocabulary; the
42                        // whole point of writing one is "use this
43                        // version", so missing versions download. The
44                        // runtimeOnFail setting overrides at the
45                        // integration layer.
46                        on_fail: aube_manifest::OnFail::Download,
47                        source,
48                        origin: path,
49                    });
50                }
51                Err(_) => {
52                    tracing::warn!(
53                        path = %path.display(),
54                        content = trimmed,
55                        "ignoring unparseable node version file"
56                    );
57                }
58            }
59        }
60        if home.as_deref() == Some(d) {
61            break;
62        }
63        dir = d.parent();
64    }
65    None
66}
67
68/// First non-empty, non-comment line. nvm tolerates comments
69/// (`# comment`) and surrounding whitespace in `.nvmrc`.
70fn first_meaningful_line(raw: &str) -> &str {
71    raw.lines()
72        .map(str::trim)
73        .find(|l| !l.is_empty() && !l.starts_with('#'))
74        .unwrap_or("")
75}
76
77/// Build the effective request for a project: `devEngines.runtime`
78/// (passed in pre-parsed by the caller) beats version files.
79///
80/// `dev_engines` carries the manifest's node runtime entry, if any:
81/// `(version range, declared onFail, manifest path)`. An entry without
82/// a `version` is treated as "no requirement".
83pub fn effective_request(
84    dev_engines: Option<(&str, Option<aube_manifest::OnFail>, &Path)>,
85    start_dir: &Path,
86) -> Result<Option<NodeRequest>, Error> {
87    if let Some((range, on_fail, manifest_path)) = dev_engines {
88        let spec = NodeSpec::parse(range)?;
89        return Ok(Some(NodeRequest {
90            spec,
91            raw: range.to_string(),
92            // The OpenJS spec defaults a missing onFail to `error`.
93            on_fail: on_fail.unwrap_or(aube_manifest::OnFail::Error),
94            source: RequestSource::DevEngines,
95            origin: manifest_path.to_path_buf(),
96        }));
97    }
98    Ok(find_version_file(start_dir))
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn finds_nvmrc_in_parent() {
107        let tmp = tempfile::tempdir().unwrap();
108        std::fs::write(tmp.path().join(".nvmrc"), "v22.1.0\n").unwrap();
109        let nested = tmp.path().join("a/b");
110        std::fs::create_dir_all(&nested).unwrap();
111        let req = find_version_file(&nested).unwrap();
112        assert_eq!(req.source, RequestSource::Nvmrc);
113        assert_eq!(req.spec, NodeSpec::Exact("22.1.0".parse().unwrap()));
114        assert_eq!(req.on_fail, aube_manifest::OnFail::Download);
115    }
116
117    #[test]
118    fn node_version_beats_nvmrc_in_same_dir() {
119        let tmp = tempfile::tempdir().unwrap();
120        std::fs::write(tmp.path().join(".nvmrc"), "20").unwrap();
121        std::fs::write(tmp.path().join(".node-version"), "22").unwrap();
122        let req = find_version_file(tmp.path()).unwrap();
123        assert_eq!(req.source, RequestSource::NodeVersionFile);
124    }
125
126    #[test]
127    fn nearer_nvmrc_beats_farther_node_version() {
128        let tmp = tempfile::tempdir().unwrap();
129        std::fs::write(tmp.path().join(".node-version"), "20").unwrap();
130        let nested = tmp.path().join("proj");
131        std::fs::create_dir_all(&nested).unwrap();
132        std::fs::write(nested.join(".nvmrc"), "22").unwrap();
133        let req = find_version_file(&nested).unwrap();
134        assert_eq!(req.source, RequestSource::Nvmrc);
135    }
136
137    #[test]
138    fn unparseable_file_is_skipped_and_walk_continues() {
139        let tmp = tempfile::tempdir().unwrap();
140        std::fs::write(tmp.path().join(".nvmrc"), "22").unwrap();
141        let nested = tmp.path().join("proj");
142        std::fs::create_dir_all(&nested).unwrap();
143        std::fs::write(nested.join(".nvmrc"), "definitely not a version !!!").unwrap();
144        let req = find_version_file(&nested).unwrap();
145        assert_eq!(req.origin, tmp.path().join(".nvmrc"));
146    }
147
148    #[test]
149    fn comments_and_blank_lines_are_tolerated() {
150        let tmp = tempfile::tempdir().unwrap();
151        std::fs::write(
152            tmp.path().join(".nvmrc"),
153            "# pinned for CI\n\n  lts/jod  \n",
154        )
155        .unwrap();
156        let req = find_version_file(tmp.path()).unwrap();
157        assert_eq!(req.spec, NodeSpec::LtsCodename("jod".into()));
158    }
159
160    #[test]
161    fn no_file_returns_none() {
162        let tmp = tempfile::tempdir().unwrap();
163        // The walk can escape the tempdir upward; only assert when the
164        // ancestor chain is clean of version files (always true on CI,
165        // usually true locally — gate on it instead of flaking).
166        let mut ancestor_has_file = false;
167        let mut d = tmp.path().parent();
168        while let Some(p) = d {
169            if p.join(".nvmrc").exists() || p.join(".node-version").exists() {
170                ancestor_has_file = true;
171                break;
172            }
173            d = p.parent();
174        }
175        if !ancestor_has_file {
176            assert!(find_version_file(tmp.path()).is_none());
177        }
178    }
179
180    #[test]
181    fn dev_engines_beats_version_files() {
182        let tmp = tempfile::tempdir().unwrap();
183        std::fs::write(tmp.path().join(".nvmrc"), "20").unwrap();
184        let manifest = tmp.path().join("package.json");
185        let req = effective_request(Some(("^22", None, manifest.as_path())), tmp.path())
186            .unwrap()
187            .unwrap();
188        assert_eq!(req.source, RequestSource::DevEngines);
189        // Spec default: missing onFail means error.
190        assert_eq!(req.on_fail, aube_manifest::OnFail::Error);
191    }
192
193    #[test]
194    fn dev_engines_on_fail_is_honored() {
195        let tmp = tempfile::tempdir().unwrap();
196        let manifest = tmp.path().join("package.json");
197        let req = effective_request(
198            Some((
199                "^22",
200                Some(aube_manifest::OnFail::Download),
201                manifest.as_path(),
202            )),
203            tmp.path(),
204        )
205        .unwrap()
206        .unwrap();
207        assert_eq!(req.on_fail, aube_manifest::OnFail::Download);
208    }
209}