From 6cdfa5dadf637364324613b980b3062c10bb0af6 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Sat, 26 Jul 2025 18:52:41 +0400 Subject: [PATCH] feat: better node version management and resolution --- Cargo.toml | 2 +- src/bundler.rs | 2 +- src/main.rs | 1 + src/node_downloader.rs | 28 ++- src/node_version_manager.rs | 373 ++++++++++++++++++++++++++++++++++++ 5 files changed, 402 insertions(+), 4 deletions(-) create mode 100644 src/node_version_manager.rs diff --git a/Cargo.toml b/Cargo.toml index 29f970d..58e80a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ edition = "2021" [dependencies] clap = { version = "4.4", features = ["derive"] } tokio = { version = "1.0", features = ["full"] } -reqwest = { version = "0.12", features = ["stream"] } +reqwest = { version = "0.12", features = ["stream", "json"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" lazy_static = "1.4" diff --git a/src/bundler.rs b/src/bundler.rs index c8deb10..250c27a 100644 --- a/src/bundler.rs +++ b/src/bundler.rs @@ -67,7 +67,7 @@ pub async fn bundle_project( let output_path = resolve_output_path(output_path, &app_name, custom_name.as_deref())?; // 6. Ensure portable Node binary is available. - let node_downloader = NodeDownloader::new_with_persistent_cache(node_version.clone())?; + let node_downloader = NodeDownloader::new_with_persistent_cache(&node_version).await?; let node_executable = node_downloader.ensure_node_binary().await?; let node_root = node_executable .parent() diff --git a/src/main.rs b/src/main.rs index f39ceed..7a68aee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod bundler; mod node_downloader; +mod node_version_manager; mod platform; use clap::{Parser, Subcommand}; diff --git a/src/node_downloader.rs b/src/node_downloader.rs index 04e2367..4c2dbf8 100644 --- a/src/node_downloader.rs +++ b/src/node_downloader.rs @@ -1,3 +1,4 @@ +use crate::node_version_manager::NodeVersionManager; use crate::platform::Platform; use anyhow::{Context, Result}; use futures_util::StreamExt; @@ -16,18 +17,41 @@ pub struct NodeDownloader { platform: Platform, cache_dir: PathBuf, node_version: String, + version_resolver: NodeVersionManager, } impl NodeDownloader { - pub fn new_with_persistent_cache(node_version: String) -> Result { + pub async fn new_with_persistent_cache(version_spec: &str) -> Result { let cache_dir = Self::get_persistent_cache_dir()?; + let version_resolver = NodeVersionManager::new(); + + // Resolve the version specification to a concrete version + let resolved_version = version_resolver + .resolve_version(version_spec) + .await + .context(format!( + "Failed to resolve Node.js version '{}'", + version_spec + ))?; + + println!( + "Resolved '{}' to Node.js version {}", + version_spec, resolved_version + ); + Ok(Self { platform: Platform::current(), cache_dir, - node_version, + node_version: resolved_version, + version_resolver, }) } + /// Get the resolved Node.js version + pub fn get_version(&self) -> &str { + &self.node_version + } + fn get_persistent_cache_dir() -> Result { let cache_dir = if let Some(cache_home) = std::env::var_os("XDG_CACHE_HOME") { PathBuf::from(cache_home).join("banderole") diff --git a/src/node_version_manager.rs b/src/node_version_manager.rs new file mode 100644 index 0000000..ac90570 --- /dev/null +++ b/src/node_version_manager.rs @@ -0,0 +1,373 @@ +use anyhow::{Context, Result}; +use lazy_static::lazy_static; +use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; +use std::sync::Mutex; +use tokio::time::{Duration, Instant}; + +lazy_static! { + static ref VERSION_CACHE: Mutex = Mutex::new(VersionCache::new()); +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeVersion { + pub version: String, + pub date: String, +} + +#[derive(Debug, Clone)] +struct VersionCache { + versions: Vec, + last_updated: Option, + cache_duration: Duration, +} + +impl VersionCache { + fn new() -> Self { + Self { + versions: Vec::new(), + last_updated: None, + cache_duration: Duration::from_secs(3600), // 1 hour + } + } + + fn is_expired(&self) -> bool { + match self.last_updated { + Some(last_updated) => last_updated.elapsed() > self.cache_duration, + None => true, + } + } + + fn update(&mut self, versions: Vec) { + self.versions = versions; + self.last_updated = Some(Instant::now()); + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ParsedVersion { + pub major: u32, + pub minor: Option, + pub patch: Option, +} + +impl ParsedVersion { + pub fn new(major: u32, minor: Option, patch: Option) -> Self { + Self { + major, + minor, + patch, + } + } + + pub fn matches(&self, other: &ParsedVersion) -> bool { + if self.major != other.major { + return false; + } + + if let Some(self_minor) = self.minor { + if let Some(other_minor) = other.minor { + if self_minor != other_minor { + return false; + } + } else { + return false; + } + } + + if let Some(self_patch) = self.patch { + if let Some(other_patch) = other.patch { + if self_patch != other_patch { + return false; + } + } else { + return false; + } + } + + true + } + + pub fn to_string(&self) -> String { + match (self.minor, self.patch) { + (Some(minor), Some(patch)) => format!("{}.{}.{}", self.major, minor, patch), + (Some(minor), None) => format!("{}.{}", self.major, minor), + (None, None) => format!("{}", self.major), + (None, Some(_)) => unreachable!("Cannot have patch without minor"), + } + } +} + +impl PartialOrd for ParsedVersion { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for ParsedVersion { + fn cmp(&self, other: &Self) -> Ordering { + match self.major.cmp(&other.major) { + Ordering::Equal => {} + other => return other, + } + + match (self.minor, other.minor) { + (Some(a), Some(b)) => match a.cmp(&b) { + Ordering::Equal => {} + other => return other, + }, + (Some(_), None) => return Ordering::Greater, + (None, Some(_)) => return Ordering::Less, + (None, None) => {} + } + + match (self.patch, other.patch) { + (Some(a), Some(b)) => a.cmp(&b), + (Some(_), None) => Ordering::Greater, + (None, Some(_)) => Ordering::Less, + (None, None) => Ordering::Equal, + } + } +} + +impl Eq for ParsedVersion {} + +pub struct NodeVersionManager { + client: reqwest::Client, +} + +impl NodeVersionManager { + pub fn new() -> Self { + Self { + client: reqwest::Client::new(), + } + } + + /// Resolve a version specification like "23", "23.5", "v22.1.0" to a complete version + pub async fn resolve_version(&self, version_spec: &str) -> Result { + let versions = self.fetch_versions().await?; + let parsed_spec = self.parse_version_spec(version_spec)?; + + let matching_versions = self.find_matching_versions(&versions, &parsed_spec); + + if matching_versions.is_empty() { + anyhow::bail!("No Node.js version found matching '{}'", version_spec); + } + + // Return the latest matching version + let latest = matching_versions.last().unwrap(); + Ok(latest.version.trim_start_matches('v').to_string()) + } + + /// Get the latest version for a major version (e.g., latest v23.x.x) + pub async fn get_latest_for_major(&self, major: u32) -> Result { + self.resolve_version(&major.to_string()).await + } + + /// List all available versions matching a pattern + pub async fn list_versions(&self, pattern: Option<&str>) -> Result> { + let versions = self.fetch_versions().await?; + + let filtered_versions = if let Some(pattern) = pattern { + let parsed_spec = self.parse_version_spec(pattern)?; + self.find_matching_versions(&versions, &parsed_spec) + } else { + versions.iter().collect() + }; + + Ok(filtered_versions + .iter() + .map(|v| v.version.trim_start_matches('v').to_string()) + .collect()) + } + + async fn fetch_versions(&self) -> Result> { + // Check cache first + { + let cache = VERSION_CACHE + .lock() + .map_err(|e| anyhow::anyhow!("Failed to acquire cache lock: {}", e))?; + + if !cache.is_expired() && !cache.versions.is_empty() { + return Ok(cache.versions.clone()); + } + } + + // Fetch from Node.js API + let url = "https://nodejs.org/dist/index.json"; + let response = self + .client + .get(url) + .timeout(Duration::from_secs(30)) + .send() + .await + .context("Failed to fetch Node.js versions")?; + + if !response.status().is_success() { + anyhow::bail!("Failed to fetch versions: HTTP {}", response.status()); + } + + let mut versions: Vec = response + .json() + .await + .context("Failed to parse Node.js versions JSON")?; + + // Sort versions (Node.js API returns them in reverse chronological order) + versions.sort_by(|a, b| { + let version_a = self.parse_node_version(&a.version).unwrap_or_default(); + let version_b = self.parse_node_version(&b.version).unwrap_or_default(); + version_a.cmp(&version_b) + }); + + // Update cache + { + let mut cache = VERSION_CACHE + .lock() + .map_err(|e| anyhow::anyhow!("Failed to acquire cache lock: {}", e))?; + cache.update(versions.clone()); + } + + Ok(versions) + } + + fn parse_version_spec(&self, spec: &str) -> Result { + let cleaned = spec.trim().trim_start_matches('v'); + + let parts: Vec<&str> = cleaned.split('.').collect(); + + match parts.len() { + 1 => { + let major = parts[0] + .parse::() + .context("Invalid major version number")?; + Ok(ParsedVersion::new(major, None, None)) + } + 2 => { + let major = parts[0] + .parse::() + .context("Invalid major version number")?; + let minor = parts[1] + .parse::() + .context("Invalid minor version number")?; + Ok(ParsedVersion::new(major, Some(minor), None)) + } + 3 => { + let major = parts[0] + .parse::() + .context("Invalid major version number")?; + let minor = parts[1] + .parse::() + .context("Invalid minor version number")?; + let patch = parts[2] + .parse::() + .context("Invalid patch version number")?; + Ok(ParsedVersion::new(major, Some(minor), Some(patch))) + } + _ => anyhow::bail!("Invalid version specification: {}", spec), + } + } + + fn parse_node_version(&self, version: &str) -> Result { + self.parse_version_spec(version) + } + + fn find_matching_versions<'a>( + &self, + versions: &'a [NodeVersion], + spec: &ParsedVersion, + ) -> Vec<&'a NodeVersion> { + let mut matching = Vec::new(); + + for version in versions { + if let Ok(parsed) = self.parse_node_version(&version.version) { + if spec.matches(&parsed) { + matching.push(version); + } + } + } + + // Sort by version number + matching.sort_by(|a, b| { + let version_a = self.parse_node_version(&a.version).unwrap_or_default(); + let version_b = self.parse_node_version(&b.version).unwrap_or_default(); + version_a.cmp(&version_b) + }); + + matching + } +} + +impl Default for ParsedVersion { + fn default() -> Self { + Self { + major: 0, + minor: Some(0), + patch: Some(0), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_version_spec() { + let resolver = NodeVersionManager::new(); + + assert_eq!( + resolver.parse_version_spec("23").unwrap(), + ParsedVersion::new(23, None, None) + ); + + assert_eq!( + resolver.parse_version_spec("23.5").unwrap(), + ParsedVersion::new(23, Some(5), None) + ); + + assert_eq!( + resolver.parse_version_spec("v22.1.0").unwrap(), + ParsedVersion::new(22, Some(1), Some(0)) + ); + + assert_eq!( + resolver.parse_version_spec("18.17.0").unwrap(), + ParsedVersion::new(18, Some(17), Some(0)) + ); + } + + #[test] + fn test_version_matching() { + let spec = ParsedVersion::new(23, None, None); + let version1 = ParsedVersion::new(23, Some(0), Some(0)); + let version2 = ParsedVersion::new(23, Some(5), Some(1)); + let version3 = ParsedVersion::new(22, Some(1), Some(0)); + + assert!(spec.matches(&version1)); + assert!(spec.matches(&version2)); + assert!(!spec.matches(&version3)); + } + + #[test] + fn test_version_ordering() { + let v1 = ParsedVersion::new(18, Some(17), Some(0)); + let v2 = ParsedVersion::new(18, Some(17), Some(1)); + let v3 = ParsedVersion::new(18, Some(18), Some(0)); + let v4 = ParsedVersion::new(19, Some(0), Some(0)); + + assert!(v1 < v2); + assert!(v2 < v3); + assert!(v3 < v4); + } + + #[tokio::test] + async fn test_version_resolution() { + let resolver = NodeVersionManager::new(); + + // This test requires internet connection + if let Ok(version) = resolver.resolve_version("18").await { + assert!(version.starts_with("18.")); + println!("Resolved '18' to: {}", version); + } + } +}