Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions crates/volta-core/src/error/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,11 @@ pub enum ErrorKind {
version: String,
},

/// Thrown when a version string appears to be a partial (incomplete) semver version
VersionParseErrorPartial {
version: String,
},

/// Thrown when there was an error writing a bin config file
WriteBinConfigError {
file: PathBuf,
Expand Down Expand Up @@ -1363,6 +1368,15 @@ To upgrade it, please use the command `{} {0}`"#,
Please verify the intended version."#,
version
),
ErrorKind::VersionParseErrorPartial { version } => write!(
f,
r#"Could not parse version "{}"

Volta requires a fully-qualified semver version (e.g. "18.0.0" instead of "18").

To install using a partial version, use `volta install node@{}` or `volta pin node@{}` on the command line, which supports semver ranges."#,
version, version, version
),
ErrorKind::WriteBinConfigError { file } => write!(
f,
"Could not write executable configuration
Expand Down Expand Up @@ -1565,6 +1579,7 @@ impl ErrorKind {
ErrorKind::UpgradePackageNotFound { .. } => ExitCode::ConfigurationError,
ErrorKind::UpgradePackageWrongManager { .. } => ExitCode::ConfigurationError,
ErrorKind::VersionParseError { .. } => ExitCode::NoVersionMatch,
ErrorKind::VersionParseErrorPartial { .. } => ExitCode::NoVersionMatch,
ErrorKind::WriteBinConfigError { .. } => ExitCode::FileSystemError,
ErrorKind::WriteDefaultNpmError { .. } => ExitCode::FileSystemError,
ErrorKind::WriteLauncherError { .. } => ExitCode::FileSystemError,
Expand Down
72 changes: 70 additions & 2 deletions crates/volta-core/src/version/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,31 @@ pub fn parse_requirements(s: impl AsRef<str>) -> Fallible<Range> {

pub fn parse_version(s: impl AsRef<str>) -> Fallible<Version> {
let s = s.as_ref();
s.parse()
.with_context(|| ErrorKind::VersionParseError { version: s.into() })
s.parse().with_context(|| {
// Detect partial versions like "18" or "18.4" and provide a more helpful error
if is_partial_version(s) {
ErrorKind::VersionParseErrorPartial {
version: s.into(),
}
} else {
ErrorKind::VersionParseError {
version: s.into(),
}
}
})
}

/// Check if a string looks like a partial (incomplete) semver version.
/// A partial version has only a major version (e.g. "18") or major.minor (e.g. "18.4")
/// but not a full major.minor.patch form.
fn is_partial_version(s: &str) -> bool {
let s = s.trim().trim_start_matches('v');
let parts: Vec<&str> = s.split('.').collect();
match parts.len() {
1 => parts[0].parse::<u64>().is_ok(),
2 => parts[0].parse::<u64>().is_ok() && parts[1].parse::<u64>().is_ok(),
_ => false,
}
}

// remove the leading 'v' from the version string, if present
Expand Down Expand Up @@ -197,3 +220,48 @@ pub mod hashmap_version_serde {
Ok(m.into_iter().map(|(k, Wrapper(v))| (k, v)).collect())
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_is_partial_version() {
// Major-only versions
assert!(is_partial_version("18"));
assert!(is_partial_version("0"));
assert!(is_partial_version("v18"));

// Major.minor versions
assert!(is_partial_version("18.4"));
assert!(is_partial_version("v18.4"));

// Full semver versions are NOT partial
assert!(!is_partial_version("18.4.0"));
assert!(!is_partial_version("v18.4.0"));

// Non-numeric strings are NOT partial
assert!(!is_partial_version("latest"));
assert!(!is_partial_version("lts"));
assert!(!is_partial_version("abc"));
assert!(!is_partial_version("18.abc"));
}

#[test]
fn test_version_spec_from_str() {
// Full versions should parse as Exact
let spec: VersionSpec = "18.4.0".parse().unwrap();
assert!(matches!(spec, VersionSpec::Exact(_)));

// Partial versions should parse as Semver (via parse_requirements fallback)
let spec: VersionSpec = "18".parse().unwrap();
assert!(matches!(spec, VersionSpec::Semver(_)));

let spec: VersionSpec = "18.4".parse().unwrap();
assert!(matches!(spec, VersionSpec::Semver(_)));

// Tags should parse as Tag
let spec: VersionSpec = "latest".parse().unwrap();
assert!(matches!(spec, VersionSpec::Tag(VersionTag::Latest)));
}
}