diff --git a/crates/volta-core/src/error/kind.rs b/crates/volta-core/src/error/kind.rs index 6add88efa..e5032864a 100644 --- a/crates/volta-core/src/error/kind.rs +++ b/crates/volta-core/src/error/kind.rs @@ -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, @@ -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 @@ -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, diff --git a/crates/volta-core/src/version/mod.rs b/crates/volta-core/src/version/mod.rs index 7015b89d7..ddd7c7213 100644 --- a/crates/volta-core/src/version/mod.rs +++ b/crates/volta-core/src/version/mod.rs @@ -93,8 +93,31 @@ pub fn parse_requirements(s: impl AsRef) -> Fallible { pub fn parse_version(s: impl AsRef) -> Fallible { 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::().is_ok(), + 2 => parts[0].parse::().is_ok() && parts[1].parse::().is_ok(), + _ => false, + } } // remove the leading 'v' from the version string, if present @@ -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))); + } +}