use crate::config::RegistryConfig;
use crate::error::UpgradeError;
use crate::upgrade::registry::npmrc::NpmrcConfig;
use crate::upgrade::registry::types::{PackageMetadata, RepositoryInfo, UpgradeType};
use reqwest::header::AUTHORIZATION;
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};
use semver::Version;
use serde::{Deserialize, Deserializer};
use std::collections::HashMap;
use std::path::Path;
use std::time::Duration;
fn deserialize_string_map_skip_nulls<'de, D>(
deserializer: D,
) -> Result<HashMap<String, String>, D::Error>
where
D: Deserializer<'de>,
{
let opt_map: HashMap<String, Option<String>> = HashMap::deserialize(deserializer)?;
Ok(opt_map.into_iter().filter_map(|(k, v)| v.map(|v| (k, v))).collect())
}
pub struct RegistryClient {
config: RegistryConfig,
http_client: ClientWithMiddleware,
npmrc: Option<NpmrcConfig>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct RegistryResponse {
name: String,
#[serde(default)]
versions: HashMap<String, VersionInfo>,
#[serde(rename = "dist-tags", default)]
dist_tags: HashMap<String, String>,
#[serde(default, deserialize_with = "deserialize_string_map_skip_nulls")]
time: HashMap<String, String>,
#[serde(default)]
repository: Option<RepositoryInfo>,
}
#[derive(Debug, Deserialize)]
struct VersionInfo {
#[serde(default)]
deprecated: Option<String>,
}
impl RegistryClient {
pub async fn new(_workspace_root: &Path, config: RegistryConfig) -> Result<Self, UpgradeError> {
let reqwest_client = reqwest::Client::builder()
.timeout(Duration::from_secs(config.timeout_secs))
.build()
.map_err(|e| UpgradeError::NetworkError {
reason: format!("Failed to build HTTP client: {}", e),
})?;
let retry_policy = ExponentialBackoff::builder()
.retry_bounds(
Duration::from_millis(config.retry_delay_ms),
Duration::from_secs(config.timeout_secs / 2),
)
.build_with_max_retries(config.retry_attempts as u32);
let http_client = ClientBuilder::new(reqwest_client)
.with(RetryTransientMiddleware::new_with_policy(retry_policy))
.build();
let npmrc = if config.read_npmrc {
match NpmrcConfig::from_workspace(
_workspace_root,
&sublime_standard_tools::filesystem::FileSystemManager::new(),
)
.await
{
Ok(cfg) => Some(cfg),
Err(e) => {
eprintln!("Warning: Failed to load .npmrc: {}", e);
None
}
}
} else {
None
};
Ok(Self { config, http_client, npmrc })
}
pub async fn get_package_info(
&self,
package_name: &str,
) -> Result<PackageMetadata, UpgradeError> {
let registry_url = self.resolve_registry_url(package_name);
let package_url = format!("{}/{}", registry_url.trim_end_matches('/'), package_name);
let mut request = self.http_client.get(&package_url);
request = request.header("Accept", "application/json");
if let Some(cred) = self.resolve_auth_token(®istry_url) {
use crate::upgrade::registry::npmrc::AuthType;
let auth_header = match cred.auth_type {
AuthType::Bearer => format!("Bearer {}", cred.value),
AuthType::Basic => format!("Basic {}", cred.value),
};
request = request.header(AUTHORIZATION, auth_header);
}
let response = request.send().await.map_err(|e| {
if e.is_timeout() {
UpgradeError::RegistryTimeout {
package: package_name.to_string(),
timeout_secs: self.config.timeout_secs,
}
} else {
UpgradeError::NetworkError {
reason: format!("Failed to query registry for '{}': {}", package_name, e),
}
}
})?;
let status = response.status();
if !status.is_success() {
if status.as_u16() == 404 {
return Err(UpgradeError::PackageNotFound {
package: package_name.to_string(),
registry: registry_url,
});
} else if status.as_u16() == 401 || status.as_u16() == 403 {
return Err(UpgradeError::AuthenticationFailed {
registry: registry_url,
reason: format!("HTTP {}: Authentication required", status.as_u16()),
});
} else {
return Err(UpgradeError::RegistryError {
package: package_name.to_string(),
reason: format!(
"HTTP {}: {}",
status.as_u16(),
status.canonical_reason().unwrap_or("Unknown error")
),
});
}
}
let registry_response: RegistryResponse =
response.json().await.map_err(|e| UpgradeError::InvalidResponse {
package: package_name.to_string(),
reason: format!("Failed to parse JSON response: {}", e),
})?;
self.convert_to_metadata(registry_response, package_name)
}
pub async fn get_latest_version(&self, package_name: &str) -> Result<String, UpgradeError> {
let metadata = self.get_package_info(package_name).await?;
Ok(metadata.latest)
}
pub fn compare_versions(
&self,
package_name: &str,
current: &str,
latest: &str,
) -> Result<UpgradeType, UpgradeError> {
let current_version =
Version::parse(current).map_err(|e| UpgradeError::InvalidVersionSpec {
package: package_name.to_string(),
spec: current.to_string(),
reason: format!("Invalid semver: {}", e),
})?;
let latest_version =
Version::parse(latest).map_err(|e| UpgradeError::InvalidVersionSpec {
package: package_name.to_string(),
spec: latest.to_string(),
reason: format!("Invalid semver: {}", e),
})?;
if latest_version <= current_version {
return Err(UpgradeError::VersionComparisonFailed {
package: package_name.to_string(),
reason: format!(
"Latest version '{}' is not greater than current version '{}'",
latest, current
),
});
}
if latest_version.major > current_version.major {
Ok(UpgradeType::Major)
} else if latest_version.minor > current_version.minor {
Ok(UpgradeType::Minor)
} else {
Ok(UpgradeType::Patch)
}
}
pub(crate) fn resolve_registry_url(&self, package_name: &str) -> String {
if let Some(npmrc) = &self.npmrc
&& let Some(registry) = npmrc.resolve_registry(package_name)
{
return registry.to_string();
}
if let Some(scope) = package_name.strip_prefix('@') {
if let Some(scope_end) = scope.find('/') {
let scope_name = &scope[..scope_end];
if let Some(registry) = self.config.scoped_registries.get(scope_name) {
return registry.clone();
}
}
}
self.config.default_registry.clone()
}
pub(crate) fn resolve_auth_token(
&self,
registry_url: &str,
) -> Option<crate::upgrade::registry::npmrc::AuthCredential> {
use crate::upgrade::registry::npmrc::{AuthCredential, AuthType};
if let Some(npmrc) = &self.npmrc
&& let Some(cred) = npmrc.get_auth_token(registry_url)
{
return Some(cred.clone());
}
if let Some(token) = self.config.auth_tokens.get(registry_url) {
return Some(AuthCredential { auth_type: AuthType::Bearer, value: token.clone() });
}
let url_without_slash = registry_url.trim_end_matches('/');
if let Some(token) = self.config.auth_tokens.get(url_without_slash) {
return Some(AuthCredential { auth_type: AuthType::Bearer, value: token.clone() });
}
None
}
fn convert_to_metadata(
&self,
response: RegistryResponse,
package_name: &str,
) -> Result<PackageMetadata, UpgradeError> {
let latest = response
.dist_tags
.get("latest")
.ok_or_else(|| UpgradeError::InvalidResponse {
package: package_name.to_string(),
reason: "No 'latest' dist-tag found".to_string(),
})?
.clone();
let mut versions: Vec<String> = response.versions.keys().cloned().collect();
versions.sort_by(|a, b| {
match (Version::parse(a), Version::parse(b)) {
(Ok(va), Ok(vb)) => va.cmp(&vb),
_ => a.cmp(b), }
});
let deprecated = response.versions.get(&latest).and_then(|v| v.deprecated.clone());
let mut time = HashMap::new();
for (key, value) in response.time {
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&value) {
time.insert(key, dt.with_timezone(&chrono::Utc));
}
}
Ok(PackageMetadata {
name: response.name,
versions,
latest,
deprecated,
time,
repository: response.repository,
})
}
}