1use crate::error::Error;
7use crate::spec::{NodeRequest, NodeSpec, RequestSource};
8use std::path::Path;
9
10pub 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 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
68fn 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
77pub 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 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 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 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}