Skip to content

Commit bc4560a

Browse files
committed
refactor(env): share project source resolution
1 parent 6880cce commit bc4560a

2 files changed

Lines changed: 227 additions & 274 deletions

File tree

crates/vite_global_cli/src/commands/env/config.rs

Lines changed: 221 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
88
use serde::{Deserialize, Serialize};
99
use vite_js_runtime::{
10-
NodeProvider, VersionSource, normalize_version, read_package_json, resolve_node_version,
10+
NodeProvider, VersionSource, is_valid_version, normalize_version, read_package_json,
11+
resolve_node_version,
1112
};
1213
use vite_path::{AbsolutePath, AbsolutePathBuf};
1314

@@ -228,91 +229,118 @@ pub async fn resolve_version(cwd: &AbsolutePath) -> Result<VersionResolution, Er
228229
resolve_version_from_files(cwd).await
229230
}
230231

231-
/// Resolve Node.js version from project files only (skipping session overrides).
232-
///
233-
/// This is used by `vp env use` without arguments to revert to file-based resolution.
234-
pub async fn resolve_version_from_files(cwd: &AbsolutePath) -> Result<VersionResolution, Error> {
235-
let provider = NodeProvider::new();
232+
pub(crate) struct ProjectVersionSource {
233+
pub version: String,
234+
pub source: String,
235+
pub source_path: AbsolutePathBuf,
236+
pub project_root: AbsolutePathBuf,
237+
}
236238

237-
// Use shared version resolution with directory walking
238-
let resolution = resolve_node_version(cwd, true)
239+
pub(crate) async fn resolve_project_version_source(
240+
cwd: &AbsolutePath,
241+
warn_invalid: bool,
242+
) -> Result<Option<ProjectVersionSource>, Error> {
243+
let Some(resolution) = resolve_node_version(cwd, true)
239244
.await
240-
.map_err(|e| Error::ConfigError(e.to_string().into()))?;
245+
.map_err(|e| Error::ConfigError(e.to_string().into()))?
246+
else {
247+
return Ok(None);
248+
};
241249

242-
if let Some(resolution) = resolution {
243-
// Validate version before attempting resolution
244-
// If invalid, warning is printed by normalize_version and we fall through to defaults
245-
if let Some(validated) =
246-
normalize_version(&resolution.version.clone().into(), &resolution.source.to_string())
250+
if let Some(version) =
251+
validate_version_spec(&resolution.version, &resolution.source.to_string(), warn_invalid)
252+
{
253+
if let (Some(source_path), Some(project_root)) =
254+
(resolution.source_path, resolution.project_root)
247255
{
248-
// Detect if the original version spec was a range (not exact)
249-
// This includes partial versions (20, 20.18), semver ranges (^20.0.0), LTS aliases, and "latest"
250-
let is_range = NodeProvider::is_version_alias(&validated)
251-
|| !NodeProvider::is_exact_version(&validated);
252-
253-
let resolved = resolve_version_string(&validated, &provider).await?;
254-
return Ok(VersionResolution {
255-
version: resolved,
256+
return Ok(Some(ProjectVersionSource {
257+
version,
256258
source: resolution.source.to_string(),
257-
source_path: resolution.source_path,
258-
project_root: resolution.project_root,
259-
is_range,
260-
});
259+
source_path,
260+
project_root,
261+
}));
261262
}
263+
return Ok(None);
264+
}
262265

263-
// Invalid version from a project source - try lower-priority sources in the same directory.
264-
// This mirrors the fallback logic in download_runtime_for_project().
265-
// - NodeVersionFile: try devEngines.runtime, then engines.node
266-
// - DevEnginesRuntime: try engines.node
267-
if matches!(
268-
resolution.source,
269-
VersionSource::NodeVersionFile | VersionSource::DevEnginesRuntime
270-
) {
271-
if let Some(project_root) = &resolution.project_root {
272-
let package_json_path = project_root.join("package.json");
273-
if let Ok(Some(pkg)) = read_package_json(&package_json_path).await {
274-
// Try devEngines.runtime (only when falling back from .node-version)
275-
if matches!(resolution.source, VersionSource::NodeVersionFile) {
276-
if let Some(dev_engines) = pkg
277-
.dev_engines_runtime("node")
278-
.and_then(|r| r.version.clone())
279-
.and_then(|v| normalize_version(&v, "devEngines.runtime"))
280-
{
281-
let resolved = resolve_version_string(&dev_engines, &provider).await?;
282-
let is_range = NodeProvider::is_lts_alias(&dev_engines)
283-
|| !NodeProvider::is_exact_version(&dev_engines);
284-
return Ok(VersionResolution {
285-
version: resolved,
286-
source: "devEngines.runtime".into(),
287-
source_path: Some(package_json_path),
288-
project_root: Some(project_root.clone()),
289-
is_range,
290-
});
291-
}
292-
}
293-
294-
// Try engines.node
295-
if let Some(engines_node) = pkg
296-
.engines
297-
.as_ref()
298-
.and_then(|e| e.node.clone())
299-
.and_then(|v| normalize_version(&v, "engines.node"))
300-
{
301-
let resolved = resolve_version_string(&engines_node, &provider).await?;
302-
let is_range = NodeProvider::is_lts_alias(&engines_node)
303-
|| !NodeProvider::is_exact_version(&engines_node);
304-
return Ok(VersionResolution {
305-
version: resolved,
306-
source: "engines.node".into(),
307-
source_path: Some(package_json_path),
308-
project_root: Some(project_root.clone()),
309-
is_range,
310-
});
311-
}
312-
}
313-
}
314-
}
315-
// Invalid version and no valid package.json sources - fall through to user default or LTS
266+
// Invalid version from a project source: try lower-priority sources in the same directory.
267+
// This mirrors the fallback logic in download_runtime_for_project().
268+
if !matches!(
269+
resolution.source,
270+
VersionSource::NodeVersionFile | VersionSource::DevEnginesRuntime
271+
) {
272+
return Ok(None);
273+
}
274+
275+
let Some(project_root) = resolution.project_root else {
276+
return Ok(None);
277+
};
278+
let package_json_path = project_root.join("package.json");
279+
let Ok(Some(pkg)) = read_package_json(&package_json_path).await else {
280+
return Ok(None);
281+
};
282+
283+
if matches!(resolution.source, VersionSource::NodeVersionFile)
284+
&& let Some(version) = pkg
285+
.dev_engines_runtime("node")
286+
.and_then(|r| r.version.clone())
287+
.and_then(|v| validate_version_spec(&v, "devEngines.runtime", warn_invalid))
288+
{
289+
return Ok(Some(ProjectVersionSource {
290+
version,
291+
source: "devEngines.runtime".into(),
292+
source_path: package_json_path,
293+
project_root,
294+
}));
295+
}
296+
297+
if let Some(version) = pkg
298+
.engines
299+
.as_ref()
300+
.and_then(|e| e.node.clone())
301+
.and_then(|v| validate_version_spec(&v, "engines.node", warn_invalid))
302+
{
303+
return Ok(Some(ProjectVersionSource {
304+
version,
305+
source: "engines.node".into(),
306+
source_path: package_json_path,
307+
project_root,
308+
}));
309+
}
310+
311+
Ok(None)
312+
}
313+
314+
fn validate_version_spec(
315+
version: &vite_str::Str,
316+
source: &str,
317+
warn_invalid: bool,
318+
) -> Option<String> {
319+
if warn_invalid {
320+
normalize_version(version, source).map(|v| v.to_string())
321+
} else {
322+
let trimmed = version.trim();
323+
is_valid_version(trimmed).then(|| trimmed.to_string())
324+
}
325+
}
326+
327+
/// Resolve Node.js version from project files only (skipping session overrides).
328+
///
329+
/// This is used by `vp env use` without arguments to revert to file-based resolution.
330+
pub async fn resolve_version_from_files(cwd: &AbsolutePath) -> Result<VersionResolution, Error> {
331+
let provider = NodeProvider::new();
332+
333+
if let Some(project_source) = resolve_project_version_source(cwd, true).await? {
334+
let is_range = NodeProvider::is_version_alias(&project_source.version)
335+
|| !NodeProvider::is_exact_version(&project_source.version);
336+
let resolved = resolve_version_string(&project_source.version, &provider).await?;
337+
return Ok(VersionResolution {
338+
version: resolved,
339+
source: project_source.source,
340+
source_path: Some(project_source.source_path),
341+
project_root: Some(project_source.project_root),
342+
is_range,
343+
});
316344
}
317345

318346
// CLI-specific: Check user default from config
@@ -779,6 +807,120 @@ mod tests {
779807
);
780808
}
781809

810+
#[tokio::test]
811+
async fn test_project_source_detects_new_dev_engines() {
812+
let temp_dir = TempDir::new().unwrap();
813+
let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
814+
815+
assert!(resolve_project_version_source(&temp_path, false).await.unwrap().is_none());
816+
817+
tokio::fs::write(
818+
temp_path.join("package.json"),
819+
r#"{"devEngines":{"runtime":{"name":"node","version":"22.22.0"}}}"#,
820+
)
821+
.await
822+
.unwrap();
823+
824+
let source = resolve_project_version_source(&temp_path, false).await.unwrap().unwrap();
825+
assert_eq!(source.version, "22.22.0");
826+
assert_eq!(source.source, "devEngines.runtime");
827+
assert_eq!(source.source_path, temp_path.join("package.json"));
828+
}
829+
830+
#[tokio::test]
831+
async fn test_project_source_prefers_nearer_dev_engines_over_parent_node_version() {
832+
let temp_dir = TempDir::new().unwrap();
833+
let parent = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
834+
let child = parent.join("child");
835+
tokio::fs::create_dir(&child).await.unwrap();
836+
tokio::fs::write(parent.join(".node-version"), "24.18.0").await.unwrap();
837+
tokio::fs::write(
838+
child.join("package.json"),
839+
r#"{"devEngines":{"runtime":{"name":"node","version":"22.22.0"}}}"#,
840+
)
841+
.await
842+
.unwrap();
843+
844+
let source = resolve_project_version_source(&child, false).await.unwrap().unwrap();
845+
assert_eq!(source.version, "22.22.0");
846+
assert_eq!(source.source, "devEngines.runtime");
847+
assert_eq!(source.source_path, child.join("package.json"));
848+
}
849+
850+
#[tokio::test]
851+
async fn test_project_source_falls_back_from_invalid_node_version_to_dev_engines() {
852+
let temp_dir = TempDir::new().unwrap();
853+
let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
854+
tokio::fs::write(temp_path.join(".node-version"), "not-a-version").await.unwrap();
855+
tokio::fs::write(
856+
temp_path.join("package.json"),
857+
r#"{"devEngines":{"runtime":{"name":"node","version":"22.22.0"}}}"#,
858+
)
859+
.await
860+
.unwrap();
861+
862+
let source = resolve_project_version_source(&temp_path, false).await.unwrap().unwrap();
863+
assert_eq!(source.version, "22.22.0");
864+
assert_eq!(source.source, "devEngines.runtime");
865+
}
866+
867+
#[tokio::test]
868+
async fn test_project_source_falls_back_from_invalid_dev_engines_to_engines_node() {
869+
let temp_dir = TempDir::new().unwrap();
870+
let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
871+
tokio::fs::write(
872+
temp_path.join("package.json"),
873+
r#"{"devEngines":{"runtime":{"name":"node","version":"not-a-version"}},"engines":{"node":"22.22.0"}}"#,
874+
)
875+
.await
876+
.unwrap();
877+
878+
let source = resolve_project_version_source(&temp_path, false).await.unwrap().unwrap();
879+
assert_eq!(source.version, "22.22.0");
880+
assert_eq!(source.source, "engines.node");
881+
}
882+
883+
#[tokio::test]
884+
async fn test_project_source_ignores_empty_engines_node_and_keeps_walking() {
885+
let temp_dir = TempDir::new().unwrap();
886+
let parent = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
887+
let child = parent.join("child");
888+
tokio::fs::create_dir(&child).await.unwrap();
889+
tokio::fs::write(parent.join(".node-version"), "24.18.0").await.unwrap();
890+
tokio::fs::write(child.join("package.json"), r#"{"engines":{"node":""}}"#).await.unwrap();
891+
892+
let source = resolve_project_version_source(&child, false).await.unwrap().unwrap();
893+
assert_eq!(source.version, "24.18.0");
894+
assert_eq!(source.source, ".node-version");
895+
assert_eq!(source.source_path, parent.join(".node-version"));
896+
}
897+
898+
#[tokio::test]
899+
async fn test_project_source_stops_at_invalid_package_source() {
900+
let temp_dir = TempDir::new().unwrap();
901+
let parent = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
902+
let child = parent.join("child");
903+
tokio::fs::create_dir(&child).await.unwrap();
904+
tokio::fs::write(parent.join(".node-version"), "24.18.0").await.unwrap();
905+
tokio::fs::write(
906+
child.join("package.json"),
907+
r#"{"devEngines":{"runtime":{"name":"node","version":"not-a-version"}}}"#,
908+
)
909+
.await
910+
.unwrap();
911+
912+
assert!(resolve_project_version_source(&child, false).await.unwrap().is_none());
913+
}
914+
915+
#[tokio::test]
916+
async fn test_project_source_returns_none_for_invalid_project_source() {
917+
let temp_dir = TempDir::new().unwrap();
918+
let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
919+
tokio::fs::write(temp_path.join(".node-version"), "not-a-version").await.unwrap();
920+
921+
assert!(resolve_project_version_source(&temp_path, false).await.unwrap().is_none());
922+
}
923+
782924
#[tokio::test]
783925
async fn test_resolve_version_latest_alias_in_node_version() {
784926
let temp_dir = TempDir::new().unwrap();

0 commit comments

Comments
 (0)