upd-cli 0.0.2__tar.gz → 0.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {upd_cli-0.0.2 → upd_cli-0.0.3}/Cargo.lock +1 -1
  2. {upd_cli-0.0.2 → upd_cli-0.0.3}/Cargo.toml +1 -1
  3. {upd_cli-0.0.2 → upd_cli-0.0.3}/PKG-INFO +1 -1
  4. {upd_cli-0.0.2 → upd_cli-0.0.3}/src/cache.rs +78 -21
  5. {upd_cli-0.0.2 → upd_cli-0.0.3}/src/main.rs +13 -16
  6. {upd_cli-0.0.2 → upd_cli-0.0.3}/src/registry/crates_io.rs +5 -2
  7. {upd_cli-0.0.2 → upd_cli-0.0.3}/src/registry/go_proxy.rs +6 -3
  8. upd_cli-0.0.3/src/registry/mod.rs +85 -0
  9. {upd_cli-0.0.2 → upd_cli-0.0.3}/src/registry/npm.rs +38 -23
  10. {upd_cli-0.0.2 → upd_cli-0.0.3}/src/registry/pypi.rs +5 -2
  11. {upd_cli-0.0.2 → upd_cli-0.0.3}/src/updater/go_mod.rs +60 -0
  12. {upd_cli-0.0.2 → upd_cli-0.0.3}/src/updater/requirements.rs +35 -2
  13. upd_cli-0.0.3/src/version/semver_util.rs +98 -0
  14. upd_cli-0.0.2/src/registry/mod.rs +0 -40
  15. upd_cli-0.0.2/src/version/semver_util.rs +0 -53
  16. {upd_cli-0.0.2 → upd_cli-0.0.3}/.mise.toml +0 -0
  17. {upd_cli-0.0.2 → upd_cli-0.0.3}/.pre-commit-config.yaml +0 -0
  18. {upd_cli-0.0.2 → upd_cli-0.0.3}/CHANGELOG.md +0 -0
  19. {upd_cli-0.0.2 → upd_cli-0.0.3}/LICENSE +0 -0
  20. {upd_cli-0.0.2 → upd_cli-0.0.3}/Makefile +0 -0
  21. {upd_cli-0.0.2 → upd_cli-0.0.3}/README.md +0 -0
  22. {upd_cli-0.0.2 → upd_cli-0.0.3}/pyproject.toml +0 -0
  23. {upd_cli-0.0.2 → upd_cli-0.0.3}/python/upd_cli/__init__.py +0 -0
  24. {upd_cli-0.0.2 → upd_cli-0.0.3}/python/upd_cli/__main__.py +0 -0
  25. {upd_cli-0.0.2 → upd_cli-0.0.3}/python/upd_cli/py.typed +0 -0
  26. {upd_cli-0.0.2 → upd_cli-0.0.3}/rust-toolchain.toml +0 -0
  27. {upd_cli-0.0.2 → upd_cli-0.0.3}/src/cli.rs +0 -0
  28. {upd_cli-0.0.2 → upd_cli-0.0.3}/src/lib.rs +0 -0
  29. {upd_cli-0.0.2 → upd_cli-0.0.3}/src/updater/cargo_toml.rs +0 -0
  30. {upd_cli-0.0.2 → upd_cli-0.0.3}/src/updater/mod.rs +0 -0
  31. {upd_cli-0.0.2 → upd_cli-0.0.3}/src/updater/package_json.rs +0 -0
  32. {upd_cli-0.0.2 → upd_cli-0.0.3}/src/updater/pyproject.rs +0 -0
  33. {upd_cli-0.0.2 → upd_cli-0.0.3}/src/version/mod.rs +0 -0
  34. {upd_cli-0.0.2 → upd_cli-0.0.3}/src/version/pep440.rs +0 -0
@@ -1521,7 +1521,7 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
1521
1521
 
1522
1522
  [[package]]
1523
1523
  name = "upd"
1524
- version = "0.0.2"
1524
+ version = "0.0.3"
1525
1525
  dependencies = [
1526
1526
  "anyhow",
1527
1527
  "async-trait",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "upd"
3
- version = "0.0.2"
3
+ version = "0.0.3"
4
4
  edition = "2024"
5
5
  rust-version = "1.91.1"
6
6
  description = "A fast dependency updater for Python and Node.js projects"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: upd-cli
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -1,9 +1,12 @@
1
+ use crate::registry::Registry;
1
2
  use anyhow::Result;
3
+ use async_trait::async_trait;
2
4
  use directories::ProjectDirs;
3
5
  use serde::{Deserialize, Serialize};
4
6
  use std::collections::HashMap;
5
7
  use std::fs;
6
8
  use std::path::PathBuf;
9
+ use std::sync::{Arc, Mutex};
7
10
  use std::time::{Duration, SystemTime, UNIX_EPOCH};
8
11
 
9
12
  const CACHE_TTL_HOURS: u64 = 24;
@@ -52,6 +55,19 @@ impl Cache {
52
55
  Ok(())
53
56
  }
54
57
 
58
+ /// Create a shared cache wrapped in `Arc<Mutex>` for thread-safe access
59
+ pub fn new_shared() -> Arc<Mutex<Self>> {
60
+ Arc::new(Mutex::new(Self::load().unwrap_or_default()))
61
+ }
62
+
63
+ /// Save a shared cache to disk
64
+ pub fn save_shared(cache: &Arc<Mutex<Cache>>) -> Result<()> {
65
+ cache
66
+ .lock()
67
+ .map_err(|e| anyhow::anyhow!("Cache lock poisoned: {}", e))?
68
+ .save()
69
+ }
70
+
55
71
  pub fn get(&self, registry: &str, package: &str) -> Option<String> {
56
72
  let entries = match registry {
57
73
  "pypi" => &self.pypi,
@@ -136,45 +152,86 @@ impl Cache {
136
152
  }
137
153
  }
138
154
 
139
- /// A cached registry wrapper that checks cache before making network requests
155
+ /// Thread-safe cached registry wrapper that implements the Registry trait.
156
+ /// Checks cache before making network requests, storing results for future lookups.
140
157
  pub struct CachedRegistry<R> {
141
158
  inner: R,
142
- cache: Cache,
143
- registry_name: &'static str,
159
+ cache: Arc<Mutex<Cache>>,
144
160
  enabled: bool,
145
161
  }
146
162
 
147
- impl<R: crate::registry::Registry> CachedRegistry<R> {
148
- pub fn new(inner: R, enabled: bool) -> Self {
149
- let cache = Cache::load().unwrap_or_default();
150
- let registry_name = inner.name();
151
-
163
+ impl<R: Registry> CachedRegistry<R> {
164
+ pub fn new(inner: R, cache: Arc<Mutex<Cache>>, enabled: bool) -> Self {
152
165
  Self {
153
166
  inner,
154
167
  cache,
155
- registry_name,
156
168
  enabled,
157
169
  }
158
170
  }
159
171
 
160
- pub async fn get_latest_version(&mut self, package: &str) -> anyhow::Result<String> {
161
- // Check cache first if enabled
162
- if self.enabled
163
- && let Some(version) = self.cache.get(self.registry_name, package)
164
- {
165
- return Ok(version);
172
+ /// Get from cache (returns None if disabled, expired, or missing)
173
+ fn cache_get(&self, package: &str) -> Option<String> {
174
+ if !self.enabled {
175
+ return None;
166
176
  }
177
+ self.cache.lock().ok()?.get(self.inner.name(), package)
178
+ }
167
179
 
168
- // Fetch from registry
180
+ /// Set in cache (no-op if disabled). Does NOT save to disk - caller saves once at end.
181
+ fn cache_set(&self, package: &str, version: &str) {
182
+ if !self.enabled {
183
+ return;
184
+ }
185
+ if let Ok(mut cache) = self.cache.lock() {
186
+ cache.set(self.inner.name(), package, version.to_string());
187
+ }
188
+ }
189
+ }
190
+
191
+ #[async_trait]
192
+ impl<R: Registry> Registry for CachedRegistry<R> {
193
+ async fn get_latest_version(&self, package: &str) -> Result<String> {
194
+ if let Some(v) = self.cache_get(package) {
195
+ return Ok(v);
196
+ }
169
197
  let version = self.inner.get_latest_version(package).await?;
198
+ self.cache_set(package, &version);
199
+ Ok(version)
200
+ }
170
201
 
171
- // Update cache if enabled
172
- if self.enabled {
173
- self.cache.set(self.registry_name, package, version.clone());
174
- // Best effort save - don't fail the operation if cache save fails
175
- let _ = self.cache.save();
202
+ async fn get_latest_version_including_prereleases(&self, package: &str) -> Result<String> {
203
+ // Pre-releases use separate cache key to avoid returning stable when pre-release needed
204
+ let cache_key = format!("{}:prerelease", package);
205
+ if let Some(v) = self.cache_get(&cache_key) {
206
+ return Ok(v);
176
207
  }
208
+ let version = self
209
+ .inner
210
+ .get_latest_version_including_prereleases(package)
211
+ .await?;
212
+ self.cache_set(&cache_key, &version);
213
+ Ok(version)
214
+ }
177
215
 
216
+ async fn get_latest_version_matching(
217
+ &self,
218
+ package: &str,
219
+ constraints: &str,
220
+ ) -> Result<String> {
221
+ // Constraint-matching uses composite key to cache per-constraint results
222
+ let cache_key = format!("{}:match:{}", package, constraints);
223
+ if let Some(v) = self.cache_get(&cache_key) {
224
+ return Ok(v);
225
+ }
226
+ let version = self
227
+ .inner
228
+ .get_latest_version_matching(package, constraints)
229
+ .await?;
230
+ self.cache_set(&cache_key, &version);
178
231
  Ok(version)
179
232
  }
233
+
234
+ fn name(&self) -> &'static str {
235
+ self.inner.name()
236
+ }
180
237
  }
@@ -2,7 +2,8 @@ use anyhow::Result;
2
2
  use clap::Parser;
3
3
  use colored::Colorize;
4
4
 
5
- use upd::cache::Cache;
5
+ use std::sync::Arc;
6
+ use upd::cache::{Cache, CachedRegistry};
6
7
  use upd::cli::{Cli, Command};
7
8
  use upd::registry::{CratesIoRegistry, GoProxyRegistry, NpmRegistry, PyPiRegistry};
8
9
  use upd::updater::{
@@ -90,11 +91,14 @@ async fn run_update(cli: &Cli) -> Result<()> {
90
91
  );
91
92
  }
92
93
 
93
- // Create registries
94
- let pypi = PyPiRegistry::new();
95
- let npm = NpmRegistry::new();
96
- let crates_io = CratesIoRegistry::new();
97
- let go_proxy = GoProxyRegistry::new();
94
+ // Create shared cache and wrap registries with caching layer
95
+ let cache = Cache::new_shared();
96
+ let cache_enabled = !cli.no_cache;
97
+
98
+ let pypi = CachedRegistry::new(PyPiRegistry::new(), Arc::clone(&cache), cache_enabled);
99
+ let npm = CachedRegistry::new(NpmRegistry::new(), Arc::clone(&cache), cache_enabled);
100
+ let crates_io = CachedRegistry::new(CratesIoRegistry::new(), Arc::clone(&cache), cache_enabled);
101
+ let go_proxy = CachedRegistry::new(GoProxyRegistry::new(), Arc::clone(&cache), cache_enabled);
98
102
 
99
103
  // Create updaters
100
104
  let requirements_updater = RequirementsUpdater::new();
@@ -103,13 +107,6 @@ async fn run_update(cli: &Cli) -> Result<()> {
103
107
  let cargo_toml_updater = CargoTomlUpdater::new();
104
108
  let go_mod_updater = GoModUpdater::new();
105
109
 
106
- // Load cache if enabled
107
- let mut cache = if !cli.no_cache {
108
- Cache::load().ok()
109
- } else {
110
- None
111
- };
112
-
113
110
  let mut total_result = UpdateResult::default();
114
111
 
115
112
  for (path, file_type) in files {
@@ -143,9 +140,9 @@ async fn run_update(cli: &Cli) -> Result<()> {
143
140
  }
144
141
  }
145
142
 
146
- // Save cache
147
- if let Some(ref mut cache) = cache {
148
- let _ = cache.save();
143
+ // Save cache to disk
144
+ if cache_enabled {
145
+ let _ = Cache::save_shared(&cache);
149
146
  }
150
147
 
151
148
  // Print summary
@@ -1,8 +1,9 @@
1
- use super::Registry;
1
+ use super::{Registry, get_with_retry};
2
2
  use anyhow::{Result, anyhow};
3
3
  use async_trait::async_trait;
4
4
  use reqwest::Client;
5
5
  use serde::Deserialize;
6
+ use std::time::Duration;
6
7
 
7
8
  pub struct CratesIoRegistry {
8
9
  client: Client,
@@ -37,6 +38,8 @@ impl CratesIoRegistry {
37
38
  .gzip(true)
38
39
  // crates.io requires a descriptive User-Agent
39
40
  .user_agent("upd/0.1.0 (https://github.com/rvben/upd)")
41
+ .timeout(Duration::from_secs(30))
42
+ .connect_timeout(Duration::from_secs(10))
40
43
  .build()
41
44
  .expect("Failed to create HTTP client");
42
45
 
@@ -55,7 +58,7 @@ impl CratesIoRegistry {
55
58
 
56
59
  async fn fetch_crate(&self, name: &str) -> Result<CratesResponse> {
57
60
  let url = format!("{}/{}", self.registry_url, name);
58
- let response = self.client.get(&url).send().await?;
61
+ let response = get_with_retry(&self.client, &url).await?;
59
62
 
60
63
  if !response.status().is_success() {
61
64
  return Err(anyhow!(
@@ -1,8 +1,9 @@
1
- use super::Registry;
1
+ use super::{Registry, get_with_retry};
2
2
  use anyhow::{Result, anyhow};
3
3
  use async_trait::async_trait;
4
4
  use reqwest::Client;
5
5
  use serde::Deserialize;
6
+ use std::time::Duration;
6
7
 
7
8
  pub struct GoProxyRegistry {
8
9
  client: Client,
@@ -24,6 +25,8 @@ impl GoProxyRegistry {
24
25
  let client = Client::builder()
25
26
  .gzip(true)
26
27
  .user_agent("upd/0.1.0")
28
+ .timeout(Duration::from_secs(30))
29
+ .connect_timeout(Duration::from_secs(10))
27
30
  .build()
28
31
  .expect("Failed to create HTTP client");
29
32
 
@@ -49,7 +52,7 @@ impl GoProxyRegistry {
49
52
  let escaped = Self::escape_module_path(module);
50
53
  let url = format!("{}/{}/@v/list", self.proxy_url, escaped);
51
54
 
52
- let response = self.client.get(&url).send().await?;
55
+ let response = get_with_retry(&self.client, &url).await?;
53
56
 
54
57
  if !response.status().is_success() {
55
58
  return Err(anyhow!(
@@ -110,7 +113,7 @@ impl Registry for GoProxyRegistry {
110
113
  let escaped = Self::escape_module_path(package);
111
114
  let url = format!("{}/{}/@latest", self.proxy_url, escaped);
112
115
 
113
- if let Ok(response) = self.client.get(&url).send().await
116
+ if let Ok(response) = get_with_retry(&self.client, &url).await
114
117
  && response.status().is_success()
115
118
  && let Ok(data) = response.json::<LatestResponse>().await
116
119
  {
@@ -0,0 +1,85 @@
1
+ mod crates_io;
2
+ mod go_proxy;
3
+ mod npm;
4
+ mod pypi;
5
+
6
+ pub use crates_io::CratesIoRegistry;
7
+ pub use go_proxy::GoProxyRegistry;
8
+ pub use npm::NpmRegistry;
9
+ pub use pypi::PyPiRegistry;
10
+
11
+ use anyhow::Result;
12
+ use async_trait::async_trait;
13
+ use reqwest::{Client, Response};
14
+ use std::time::Duration;
15
+
16
+ /// Maximum number of retry attempts for failed HTTP requests
17
+ const MAX_RETRIES: u32 = 3;
18
+
19
+ /// Base delay for exponential backoff (100ms, 200ms, 400ms)
20
+ const BASE_DELAY_MS: u64 = 100;
21
+
22
+ /// Execute an HTTP GET request with retry and exponential backoff.
23
+ /// Retries on transient errors (network issues, 5xx server errors).
24
+ pub async fn get_with_retry(client: &Client, url: &str) -> Result<Response, reqwest::Error> {
25
+ let mut last_error = None;
26
+
27
+ for attempt in 0..MAX_RETRIES {
28
+ match client.get(url).send().await {
29
+ Ok(response) => {
30
+ // Don't retry client errors (4xx) - they won't succeed on retry
31
+ if response.status().is_client_error() || response.status().is_success() {
32
+ return Ok(response);
33
+ }
34
+
35
+ // Retry server errors (5xx)
36
+ if response.status().is_server_error() && attempt < MAX_RETRIES - 1 {
37
+ let delay = Duration::from_millis(BASE_DELAY_MS * (1 << attempt));
38
+ tokio::time::sleep(delay).await;
39
+ continue;
40
+ }
41
+
42
+ return Ok(response);
43
+ }
44
+ Err(e) => {
45
+ last_error = Some(e);
46
+
47
+ // Don't retry on the last attempt
48
+ if attempt < MAX_RETRIES - 1 {
49
+ let delay = Duration::from_millis(BASE_DELAY_MS * (1 << attempt));
50
+ tokio::time::sleep(delay).await;
51
+ }
52
+ }
53
+ }
54
+ }
55
+
56
+ Err(last_error.unwrap())
57
+ }
58
+
59
+ #[async_trait]
60
+ pub trait Registry: Send + Sync {
61
+ /// Get the latest stable version of a package
62
+ async fn get_latest_version(&self, package: &str) -> Result<String>;
63
+
64
+ /// Get the latest version including pre-releases
65
+ /// Used when the user's current version is a pre-release
66
+ async fn get_latest_version_including_prereleases(&self, package: &str) -> Result<String> {
67
+ // Default: fall back to stable-only
68
+ self.get_latest_version(package).await
69
+ }
70
+
71
+ /// Get the latest version matching the given constraints (e.g., ">=2.8.0,<9")
72
+ /// Default implementation falls back to get_latest_version
73
+ async fn get_latest_version_matching(
74
+ &self,
75
+ package: &str,
76
+ constraints: &str,
77
+ ) -> Result<String> {
78
+ // Default: ignore constraints and return latest
79
+ let _ = constraints;
80
+ self.get_latest_version(package).await
81
+ }
82
+
83
+ /// Registry name for display
84
+ fn name(&self) -> &'static str;
85
+ }
@@ -3,18 +3,22 @@ use anyhow::{Result, anyhow};
3
3
  use async_trait::async_trait;
4
4
  use reqwest::Client;
5
5
  use serde::Deserialize;
6
- use std::collections::HashMap;
6
+ use serde_json::Value;
7
+ use std::time::Duration;
7
8
 
8
9
  pub struct NpmRegistry {
9
10
  client: Client,
10
11
  registry_url: String,
11
12
  }
12
13
 
14
+ /// Abbreviated npm response (smaller, faster)
15
+ /// Uses Accept: application/vnd.npm.install-v1+json
13
16
  #[derive(Debug, Deserialize)]
14
- struct NpmResponse {
17
+ struct NpmAbbreviatedResponse {
15
18
  #[serde(rename = "dist-tags")]
16
19
  dist_tags: DistTags,
17
- versions: HashMap<String, VersionInfo>,
20
+ /// Version keys only (we parse them dynamically to avoid large struct)
21
+ versions: Value,
18
22
  }
19
23
 
20
24
  #[derive(Debug, Deserialize)]
@@ -22,11 +26,6 @@ struct DistTags {
22
26
  latest: Option<String>,
23
27
  }
24
28
 
25
- #[derive(Debug, Deserialize)]
26
- struct VersionInfo {
27
- deprecated: Option<String>,
28
- }
29
-
30
29
  impl NpmRegistry {
31
30
  pub fn new() -> Self {
32
31
  Self::with_registry_url("https://registry.npmjs.org".to_string())
@@ -36,6 +35,8 @@ impl NpmRegistry {
36
35
  let client = Client::builder()
37
36
  .gzip(true)
38
37
  .user_agent("upd/0.1.0")
38
+ .timeout(Duration::from_secs(30))
39
+ .connect_timeout(Duration::from_secs(10))
39
40
  .build()
40
41
  .expect("Failed to create HTTP client");
41
42
 
@@ -50,10 +51,18 @@ impl NpmRegistry {
50
51
  std::env::var("NPM_REGISTRY").ok().filter(|s| !s.is_empty())
51
52
  }
52
53
 
53
- /// Fetch package metadata from npm
54
- async fn fetch_package(&self, package: &str) -> Result<NpmResponse> {
54
+ /// Fetch abbreviated package metadata from npm
55
+ /// Uses the install-v1 format which is smaller and faster
56
+ async fn fetch_package(&self, package: &str) -> Result<NpmAbbreviatedResponse> {
55
57
  let url = format!("{}/{}", self.registry_url, package);
56
- let response = self.client.get(&url).send().await?;
58
+
59
+ // Use abbreviated metadata format (much smaller for large packages like react)
60
+ let response = self
61
+ .client
62
+ .get(&url)
63
+ .header("Accept", "application/vnd.npm.install-v1+json")
64
+ .send()
65
+ .await?;
57
66
 
58
67
  if !response.status().is_success() {
59
68
  return Err(anyhow!(
@@ -63,16 +72,24 @@ impl NpmRegistry {
63
72
  ));
64
73
  }
65
74
 
66
- Ok(response.json().await?)
75
+ response
76
+ .json()
77
+ .await
78
+ .map_err(|e| anyhow!("Failed to parse npm response for '{}': {}", package, e))
67
79
  }
68
80
 
69
- /// Get all stable (non-prerelease, non-deprecated) versions sorted descending
70
- fn get_stable_versions(data: &NpmResponse) -> Vec<(semver::Version, String)> {
71
- let mut versions: Vec<_> = data
72
- .versions
73
- .iter()
74
- .filter(|(_, info)| info.deprecated.is_none())
75
- .filter_map(|(ver_str, _)| {
81
+ /// Get all stable (non-prerelease) versions sorted descending
82
+ /// Note: abbreviated metadata doesn't include deprecated info, but dist-tags.latest
83
+ /// is authoritative for the recommended version
84
+ fn get_stable_versions(data: &NpmAbbreviatedResponse) -> Vec<(semver::Version, String)> {
85
+ let versions_obj = match data.versions.as_object() {
86
+ Some(obj) => obj,
87
+ None => return Vec::new(),
88
+ };
89
+
90
+ let mut versions: Vec<_> = versions_obj
91
+ .keys()
92
+ .filter_map(|ver_str| {
76
93
  semver::Version::parse(ver_str).ok().and_then(|v| {
77
94
  // Skip pre-release versions
78
95
  if v.pre.is_empty() {
@@ -100,17 +117,15 @@ impl Registry for NpmRegistry {
100
117
  async fn get_latest_version(&self, package: &str) -> Result<String> {
101
118
  let data = self.fetch_package(package).await?;
102
119
 
103
- // Use the 'latest' dist-tag first
120
+ // Use the 'latest' dist-tag (this is the authoritative answer from npm)
104
121
  if let Some(latest) = &data.dist_tags.latest
105
- && let Some(version_info) = data.versions.get(latest)
106
- && version_info.deprecated.is_none()
107
122
  && let Ok(v) = semver::Version::parse(latest)
108
123
  && v.pre.is_empty()
109
124
  {
110
125
  return Ok(latest.clone());
111
126
  }
112
127
 
113
- // Fall back to finding the latest stable version
128
+ // Fall back to finding the latest stable version from the versions list
114
129
  let versions = Self::get_stable_versions(&data);
115
130
  versions
116
131
  .first()
@@ -1,4 +1,4 @@
1
- use super::Registry;
1
+ use super::{Registry, get_with_retry};
2
2
  use anyhow::{Result, anyhow};
3
3
  use async_trait::async_trait;
4
4
  use pep440_rs::{Version, VersionSpecifiers};
@@ -6,6 +6,7 @@ use reqwest::Client;
6
6
  use serde::Deserialize;
7
7
  use std::collections::HashMap;
8
8
  use std::str::FromStr;
9
+ use std::time::Duration;
9
10
 
10
11
  pub struct PyPiRegistry {
11
12
  client: Client,
@@ -31,6 +32,8 @@ impl PyPiRegistry {
31
32
  let client = Client::builder()
32
33
  .gzip(true)
33
34
  .user_agent("upd/0.1.0")
35
+ .timeout(Duration::from_secs(30))
36
+ .connect_timeout(Duration::from_secs(10))
34
37
  .build()
35
38
  .expect("Failed to create HTTP client");
36
39
 
@@ -78,7 +81,7 @@ impl PyPiRegistry {
78
81
  let normalized = package.to_lowercase().replace('_', "-");
79
82
  let url = format!("{}/{}/json", self.index_url, normalized);
80
83
 
81
- let response = self.client.get(&url).send().await?;
84
+ let response = get_with_retry(&self.client, &url).await?;
82
85
 
83
86
  if !response.status().is_success() {
84
87
  return Err(anyhow!(
@@ -79,6 +79,36 @@ impl GoModUpdater {
79
79
  .map(|v| !v.pre.is_empty())
80
80
  .unwrap_or(false)
81
81
  }
82
+
83
+ /// Check if a version is a pseudo-version (commit-based, not a real tag).
84
+ /// Pseudo-versions have the format: v0.0.0-YYYYMMDDHHMMSS-abcdefabcdef
85
+ /// Or for pre-release: v1.2.4-0.YYYYMMDDHHMMSS-abcdefabcdef
86
+ /// These modules have no semver tags (or point to commits), so updating them via registry fails.
87
+ fn is_pseudo_version(version: &str) -> bool {
88
+ // Pseudo-version patterns:
89
+ // 1. v0.0.0-20241217172646-ca3f786aa774 (base version is 0.0.0)
90
+ // 2. v1.2.4-0.20220331215641-2d8c0ab7ef04 (pre-release pseudo after real version)
91
+ //
92
+ // Look for timestamp pattern: 14 digits (YYYYMMDDHHMMSS)
93
+ let contains_timestamp = |s: &str| s.len() == 14 && s.chars().all(|c| c.is_ascii_digit());
94
+
95
+ // Split by dash and look for the timestamp part
96
+ let parts: Vec<&str> = version.split('-').collect();
97
+
98
+ for part in &parts {
99
+ if contains_timestamp(part) {
100
+ return true;
101
+ }
102
+ // Handle "0.20220331215641" format (pre-release pseudo)
103
+ if let Some(after_dot) = part.strip_prefix("0.")
104
+ && contains_timestamp(after_dot)
105
+ {
106
+ return true;
107
+ }
108
+ }
109
+
110
+ false
111
+ }
82
112
  }
83
113
 
84
114
  impl Default for GoModUpdater {
@@ -149,6 +179,13 @@ impl Updater for GoModUpdater {
149
179
  continue;
150
180
  }
151
181
 
182
+ // Skip pseudo-versions (commit-based, no semver tags available)
183
+ if Self::is_pseudo_version(current_version) {
184
+ new_lines.push(line.to_string());
185
+ result.unchanged += 1;
186
+ continue;
187
+ }
188
+
152
189
  // Fetch latest version
153
190
  let version_result = if Self::is_prerelease(current_version) {
154
191
  registry
@@ -276,4 +313,27 @@ replace (
276
313
  assert!(GoModUpdater::is_prerelease("v1.0.0-rc1"));
277
314
  assert!(GoModUpdater::is_prerelease("v1.0.0-beta"));
278
315
  }
316
+
317
+ #[test]
318
+ fn test_is_pseudo_version() {
319
+ // Standard pseudo-versions (commit-based, no semver tags)
320
+ assert!(GoModUpdater::is_pseudo_version(
321
+ "v0.0.0-20241217172646-ca3f786aa774"
322
+ ));
323
+ assert!(GoModUpdater::is_pseudo_version(
324
+ "v0.0.0-20220331215641-2d8c0ab7ef04"
325
+ ));
326
+
327
+ // Pre-release pseudo-versions (e.g., for modules with tagged releases)
328
+ assert!(GoModUpdater::is_pseudo_version(
329
+ "v1.2.4-0.20220331215641-2d8c0ab7ef04"
330
+ ));
331
+
332
+ // Normal versions should NOT be detected as pseudo-versions
333
+ assert!(!GoModUpdater::is_pseudo_version("v1.0.0"));
334
+ assert!(!GoModUpdater::is_pseudo_version("v1.0.0-alpha.1"));
335
+ assert!(!GoModUpdater::is_pseudo_version("v1.0.0-rc1"));
336
+ assert!(!GoModUpdater::is_pseudo_version("v2.0.0+incompatible"));
337
+ assert!(!GoModUpdater::is_pseudo_version("v1.0.0-beta"));
338
+ }
279
339
  }
@@ -83,9 +83,30 @@ impl RequirementsUpdater {
83
83
  None
84
84
  }
85
85
 
86
- /// Check if constraint is a simple single-version constraint (==, >=, etc. without upper bound)
86
+ /// Check if constraint is a simple single-version constraint that doesn't need
87
+ /// constraint-aware lookup (i.e., no upper bounds that could be violated)
87
88
  fn is_simple_constraint(constraint: &str) -> bool {
88
- !constraint.contains(',')
89
+ // If there are multiple constraints (comma-separated), need constraint-aware lookup
90
+ if constraint.contains(',') {
91
+ return false;
92
+ }
93
+
94
+ // If the constraint has an upper-bound operator, need constraint-aware lookup
95
+ // Examples: "<4.2", "<=2.0", "~=1.4" (compatible release - allows only patch updates)
96
+ if constraint.starts_with('<')
97
+ || constraint.starts_with("<=")
98
+ || constraint.starts_with("~=")
99
+ {
100
+ return false;
101
+ }
102
+
103
+ // Also check for != which could affect version selection
104
+ if constraint.starts_with("!=") {
105
+ return false;
106
+ }
107
+
108
+ // Simple constraints like "==1.0.0", ">=1.0.0", ">1.0.0" are fine
109
+ true
89
110
  }
90
111
 
91
112
  fn update_line(&self, line: &str, new_version: &str) -> String {
@@ -232,10 +253,22 @@ mod tests {
232
253
 
233
254
  #[test]
234
255
  fn test_is_simple_constraint() {
256
+ // Simple constraints - no upper bound, no exclusions
235
257
  assert!(RequirementsUpdater::is_simple_constraint("==1.0.0"));
236
258
  assert!(RequirementsUpdater::is_simple_constraint(">=1.0.0"));
259
+ assert!(RequirementsUpdater::is_simple_constraint(">1.0.0"));
260
+
261
+ // Multiple constraints with comma
237
262
  assert!(!RequirementsUpdater::is_simple_constraint(">=1.0.0,<2.0.0"));
238
263
  assert!(!RequirementsUpdater::is_simple_constraint(">=2.8.0,<9"));
264
+
265
+ // Upper-bound operators (need constraint-aware lookup)
266
+ assert!(!RequirementsUpdater::is_simple_constraint("<4.2"));
267
+ assert!(!RequirementsUpdater::is_simple_constraint("<=2.0"));
268
+ assert!(!RequirementsUpdater::is_simple_constraint("~=1.4"));
269
+
270
+ // Exclusion operator
271
+ assert!(!RequirementsUpdater::is_simple_constraint("!=1.5.0"));
239
272
  }
240
273
 
241
274
  #[test]
@@ -0,0 +1,98 @@
1
+ use semver::Version;
2
+
3
+ /// Normalize a version string to full semver format (MAJOR.MINOR.PATCH)
4
+ /// "1" -> "1.0.0", "1.2" -> "1.2.0", "1.2.3" -> "1.2.3"
5
+ fn normalize_version(version_str: &str) -> String {
6
+ // Handle prerelease suffix (e.g., "1.0-alpha" -> keep as is after normalization)
7
+ let (base, suffix) = if let Some(idx) = version_str.find('-') {
8
+ (&version_str[..idx], &version_str[idx..])
9
+ } else {
10
+ (version_str, "")
11
+ };
12
+
13
+ let parts: Vec<&str> = base.split('.').collect();
14
+ let normalized = match parts.len() {
15
+ 1 => format!("{}.0.0", parts[0]),
16
+ 2 => format!("{}.{}.0", parts[0], parts[1]),
17
+ _ => base.to_string(),
18
+ };
19
+
20
+ format!("{}{}", normalized, suffix)
21
+ }
22
+
23
+ /// Check if a semver version string represents a stable release
24
+ /// Handles incomplete versions like "0.9" by normalizing to "0.9.0"
25
+ pub fn is_stable_semver(version_str: &str) -> bool {
26
+ let normalized = normalize_version(version_str);
27
+ if let Ok(version) = Version::parse(&normalized) {
28
+ version.pre.is_empty()
29
+ } else {
30
+ // If it still can't parse, assume it's stable (e.g., "*" or complex constraints)
31
+ // This is safer than treating unknown formats as prereleases
32
+ true
33
+ }
34
+ }
35
+
36
+ /// Compare two semver version strings
37
+ /// Returns None if either version is invalid
38
+ pub fn compare_versions(a: &str, b: &str) -> Option<std::cmp::Ordering> {
39
+ let va = Version::parse(a).ok()?;
40
+ let vb = Version::parse(b).ok()?;
41
+ Some(va.cmp(&vb))
42
+ }
43
+
44
+ #[cfg(test)]
45
+ mod tests {
46
+ use super::*;
47
+
48
+ #[test]
49
+ fn test_normalize_version() {
50
+ assert_eq!(normalize_version("1"), "1.0.0");
51
+ assert_eq!(normalize_version("1.2"), "1.2.0");
52
+ assert_eq!(normalize_version("1.2.3"), "1.2.3");
53
+ assert_eq!(normalize_version("0.9"), "0.9.0");
54
+ assert_eq!(normalize_version("1.0-alpha"), "1.0.0-alpha");
55
+ assert_eq!(normalize_version("1-beta.1"), "1.0.0-beta.1");
56
+ }
57
+
58
+ #[test]
59
+ fn test_stable_versions() {
60
+ assert!(is_stable_semver("1.0.0"));
61
+ assert!(is_stable_semver("2.31.0"));
62
+ assert!(is_stable_semver("0.1.0"));
63
+ }
64
+
65
+ #[test]
66
+ fn test_incomplete_versions_are_stable() {
67
+ // Incomplete versions like "0.9" should be treated as stable
68
+ assert!(is_stable_semver("0.9"));
69
+ assert!(is_stable_semver("1"));
70
+ assert!(is_stable_semver("2.0"));
71
+ }
72
+
73
+ #[test]
74
+ fn test_prerelease_versions() {
75
+ assert!(!is_stable_semver("1.0.0-alpha.1"));
76
+ assert!(!is_stable_semver("1.0.0-beta.2"));
77
+ assert!(!is_stable_semver("1.0.0-rc.1"));
78
+ // Incomplete versions with prerelease suffix
79
+ assert!(!is_stable_semver("1.0-alpha"));
80
+ assert!(!is_stable_semver("0.9-rc1"));
81
+ }
82
+
83
+ #[test]
84
+ fn test_version_comparison() {
85
+ assert_eq!(
86
+ compare_versions("1.0.0", "2.0.0"),
87
+ Some(std::cmp::Ordering::Less)
88
+ );
89
+ assert_eq!(
90
+ compare_versions("2.0.0", "1.0.0"),
91
+ Some(std::cmp::Ordering::Greater)
92
+ );
93
+ assert_eq!(
94
+ compare_versions("1.0.0", "1.0.0"),
95
+ Some(std::cmp::Ordering::Equal)
96
+ );
97
+ }
98
+ }
@@ -1,40 +0,0 @@
1
- mod crates_io;
2
- mod go_proxy;
3
- mod npm;
4
- mod pypi;
5
-
6
- pub use crates_io::CratesIoRegistry;
7
- pub use go_proxy::GoProxyRegistry;
8
- pub use npm::NpmRegistry;
9
- pub use pypi::PyPiRegistry;
10
-
11
- use anyhow::Result;
12
- use async_trait::async_trait;
13
-
14
- #[async_trait]
15
- pub trait Registry: Send + Sync {
16
- /// Get the latest stable version of a package
17
- async fn get_latest_version(&self, package: &str) -> Result<String>;
18
-
19
- /// Get the latest version including pre-releases
20
- /// Used when the user's current version is a pre-release
21
- async fn get_latest_version_including_prereleases(&self, package: &str) -> Result<String> {
22
- // Default: fall back to stable-only
23
- self.get_latest_version(package).await
24
- }
25
-
26
- /// Get the latest version matching the given constraints (e.g., ">=2.8.0,<9")
27
- /// Default implementation falls back to get_latest_version
28
- async fn get_latest_version_matching(
29
- &self,
30
- package: &str,
31
- constraints: &str,
32
- ) -> Result<String> {
33
- // Default: ignore constraints and return latest
34
- let _ = constraints;
35
- self.get_latest_version(package).await
36
- }
37
-
38
- /// Registry name for display
39
- fn name(&self) -> &'static str;
40
- }
@@ -1,53 +0,0 @@
1
- use semver::Version;
2
-
3
- /// Check if a semver version string represents a stable release
4
- pub fn is_stable_semver(version_str: &str) -> bool {
5
- if let Ok(version) = Version::parse(version_str) {
6
- version.pre.is_empty()
7
- } else {
8
- false
9
- }
10
- }
11
-
12
- /// Compare two semver version strings
13
- /// Returns None if either version is invalid
14
- pub fn compare_versions(a: &str, b: &str) -> Option<std::cmp::Ordering> {
15
- let va = Version::parse(a).ok()?;
16
- let vb = Version::parse(b).ok()?;
17
- Some(va.cmp(&vb))
18
- }
19
-
20
- #[cfg(test)]
21
- mod tests {
22
- use super::*;
23
-
24
- #[test]
25
- fn test_stable_versions() {
26
- assert!(is_stable_semver("1.0.0"));
27
- assert!(is_stable_semver("2.31.0"));
28
- assert!(is_stable_semver("0.1.0"));
29
- }
30
-
31
- #[test]
32
- fn test_prerelease_versions() {
33
- assert!(!is_stable_semver("1.0.0-alpha.1"));
34
- assert!(!is_stable_semver("1.0.0-beta.2"));
35
- assert!(!is_stable_semver("1.0.0-rc.1"));
36
- }
37
-
38
- #[test]
39
- fn test_version_comparison() {
40
- assert_eq!(
41
- compare_versions("1.0.0", "2.0.0"),
42
- Some(std::cmp::Ordering::Less)
43
- );
44
- assert_eq!(
45
- compare_versions("2.0.0", "1.0.0"),
46
- Some(std::cmp::Ordering::Greater)
47
- );
48
- assert_eq!(
49
- compare_versions("1.0.0", "1.0.0"),
50
- Some(std::cmp::Ordering::Equal)
51
- );
52
- }
53
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes