use anyhow::{Context as _, Result, anyhow, bail};use async_compression::futures::bufread::GzipDecoder;use async_tar::Archive;use chrono::{DateTime, Utc};use futures::{AsyncReadExt, FutureExt as _, channel::oneshot, future::Shared};use http_client::{Host, HttpClient, Url};use log::Level;use semver::Version;use serde::Deserialize;use smol::io::BufReader;use smol::{fs, lock::Mutex};use std::collections::HashMap;use std::fmt::Display;use std::{ env::{self, consts}, ffi::OsString, io, net::{IpAddr, Ipv4Addr}, path::{Path, PathBuf}, process::Output, sync::Arc,};use util::ResultExt;use util::archive::extract_zip; const NODE_CA_CERTS_ENV_VAR: &str = "NODE_EXTRA_CA_CERTS"; #[derive(Clone, Debug, Default, Eq, PartialEq)]pub struct NodeBinaryOptions { pub allow_path_lookup: bool, pub allow_binary_download: bool, pub use_paths: Option<(PathBuf, PathBuf)>,} /// Use this when you need to launch npm as a long-lived process (for example, an agent server),/// so the invocation and environment stay consistent with the Node runtime's proxy and CA setup.#[derive(Clone, Debug)]pub struct NpmCommand { pub path: PathBuf, pub args: Vec<String>, pub env: HashMap<String, String>,} pub enum VersionStrategy<'a> { /// Install if current version doesn't match pinned version Pin(&'a Version), /// Install if current version is older than latest version Latest(&'a Version),} #[derive(Clone)]pub struct NodeRuntime(Arc<Mutex<NodeRuntimeState>>); struct NodeRuntimeState { http: Arc<dyn HttpClient>, instance: Option<Box<dyn NodeRuntimeTrait>>, last_options: Option<NodeBinaryOptions>, options: watch::Receiver<Option<NodeBinaryOptions>>, shell_env_loaded: Shared<oneshot::Receiver<()>>,} impl NodeRuntime { pub fn new( http: Arc<dyn HttpClient>, shell_env_loaded: Option<oneshot::Receiver<()>>, options: watch::Receiver<Option<NodeBinaryOptions>>, ) -> Self { NodeRuntime(Arc::new(Mutex::new(NodeRuntimeState { http, instance: None, last_options: None, options, shell_env_loaded: shell_env_loaded.unwrap_or(oneshot::channel().1).shared(), }))) } pub fn unavailable() -> Self { NodeRuntime(Arc::new(Mutex::new(NodeRuntimeState { http: Arc::new(http_client::BlockedHttpClient), instance: None, last_options: None, options: watch::channel(Some(NodeBinaryOptions::default())).1, shell_env_loaded: oneshot::channel().1.shared(), }))) } async fn instance(&self) -> Box<dyn NodeRuntimeTrait> { let mut state = self.0.lock().await; let options = loop { if let Some(options) = state.options.borrow().as_ref() { break options.clone(); } match state.options.changed().await { Ok(()) => {} // failure case not cached Err(err) => { return Box::new(UnavailableNodeRuntime { error_message: err.to_string().into(), }) as Box<dyn NodeRuntimeTrait>; } } }; if state.last_options.as_ref() != Some(&options) { state.instance.take(); } if let Some(instance) = state.instance.as_ref() { return instance.boxed_clone(); } if let Some((node, npm)) = options.use_paths.as_ref() { let instance = match SystemNodeRuntime::new(node.clone(), npm.clone()).await { Ok(instance) => { log::info!("using Node.js from `node.path` in settings: {:?}", instance); Box::new(instance) } Err(err) => { // failure case not cached, since it's cheap to check again return Box::new(UnavailableNodeRuntime { error_message: format!( "failure checking Node.js from `node.path` in settings ({}): {:?}", node.display(), err ) .into(), }); } }; state.instance = Some(instance.boxed_clone()); state.last_options = Some(options); return instance; } let system_node_error = if options.allow_path_lookup { state.shell_env_loaded.clone().await.ok(); match SystemNodeRuntime::detect().await { Ok(instance) => { log::info!("using Node.js found on PATH: {:?}", instance); state.instance = Some(instance.boxed_clone()); state.last_options = Some(options); return Box::new(instance) as Box<dyn NodeRuntimeTrait>; } Err(err) => Some(err), } } else { None }; let instance = if options.allow_binary_download { let (log_level, why_using_managed) = match system_node_error { Some(err @ DetectError::Other(_)) => (Level::Warn, err.to_string()), Some(err @ DetectError::NotInPath(_)) => (Level::Info, err.to_string()), None => ( Level::Info, "`node.ignore_system_version` is `true` in settings".to_string(), ), }; match ManagedNodeRuntime::install_if_needed(&state.http).await { Ok(instance) => { log::log!( log_level, "using Zed managed Node.js at {} since {}", instance.installation_path.display(), why_using_managed ); Box::new(instance) as Box<dyn NodeRuntimeTrait> } Err(err) => { // failure case is cached, since downloading + installing may be expensive. The // downside of this is that it may fail due to an intermittent network issue. // // TODO: Have `install_if_needed` indicate which failure cases are retryable // and/or have shared tracking of when internet is available. Box::new(UnavailableNodeRuntime { error_message: format!( "failure while downloading and/or installing Zed managed Node.js, \ restart Zed to retry: {}", err ) .into(), }) as Box<dyn NodeRuntimeTrait> } } } else if let Some(system_node_error) = system_node_error { // failure case not cached, since it's cheap to check again // // TODO: When support is added for setting `options.allow_binary_download`, update this // error message. return Box::new(UnavailableNodeRuntime { error_message: format!( "failure while checking system Node.js from PATH: {}", system_node_error ) .into(), }); } else { // failure case is cached because it will always happen with these options // // TODO: When support is added for setting `options.allow_binary_download`, update this // error message. Box::new(UnavailableNodeRuntime { error_message: "`node` settings do not allow any way to use Node.js" .to_string() .into(), }) }; state.instance = Some(instance.boxed_clone()); state.last_options = Some(options); instance } pub async fn binary_path(&self) -> Result<PathBuf> { self.instance().await.binary_path() } pub async fn run_npm_subcommand( &self, directory: Option<&Path>, subcommand: &str, args: &[&str], ) -> Result<Output> { let http = self.0.lock().await.http.clone(); self.instance() .await .run_npm_subcommand(directory, http.proxy(), subcommand, args) .await } pub async fn npm_package_installed_version( &self, local_package_directory: &Path, name: &str, ) -> Result<Option<Version>> { self.instance() .await .npm_package_installed_version(local_package_directory, name) .await } pub async fn npm_command( &self, prefix_dir: Option<&Path>, subcommand: &str, args: &[&str], ) -> Result<NpmCommand> { let http = self.0.lock().await.http.clone(); self.instance() .await .npm_command(prefix_dir, http.proxy(), subcommand, args) .await } pub async fn npm_package_latest_version(&self, name: &str) -> Result<Version> { let http = self.0.lock().await.http.clone(); let instance = self.instance().await; let output = instance .run_npm_subcommand( None, http.proxy(), "info", &[ name, "--json", "--fetch-retry-mintimeout", "2000", "--fetch-retry-maxtimeout", "5000", "--fetch-timeout", "5000", ], ) .await?; let info: NpmInfo = serde_json::from_slice(&output.stdout)?; let before = npm_config_before(instance.as_ref(), http.proxy()) .await .context("getting npm before config") .log_err() .flatten(); let latest_dist_tag = info.dist_tags.latest.clone(); let selected_version = select_npm_package_version(name, info, before.as_deref())?; log::debug!( "selected latest npm package version package={name:?} before={before:?} dist_tag_latest={latest_dist_tag:?} selected={selected_version}" ); Ok(selected_version) } pub async fn npm_install_packages( &self, directory: &Path, packages: &[(&str, &str)], ) -> Result<()> { if packages.is_empty() { return Ok(()); } log::debug!( "installing npm packages directory={} packages={packages:?}", directory.display() ); let packages: Vec<_> = packages .iter() .map(|(name, version)| format!("{name}@{version}")) .collect(); let arguments: Vec<_> = packages .iter() .map(|p| p.as_str()) .chain([ "--save-exact", "--fetch-retry-mintimeout", "2000", "--fetch-retry-maxtimeout", "5000", "--fetch-timeout", "5000", ]) .collect(); // This is also wrong because the directory is wrong. self.run_npm_subcommand(Some(directory), "install", &arguments) .await?; Ok(()) } pub async fn npm_install_latest_packages( &self, directory: &Path, package_names: &[&str], ) -> Result<()> { // Let npm apply user config such as `before` and `min-release-age` during resolution. log::debug!( "installing latest npm packages directory={} packages={package_names:?}", directory.display() ); let packages = package_names .iter() .map(|package_name| (*package_name, "latest")) .collect::<Vec<_>>(); self.npm_install_packages(directory, &packages).await } pub async fn should_install_npm_package( &self, package_name: &str, local_executable_path: &Path, local_package_directory: &Path, version_strategy: VersionStrategy<'_>, ) -> bool { // In the case of the local system not having the package installed, // or in the instances where we fail to parse package.json data, // we attempt to install the package. if fs::metadata(local_executable_path).await.is_err() { log::debug!( "npm package cache miss package={package_name:?} reason=missing-executable executable={}", local_executable_path.display() ); return true; } let Some(installed_version) = self .npm_package_installed_version(local_package_directory, package_name) .await .log_err() .flatten() else { log::debug!( "npm package cache miss package={package_name:?} reason=missing-installed-version package_dir={}", local_package_directory.display() ); return true; }; let version_strategy_label = match &version_strategy { VersionStrategy::Pin(version) => format!("pin:{version}"), VersionStrategy::Latest(version) => format!("latest:{version}"), }; let should_install = should_install_npm_package_version(&installed_version, version_strategy); log::debug!( "npm package cache check package={package_name:?} installed={installed_version} strategy={version_strategy_label} should_install={should_install}" ); should_install }} fn should_install_npm_package_version( installed_version: &Version, version_strategy: VersionStrategy<'_>,) -> bool { match version_strategy { VersionStrategy::Pin(pinned_version) => installed_version != pinned_version, VersionStrategy::Latest(latest_version) => installed_version < latest_version, }} enum ArchiveType { TarGz, Zip,} #[derive(Debug, Deserialize)]#[serde(rename_all = "kebab-case")]pub struct NpmInfo { #[serde(default)] dist_tags: NpmInfoDistTags, versions: Vec<Version>, #[serde(default)] time: HashMap<String, String>,} #[derive(Debug, Deserialize, Default)]pub struct NpmInfoDistTags { latest: Option<Version>,} #[derive(Debug, Deserialize)]struct NpmConfig { #[serde(default)] before: Option<String>,} async fn npm_config_before( node_runtime: &dyn NodeRuntimeTrait, proxy: Option<&Url>,) -> Result<Option<String>> { // `npm config get before` renders Date values for display. The JSON config output keeps the // computed cutoff in the same ISO format used by `npm info --json` release times. let output = node_runtime .run_npm_subcommand(None, proxy, "config", &["list", "--json"]) .await?; let config: NpmConfig = serde_json::from_slice(&output.stdout)?; Ok(config .before .filter(|before| !before.trim().is_empty() && before != "null"))} fn select_npm_package_version( package_name: &str, mut info: NpmInfo, before: Option<&str>,) -> Result<Version> { if let Some(before) = before && !info.time.is_empty() { let before_timestamp = DateTime::parse_from_rfc3339(before) .with_context(|| format!("parsing npm before config timestamp {before:?}"))? .with_timezone(&Utc); let latest_version = info.dist_tags.latest.as_ref(); if let Some(version) = latest_version && npm_version_was_published_before(version, &info.time, &before_timestamp)? { return Ok(version.clone()); } for version in info.versions.iter().rev() { if is_allowed_npm_version_before( version, latest_version, &info.time, &before_timestamp, )? { return Ok(version.clone()); } } bail!("no version found for npm package {package_name} before {before}"); } info.dist_tags .latest .or_else(|| info.versions.pop()) .with_context(|| format!("no version found for npm package {package_name}"))} fn is_allowed_npm_version_before( version: &Version, latest_version: Option<&Version>, published_at_by_version: &HashMap<String, String>, before: &DateTime<Utc>,) -> Result<bool> { if !version.pre.is_empty() || latest_version.is_some_and(|latest_version| version > latest_version) { return Ok(false); } npm_version_was_published_before(version, published_at_by_version, before)} fn npm_version_was_published_before( version: &Version, published_at_by_version: &HashMap<String, String>, before: &DateTime<Utc>,) -> Result<bool> { let Some(published_at) = published_at_by_version.get(&version.to_string()) else {Lines 1–500 of 1343. Load more
use anyhow::{Context as _, Result, anyhow, bail}; use async_compression::futures::bufread::GzipDecoder; use async_tar::Archive; use chrono::{DateTime, Utc}; use futures::{AsyncReadExt, FutureExt as _, channel::oneshot, future::Shared}; use http_client::{Host, HttpClient, Url}; use log::Level; use semver::Version; use serde::Deserialize; use smol::io::BufReader; use smol::{fs, lock::Mutex}; use std::collections::HashMap; use std::fmt::Display; use std::{ env::{self, consts}, ffi::OsString, io, net::{IpAddr, Ipv4Addr}, path::{Path, PathBuf}, process::Output, sync::Arc, }; use util::ResultExt; use util::archive::extract_zip; const NODE_CA_CERTS_ENV_VAR: &str = "NODE_EXTRA_CA_CERTS"; #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct NodeBinaryOptions { pub allow_path_lookup: bool, pub allow_binary_download: bool, pub use_paths: Option<(PathBuf, PathBuf)>, } /// Use this when you need to launch npm as a long-lived process (for example, an agent server), /// so the invocation and environment stay consistent with the Node runtime's proxy and CA setup. #[derive(Clone, Debug)] pub struct NpmCommand { pub path: PathBuf, pub args: Vec<String>, pub env: HashMap<String, String>, } pub enum VersionStrategy<'a> { /// Install if current version doesn't match pinned version Pin(&'a Version), /// Install if current version is older than latest version Latest(&'a Version), } #[derive(Clone)] pub struct NodeRuntime(Arc<Mutex<NodeRuntimeState>>); struct NodeRuntimeState { http: Arc<dyn HttpClient>, instance: Option<Box<dyn NodeRuntimeTrait>>, last_options: Option<NodeBinaryOptions>, options: watch::Receiver<Option<NodeBinaryOptions>>, shell_env_loaded: Shared<oneshot::Receiver<()>>, } impl NodeRuntime { pub fn new( http: Arc<dyn HttpClient>, shell_env_loaded: Option<oneshot::Receiver<()>>, options: watch::Receiver<Option<NodeBinaryOptions>>, ) -> Self { NodeRuntime(Arc::new(Mutex::new(NodeRuntimeState { http, instance: None, last_options: None, options, shell_env_loaded: shell_env_loaded.unwrap_or(oneshot::channel().1).shared(), }))) } pub fn unavailable() -> Self { NodeRuntime(Arc::new(Mutex::new(NodeRuntimeState { http: Arc::new(http_client::BlockedHttpClient), instance: None, last_options: None, options: watch::channel(Some(NodeBinaryOptions::default())).1, shell_env_loaded: oneshot::channel().1.shared(), }))) } async fn instance(&self) -> Box<dyn NodeRuntimeTrait> { let mut state = self.0.lock().await; let options = loop { if let Some(options) = state.options.borrow().as_ref() { break options.clone(); } match state.options.changed().await { Ok(()) => {} // failure case not cached Err(err) => { return Box::new(UnavailableNodeRuntime { error_message: err.to_string().into(), }) as Box<dyn NodeRuntimeTrait>; } } }; if state.last_options.as_ref() != Some(&options) { state.instance.take(); } if let Some(instance) = state.instance.as_ref() { return instance.boxed_clone(); } if let Some((node, npm)) = options.use_paths.as_ref() { let instance = match SystemNodeRuntime::new(node.clone(), npm.clone()).await { Ok(instance) => { log::info!("using Node.js from `node.path` in settings: {:?}", instance); Box::new(instance) } Err(err) => { // failure case not cached, since it's cheap to check again return Box::new(UnavailableNodeRuntime { error_message: format!( "failure checking Node.js from `node.path` in settings ({}): {:?}", node.display(), err ) .into(), }); } }; state.instance = Some(instance.boxed_clone()); state.last_options = Some(options); return instance; } let system_node_error = if options.allow_path_lookup { state.shell_env_loaded.clone().await.ok(); match SystemNodeRuntime::detect().await { Ok(instance) => { log::info!("using Node.js found on PATH: {:?}", instance); state.instance = Some(instance.boxed_clone()); state.last_options = Some(options); return Box::new(instance) as Box<dyn NodeRuntimeTrait>; } Err(err) => Some(err), } } else { None }; let instance = if options.allow_binary_download { let (log_level, why_using_managed) = match system_node_error { Some(err @ DetectError::Other(_)) => (Level::Warn, err.to_string()), Some(err @ DetectError::NotInPath(_)) => (Level::Info, err.to_string()), None => ( Level::Info, "`node.ignore_system_version` is `true` in settings".to_string(), ), }; match ManagedNodeRuntime::install_if_needed(&state.http).await { Ok(instance) => { log::log!( log_level, "using Zed managed Node.js at {} since {}", instance.installation_path.display(), why_using_managed ); Box::new(instance) as Box<dyn NodeRuntimeTrait> } Err(err) => { // failure case is cached, since downloading + installing may be expensive. The // downside of this is that it may fail due to an intermittent network issue. // // TODO: Have `install_if_needed` indicate which failure cases are retryable // and/or have shared tracking of when internet is available. Box::new(UnavailableNodeRuntime { error_message: format!( "failure while downloading and/or installing Zed managed Node.js, \ restart Zed to retry: {}", err ) .into(), }) as Box<dyn NodeRuntimeTrait> } } } else if let Some(system_node_error) = system_node_error { // failure case not cached, since it's cheap to check again // // TODO: When support is added for setting `options.allow_binary_download`, update this // error message. return Box::new(UnavailableNodeRuntime { error_message: format!( "failure while checking system Node.js from PATH: {}", system_node_error ) .into(), }); } else { // failure case is cached because it will always happen with these options // // TODO: When support is added for setting `options.allow_binary_download`, update this // error message. Box::new(UnavailableNodeRuntime { error_message: "`node` settings do not allow any way to use Node.js" .to_string() .into(), }) }; state.instance = Some(instance.boxed_clone()); state.last_options = Some(options); instance } pub async fn binary_path(&self) -> Result<PathBuf> { self.instance().await.binary_path() } pub async fn run_npm_subcommand( &self, directory: Option<&Path>, subcommand: &str, args: &[&str], ) -> Result<Output> { let http = self.0.lock().await.http.clone(); self.instance() .await .run_npm_subcommand(directory, http.proxy(), subcommand, args) .await } pub async fn npm_package_installed_version( &self, local_package_directory: &Path, name: &str, ) -> Result<Option<Version>> { self.instance() .await .npm_package_installed_version(local_package_directory, name) .await } pub async fn npm_command( &self, prefix_dir: Option<&Path>, subcommand: &str, args: &[&str], ) -> Result<NpmCommand> { let http = self.0.lock().await.http.clone(); self.instance() .await .npm_command(prefix_dir, http.proxy(), subcommand, args) .await } pub async fn npm_package_latest_version(&self, name: &str) -> Result<Version> { let http = self.0.lock().await.http.clone(); let instance = self.instance().await; let output = instance .run_npm_subcommand( None, http.proxy(), "info", &[ name, "--json", "--fetch-retry-mintimeout", "2000", "--fetch-retry-maxtimeout", "5000", "--fetch-timeout", "5000", ], ) .await?; let info: NpmInfo = serde_json::from_slice(&output.stdout)?; let before = npm_config_before(instance.as_ref(), http.proxy()) .await .context("getting npm before config") .log_err() .flatten(); let latest_dist_tag = info.dist_tags.latest.clone(); let selected_version = select_npm_package_version(name, info, before.as_deref())?; log::debug!( "selected latest npm package version package={name:?} before={before:?} dist_tag_latest={latest_dist_tag:?} selected={selected_version}" ); Ok(selected_version) } pub async fn npm_install_packages( &self, directory: &Path, packages: &[(&str, &str)], ) -> Result<()> { if packages.is_empty() { return Ok(()); } log::debug!( "installing npm packages directory={} packages={packages:?}", directory.display() ); let packages: Vec<_> = packages .iter() .map(|(name, version)| format!("{name}@{version}")) .collect(); let arguments: Vec<_> = packages .iter() .map(|p| p.as_str()) .chain([ "--save-exact", "--fetch-retry-mintimeout", "2000", "--fetch-retry-maxtimeout", "5000", "--fetch-timeout", "5000", ]) .collect(); // This is also wrong because the directory is wrong. self.run_npm_subcommand(Some(directory), "install", &arguments) .await?; Ok(()) } pub async fn npm_install_latest_packages( &self, directory: &Path, package_names: &[&str], ) -> Result<()> { // Let npm apply user config such as `before` and `min-release-age` during resolution. log::debug!( "installing latest npm packages directory={} packages={package_names:?}", directory.display() ); let packages = package_names .iter() .map(|package_name| (*package_name, "latest")) .collect::<Vec<_>>(); self.npm_install_packages(directory, &packages).await } pub async fn should_install_npm_package( &self, package_name: &str, local_executable_path: &Path, local_package_directory: &Path, version_strategy: VersionStrategy<'_>, ) -> bool { // In the case of the local system not having the package installed, // or in the instances where we fail to parse package.json data, // we attempt to install the package. if fs::metadata(local_executable_path).await.is_err() { log::debug!( "npm package cache miss package={package_name:?} reason=missing-executable executable={}", local_executable_path.display() ); return true; } let Some(installed_version) = self .npm_package_installed_version(local_package_directory, package_name) .await .log_err() .flatten() else { log::debug!( "npm package cache miss package={package_name:?} reason=missing-installed-version package_dir={}", local_package_directory.display() ); return true; }; let version_strategy_label = match &version_strategy { VersionStrategy::Pin(version) => format!("pin:{version}"), VersionStrategy::Latest(version) => format!("latest:{version}"), }; let should_install = should_install_npm_package_version(&installed_version, version_strategy); log::debug!( "npm package cache check package={package_name:?} installed={installed_version} strategy={version_strategy_label} should_install={should_install}" ); should_install } } fn should_install_npm_package_version( installed_version: &Version, version_strategy: VersionStrategy<'_>, ) -> bool { match version_strategy { VersionStrategy::Pin(pinned_version) => installed_version != pinned_version, VersionStrategy::Latest(latest_version) => installed_version < latest_version, } } enum ArchiveType { TarGz, Zip, } #[derive(Debug, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct NpmInfo { #[serde(default)] dist_tags: NpmInfoDistTags, versions: Vec<Version>, #[serde(default)] time: HashMap<String, String>, } #[derive(Debug, Deserialize, Default)] pub struct NpmInfoDistTags { latest: Option<Version>, } #[derive(Debug, Deserialize)] struct NpmConfig { #[serde(default)] before: Option<String>, } async fn npm_config_before( node_runtime: &dyn NodeRuntimeTrait, proxy: Option<&Url>, ) -> Result<Option<String>> { // `npm config get before` renders Date values for display. The JSON config output keeps the // computed cutoff in the same ISO format used by `npm info --json` release times. let output = node_runtime .run_npm_subcommand(None, proxy, "config", &["list", "--json"]) .await?; let config: NpmConfig = serde_json::from_slice(&output.stdout)?; Ok(config .before .filter(|before| !before.trim().is_empty() && before != "null")) } fn select_npm_package_version( package_name: &str, mut info: NpmInfo, before: Option<&str>, ) -> Result<Version> { if let Some(before) = before && !info.time.is_empty() { let before_timestamp = DateTime::parse_from_rfc3339(before) .with_context(|| format!("parsing npm before config timestamp {before:?}"))? .with_timezone(&Utc); let latest_version = info.dist_tags.latest.as_ref(); if let Some(version) = latest_version && npm_version_was_published_before(version, &info.time, &before_timestamp)? { return Ok(version.clone()); } for version in info.versions.iter().rev() { if is_allowed_npm_version_before( version, latest_version, &info.time, &before_timestamp, )? { return Ok(version.clone()); } } bail!("no version found for npm package {package_name} before {before}"); } info.dist_tags .latest .or_else(|| info.versions.pop()) .with_context(|| format!("no version found for npm package {package_name}")) } fn is_allowed_npm_version_before( version: &Version, latest_version: Option<&Version>, published_at_by_version: &HashMap<String, String>, before: &DateTime<Utc>, ) -> Result<bool> { if !version.pre.is_empty() || latest_version.is_some_and(|latest_version| version > latest_version) { return Ok(false); } npm_version_was_published_before(version, published_at_by_version, before) } fn npm_version_was_published_before( version: &Version, published_at_by_version: &HashMap<String, String>, before: &DateTime<Utc>, ) -> Result<bool> { let Some(published_at) = published_at_by_version.get(&version.to_string()) else {