deps_core/registry.rs
1use crate::error::Result;
2use crate::parser::DependencySource;
3use crate::{ConcreteVersion, PackageName, VersionReq};
4use std::any::Any;
5use std::pin::Pin;
6
7type BoxFuture<'a, T> = Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
8
9/// Generic package registry interface.
10///
11/// Implementors provide access to a package registry (crates.io, npm, PyPI, etc.)
12/// with version lookup, search, and metadata retrieval capabilities.
13///
14/// All methods return `Result<T>` to allow graceful error handling.
15/// LSP handlers must never panic on registry errors.
16///
17/// # Type Erasure
18///
19/// This trait uses `Box<dyn Trait>` return types instead of associated types
20/// to allow runtime polymorphism and dynamic ecosystem registration.
21///
22/// # Examples
23///
24/// ```no_run
25/// use deps_core::{Registry, Version, Metadata, PackageName, ConcreteVersion};
26/// use std::any::Any;
27/// use std::pin::Pin;
28///
29/// struct MyRegistry;
30///
31/// #[derive(Clone)]
32/// struct MyVersion { version: ConcreteVersion }
33///
34/// impl Version for MyVersion {
35/// fn version_string(&self) -> &ConcreteVersion { &self.version }
36/// fn as_any(&self) -> &dyn Any { self }
37/// }
38///
39/// #[derive(Clone)]
40/// struct MyMetadata { name: PackageName, latest: ConcreteVersion }
41///
42/// impl Metadata for MyMetadata {
43/// fn name(&self) -> &PackageName { &self.name }
44/// fn description(&self) -> Option<&str> { None }
45/// fn repository(&self) -> Option<&str> { None }
46/// fn documentation(&self) -> Option<&str> { None }
47/// fn latest_version(&self) -> &ConcreteVersion { &self.latest }
48/// fn as_any(&self) -> &dyn Any { self }
49/// }
50///
51/// impl Registry for MyRegistry {
52/// fn get_versions<'a>(&'a self, _name: &'a PackageName)
53/// -> Pin<Box<dyn std::future::Future<Output = deps_core::error::Result<Vec<Box<dyn Version>>>> + Send + 'a>>
54/// {
55/// Box::pin(async move { Ok(vec![Box::new(MyVersion { version: "1.0.0".into() }) as Box<dyn Version>]) })
56/// }
57///
58/// fn get_latest_matching<'a>(&'a self, _name: &'a PackageName, _req: &'a deps_core::VersionReq)
59/// -> Pin<Box<dyn std::future::Future<Output = deps_core::error::Result<Option<Box<dyn Version>>>> + Send + 'a>>
60/// {
61/// Box::pin(async move { Ok(None) })
62/// }
63///
64/// fn search<'a>(&'a self, _query: &'a str, _limit: usize)
65/// -> Pin<Box<dyn std::future::Future<Output = deps_core::error::Result<Vec<Box<dyn Metadata>>>> + Send + 'a>>
66/// {
67/// Box::pin(async move { Ok(vec![]) })
68/// }
69///
70/// fn as_any(&self) -> &dyn Any { self }
71/// }
72/// ```
73pub trait Registry: Send + Sync {
74 /// Fetches all available versions for a package.
75 ///
76 /// Returns versions sorted newest-first. May include yanked/deprecated versions.
77 ///
78 /// # Errors
79 ///
80 /// Returns error if:
81 /// - Package does not exist
82 /// - Network request fails
83 /// - Response parsing fails
84 fn get_versions<'a>(
85 &'a self,
86 name: &'a PackageName,
87 ) -> BoxFuture<'a, Result<Vec<Box<dyn Version>>>>;
88
89 /// Like [`get_versions`](Self::get_versions), but lets a registry that can obtain
90 /// [`Version::published_at`] only through an *extra* request gate that request behind
91 /// `freshness.enabled` instead of always paying for it.
92 ///
93 /// Implementors overriding this method MUST keep every other aspect of
94 /// [`get_versions`](Self::get_versions)'s behavior — set, order, and content of the
95 /// returned versions — identical; the only difference the override may introduce is
96 /// populating [`Version::published_at`]. Callers that render publish ages MUST call this
97 /// method rather than [`get_versions`](Self::get_versions): the default implementation
98 /// below simply forwards to `get_versions` and ignores `freshness`, so a caller that
99 /// keeps calling `get_versions` silently gets no freshness signal even from a registry
100 /// that implements this override.
101 ///
102 /// Default: forwards to [`get_versions`](Self::get_versions), ignoring `freshness`. This
103 /// keeps the ten registries with no extra publish-time source unchanged, and keeps
104 /// [`FreshnessSettings`](crate::freshness::FreshnessSettings) — a `Copy + 'static` DTO —
105 /// out of every `Registry::new` signature and the `register!` ecosystem-registration
106 /// macro.
107 fn get_versions_with<'a>(
108 &'a self,
109 name: &'a PackageName,
110 freshness: crate::freshness::FreshnessSettings,
111 ) -> BoxFuture<'a, Result<Vec<Box<dyn Version>>>> {
112 let _ = freshness;
113 self.get_versions(name)
114 }
115
116 /// Like [`get_versions_with`](Self::get_versions_with), but additionally carries the
117 /// dependency's resolved [`DependencySource`], for a registry that routes a single
118 /// fetch across more than one underlying index (e.g. `deps-cargo`'s `CargoRegistry`,
119 /// which dispatches a `DependencySource::AlternateRegistry` to a private sparse index
120 /// instead of crates.io).
121 ///
122 /// Default: forwards to [`get_versions_with`](Self::get_versions_with), ignoring
123 /// `source` entirely. This keeps every registry with no per-dependency routing concept
124 /// — every ecosystem except Cargo today — bit-identical: `source` is accepted and
125 /// dropped, so a caller migrating to this method from `get_versions_with` changes no
126 /// observable behavior for them.
127 ///
128 /// Callers that know which source a dependency resolved to should call this rather
129 /// than [`get_versions_with`](Self::get_versions_with), even against a registry with no
130 /// override — the whole point is that call sites don't need to know which registries
131 /// route on source and which don't.
132 fn get_versions_from<'a>(
133 &'a self,
134 name: &'a PackageName,
135 source: &'a DependencySource,
136 freshness: crate::freshness::FreshnessSettings,
137 ) -> BoxFuture<'a, Result<Vec<Box<dyn Version>>>> {
138 let _ = source;
139 self.get_versions_with(name, freshness)
140 }
141
142 /// Finds the latest version matching a version requirement.
143 ///
144 /// Filter with [`RemovalStatus::blocks_resolution`], never with
145 /// [`RemovalStatus::is_flagged`]. An `AdvisoryDeprecated` version is fully
146 /// installable — excluding it turns an existing package into a false
147 /// "Unknown package" (#347). Under a wildcard/empty requirement
148 /// (`"*"`/`""`, see [`is_existence_wildcard`]) this is an *existence* check ("does this
149 /// package exist / what is its newest version for display purposes"), not an upgrade
150 /// recommendation: an implementation may prefer a non-yanked version but fall back to a
151 /// yanked one rather than returning `None` when no non-yanked version exists — a yanked
152 /// package still exists. `deps-npm` implements this fallback (mirrored by
153 /// [`select_latest_matching`](Self::select_latest_matching)'s wildcard branch on the
154 /// same registry, which every caller reaching this trait method through the shared
155 /// fetch loop actually goes through first); the exception never applies to a concrete
156 /// requirement.
157 ///
158 /// # Arguments
159 ///
160 /// * `name` - Package name
161 /// * `req` - Version requirement string (e.g., "^1.0", ">=2.0")
162 ///
163 /// # Returns
164 ///
165 /// - `Ok(Some(version))` - Latest matching version found
166 /// - `Ok(None)` - No matching version found
167 /// - `Err(_)` - Network or parsing error
168 fn get_latest_matching<'a>(
169 &'a self,
170 name: &'a PackageName,
171 req: &'a VersionReq,
172 ) -> BoxFuture<'a, Result<Option<Box<dyn Version>>>>;
173
174 /// Like [`get_latest_matching`](Self::get_latest_matching), but lets a registry whose
175 /// "latest matching" selection can be refined by ecosystem-specific manifest state (e.g.
176 /// Composer's `minimum-stability` field, #424) read it, alongside `req`.
177 ///
178 /// `minimum_stability` is an opaque, ecosystem-defined string (Composer's own stability
179 /// keyword: `"dev"`, `"alpha"`, `"beta"`, `"RC"`, or `"stable"`) rather than a shared type,
180 /// mirroring [`get_versions_with`](Self::get_versions_with)'s
181 /// [`FreshnessSettings`](crate::freshness::FreshnessSettings) precedent for "an optional
182 /// extra parameter most registries ignore" — except here even the *shape* of the extra
183 /// context is ecosystem-specific, so no shared DTO is introduced for it; only the one
184 /// registry that understands the string overrides this method.
185 ///
186 /// Default: forwards to [`get_latest_matching`](Self::get_latest_matching), ignoring
187 /// `minimum_stability`. This keeps every registry with no manifest-level stability
188 /// concept unchanged.
189 fn get_latest_matching_with_context<'a>(
190 &'a self,
191 name: &'a PackageName,
192 req: &'a VersionReq,
193 minimum_stability: Option<&'a str>,
194 ) -> BoxFuture<'a, Result<Option<Box<dyn Version>>>> {
195 let _ = minimum_stability;
196 self.get_latest_matching(name, req)
197 }
198
199 /// Like [`get_latest_matching_with_context`](Self::get_latest_matching_with_context),
200 /// but additionally carries the dependency's resolved [`DependencySource`] — the
201 /// `get_latest_matching`-shaped counterpart to
202 /// [`get_versions_from`](Self::get_versions_from), covering the fallback path a caller
203 /// takes when the list-based pick fails on a non-empty [`get_versions_from`](Self::get_versions_from)
204 /// result (see `deps_core::lsp_helpers::hover`'s `list_fallback_latest` and
205 /// `deps-lsp`'s background-fetch fallback for the two call sites this exists for).
206 ///
207 /// Default: forwards to
208 /// [`get_latest_matching_with_context`](Self::get_latest_matching_with_context),
209 /// ignoring `source` — every registry with no per-dependency routing concept stays
210 /// bit-identical, exactly as [`get_versions_from`](Self::get_versions_from) does for the
211 /// list-fetching side.
212 fn get_latest_matching_from<'a>(
213 &'a self,
214 name: &'a PackageName,
215 source: &'a DependencySource,
216 req: &'a VersionReq,
217 minimum_stability: Option<&'a str>,
218 ) -> BoxFuture<'a, Result<Option<Box<dyn Version>>>> {
219 let _ = source;
220 self.get_latest_matching_with_context(name, req, minimum_stability)
221 }
222
223 /// Searches for packages by name or keywords.
224 ///
225 /// Returns up to `limit` results sorted by relevance/popularity.
226 ///
227 /// # Errors
228 ///
229 /// Returns error if network request or parsing fails.
230 fn search<'a>(
231 &'a self,
232 query: &'a str,
233 limit: usize,
234 ) -> BoxFuture<'a, Result<Vec<Box<dyn Metadata>>>>;
235
236 /// Index of the latest version in `versions` satisfying `req`, with no I/O.
237 ///
238 /// Filter with [`RemovalStatus::blocks_resolution`], never with
239 /// [`RemovalStatus::is_flagged`]. An `AdvisoryDeprecated` version is fully
240 /// installable — excluding it turns an existing package into a false
241 /// "Unknown package" (#347). Under a wildcard/empty requirement (see
242 /// [`is_existence_wildcard`]) this is an *existence* check, not an upgrade
243 /// recommendation: Cargo, PyPI, Dart, npm, and Deno implement it by gating on
244 /// [`is_existence_wildcard`] and delegating to [`select_latest_for_existence`].
245 ///
246 /// `versions` must be a newest-first list as returned by this registry's
247 /// [`get_versions`](Self::get_versions). Returns an index rather than a reference so
248 /// callers holding an owned `Vec` can move the chosen element out
249 /// (`versions.into_iter().nth(i)`) — a borrow into the list would keep it frozen while
250 /// [`get_latest_matching`](Self::get_latest_matching) needs to return an owned
251 /// `Box<dyn Version>`, and `Version` has no `clone_box`.
252 ///
253 /// Default: `None`. Every registry reachable from the LSP fetch path overrides this so
254 /// the fetch loop can obtain both "latest" and the full version list from one round
255 /// trip; the default exists so test doubles that never resolve a "latest" compile
256 /// unchanged.
257 fn select_latest_matching(
258 &self,
259 _versions: &[Box<dyn Version>],
260 _req: &VersionReq,
261 ) -> Option<usize> {
262 None
263 }
264
265 /// Like [`select_latest_matching`](Self::select_latest_matching), but lets a registry
266 /// whose selection can be refined by ecosystem-specific manifest state (e.g. Composer's
267 /// `minimum-stability` field, #424) read it, alongside `versions` and `req`. See
268 /// [`get_latest_matching_with_context`](Self::get_latest_matching_with_context) for why
269 /// `minimum_stability` is an opaque per-ecosystem string rather than a shared type.
270 ///
271 /// Default: forwards to [`select_latest_matching`](Self::select_latest_matching), ignoring
272 /// `minimum_stability`. This keeps every registry with no manifest-level stability concept
273 /// unchanged.
274 fn select_latest_matching_with_context(
275 &self,
276 versions: &[Box<dyn Version>],
277 req: &VersionReq,
278 minimum_stability: Option<&str>,
279 ) -> Option<usize> {
280 let _ = minimum_stability;
281 self.select_latest_matching(versions, req)
282 }
283
284 /// Whether [`get_versions`](Self::get_versions) results carry meaningful
285 /// per-version yank/deprecation data via [`Version::removal_status`].
286 ///
287 /// Default `true`: a registry is opted into the yanked-version diagnostic
288 /// unless it explicitly says it cannot answer. This fails toward
289 /// correctness — a registry whose `removal_status` later becomes real
290 /// data starts participating automatically, by deleting its opt-out
291 /// rather than by someone remembering to add an opt-in. Return `false`
292 /// only when `removal_status()` is hardcoded (e.g. always
293 /// `RemovalStatus::Available`) or otherwise cannot reflect real registry
294 /// data; a `true` return authorizes callers to trust `removal_status()`
295 /// on versions from this registry's normal
296 /// [`get_versions`](Self::get_versions)/
297 /// [`get_latest_matching`](Self::get_latest_matching) results — it does
298 /// not trigger any additional network request.
299 fn reports_yanked(&self) -> bool {
300 true
301 }
302
303 /// Downcast to concrete registry type for ecosystem-specific operations
304 fn as_any(&self) -> &dyn Any;
305}
306
307/// Whether `version` contains one of the common pre-release substrings
308/// (`-alpha`, `-beta`, `-rc`, `-dev`, `-pre`, `-snapshot`, `-canary`,
309/// `-nightly`), case-insensitively.
310///
311/// This is [`Version::is_prerelease`]'s default heuristic, extracted so an
312/// ecosystem that overrides `is_prerelease` to cover format-specific gaps
313/// (e.g. Composer's `dev-` branch-alias prefix) can extend this baseline
314/// instead of copy-pasting the substring list out of sync with it.
315///
316/// # Examples
317///
318/// ```
319/// use deps_core::has_default_prerelease_marker;
320///
321/// assert!(has_default_prerelease_marker("1.0.0-beta"));
322/// assert!(has_default_prerelease_marker("1.0.0-RC1"));
323/// assert!(!has_default_prerelease_marker("1.0.0"));
324/// ```
325#[must_use]
326pub fn has_default_prerelease_marker(version: &str) -> bool {
327 let v = version.to_lowercase();
328 v.contains("-alpha")
329 || v.contains("-beta")
330 || v.contains("-rc")
331 || v.contains("-dev")
332 || v.contains("-pre")
333 || v.contains("-snapshot")
334 || v.contains("-canary")
335 || v.contains("-nightly")
336}
337
338/// Hashes an ordered sequence of `&str` routing-hop parts into an opaque
339/// `"{prefix}:{digest:016x}"` chain-identity key, using
340/// [`std::collections::hash_map::DefaultHasher`].
341///
342/// Extracted from three independently hand-rolled copies of this exact pattern
343/// (`deps_pypi::config::ResolvedChain::chain`, `deps_nuget::config::NuGetSourceChain::chain`,
344/// `deps_go::config::GoProxyChain::keyed`, #579) so "never hash a credential" is a property of
345/// one function signature instead of a convention repeated in three doc comments.
346///
347/// Deliberately takes `&str`, not `impl Hash`: a caller with a typed credential wrapper (e.g.
348/// `NuGetAuth`, which deliberately does not derive `Hash` — see its doc) must explicitly
349/// convert it to a string before it can even be considered here, rather than being able to
350/// `.hash()` the whole struct in place. Prefer a small, explicit, non-`Debug` `as_key_str()`-
351/// style accessor on the hashed field's own type over `format!("{value:?}")` at the call
352/// site — chain identity would otherwise silently change if that type's `Debug` output is
353/// ever reworded (e.g. an enum variant rename), even though nothing routing-relevant changed.
354///
355/// **Security invariant**: `parts` must contain only routing-identity data (URLs, slot/hop
356/// kind markers, boolean flags) — **never** credential material. Hashing a credential would
357/// make the chain's identity key change whenever that credential value rotates, defeating
358/// rotation-stability guarantees callers rely on (e.g. NuGet issue #561's FR-016). This
359/// function has no way to enforce that; it is a caller obligation.
360///
361/// # Examples
362///
363/// ```
364/// use deps_core::hash_routing_key;
365///
366/// let key = hash_routing_key("pypi-chain", ["https://example.test/simple/", "true"].into_iter());
367/// assert!(key.starts_with("pypi-chain:"));
368/// assert_eq!(key.len(), "pypi-chain:".len() + 16);
369///
370/// // Same parts, same key — deterministic within a process.
371/// let key2 = hash_routing_key("pypi-chain", ["https://example.test/simple/", "true"].into_iter());
372/// assert_eq!(key, key2);
373///
374/// // Hop order is part of the identity.
375/// let forward = hash_routing_key("go-proxy", ["a", "b"].into_iter());
376/// let reversed = hash_routing_key("go-proxy", ["b", "a"].into_iter());
377/// assert_ne!(forward, reversed);
378/// ```
379#[must_use]
380pub fn hash_routing_key<'a>(prefix: &str, parts: impl Iterator<Item = &'a str>) -> String {
381 use std::hash::{Hash, Hasher};
382
383 let mut hasher = std::collections::hash_map::DefaultHasher::new();
384 for part in parts {
385 part.hash(&mut hasher);
386 }
387 format!("{prefix}:{:016x}", hasher.finish())
388}
389
390/// Outcome of a registry's per-version removal/deprecation signal.
391///
392/// Replaces a bare `is_yanked(): bool`, which could not distinguish a version
393/// a registry has *hard-removed from resolution* (`Yanked`) from one that is
394/// merely flagged as deprecated/abandoned but still fully installable
395/// (`AdvisoryDeprecated`). Conflating the two caused #347: Composer's
396/// package-level `abandoned` flag was read through `is_yanked()`, so every
397/// version of an abandoned-but-installable package was filtered out of
398/// resolution, turning an existing package into a false "Unknown package"
399/// diagnostic.
400///
401/// Call [`blocks_resolution`](Self::blocks_resolution) to decide whether a
402/// version may be selected as an upgrade/latest candidate. Call
403/// [`is_flagged`](Self::is_flagged) only to surface the registry's flag to
404/// the user (e.g. a yanked/deprecated diagnostic) — never to filter
405/// resolution.
406///
407/// # Examples
408///
409/// ```
410/// use deps_core::RemovalStatus;
411///
412/// assert!(!RemovalStatus::Available.blocks_resolution());
413/// assert!(!RemovalStatus::AdvisoryDeprecated.blocks_resolution());
414/// assert!(RemovalStatus::Yanked.blocks_resolution());
415///
416/// assert!(RemovalStatus::AdvisoryDeprecated.is_flagged());
417/// assert!(RemovalStatus::Yanked.is_flagged());
418/// assert!(!RemovalStatus::Available.is_flagged());
419/// ```
420#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
421pub enum RemovalStatus {
422 /// The registry reports no removal/deprecation signal for this version.
423 Available,
424 /// The registry flags this version (or its package) as
425 /// deprecated/abandoned, but the version remains fully resolvable and
426 /// installable.
427 AdvisoryDeprecated,
428 /// The registry has hard-removed this version from fresh resolution (a
429 /// real yank/retraction). Existing installs may keep using it, but it
430 /// must not be selected as an upgrade/latest candidate.
431 Yanked,
432}
433
434impl RemovalStatus {
435 /// Whether fresh resolution must skip this version.
436 ///
437 /// `true` only for [`Yanked`](Self::Yanked). An
438 /// [`AdvisoryDeprecated`](Self::AdvisoryDeprecated) version is fully
439 /// installable — excluding it from resolution is what caused #347.
440 ///
441 /// # Examples
442 ///
443 /// ```
444 /// use deps_core::RemovalStatus;
445 ///
446 /// assert!(!RemovalStatus::AdvisoryDeprecated.blocks_resolution());
447 /// assert!(RemovalStatus::Yanked.blocks_resolution());
448 /// ```
449 #[must_use]
450 pub const fn blocks_resolution(self) -> bool {
451 matches!(self, Self::Yanked)
452 }
453
454 /// Whether the registry flags this version at all, hard or advisory.
455 ///
456 /// Use this only to decide whether to surface a warning to the user —
457 /// never to filter resolution; use
458 /// [`blocks_resolution`](Self::blocks_resolution) for that.
459 ///
460 /// # Examples
461 ///
462 /// ```
463 /// use deps_core::RemovalStatus;
464 ///
465 /// assert!(!RemovalStatus::Available.is_flagged());
466 /// assert!(RemovalStatus::AdvisoryDeprecated.is_flagged());
467 /// assert!(RemovalStatus::Yanked.is_flagged());
468 /// ```
469 #[must_use]
470 pub const fn is_flagged(self) -> bool {
471 !matches!(self, Self::Available)
472 }
473
474 /// Builds a status from a registry's hard yanked/retracted boolean flag.
475 ///
476 /// # Examples
477 ///
478 /// ```
479 /// use deps_core::RemovalStatus;
480 ///
481 /// assert_eq!(RemovalStatus::from_yanked(true), RemovalStatus::Yanked);
482 /// assert_eq!(RemovalStatus::from_yanked(false), RemovalStatus::Available);
483 /// ```
484 #[must_use]
485 pub const fn from_yanked(flag: bool) -> Self {
486 if flag { Self::Yanked } else { Self::Available }
487 }
488
489 /// Builds a status from a registry's advisory deprecated/abandoned boolean flag.
490 ///
491 /// # Examples
492 ///
493 /// ```
494 /// use deps_core::RemovalStatus;
495 ///
496 /// assert_eq!(RemovalStatus::from_advisory(true), RemovalStatus::AdvisoryDeprecated);
497 /// assert_eq!(RemovalStatus::from_advisory(false), RemovalStatus::Available);
498 /// ```
499 #[must_use]
500 pub const fn from_advisory(flag: bool) -> Self {
501 if flag {
502 Self::AdvisoryDeprecated
503 } else {
504 Self::Available
505 }
506 }
507}
508
509/// Package-level deprecation/abandonment payload: why a package is deprecated, and what
510/// to use instead.
511///
512/// Distinct from [`RemovalStatus`], which answers *"may resolution select this
513/// version"*. `Deprecation` answers *"what should the user be told"* — the reason text
514/// and/or a registry-supplied replacement package name. Kept as a separate type rather
515/// than a payload-carrying `RemovalStatus` variant: `RemovalStatus` is `Copy`, returned
516/// from `const fn`s, and matched in every ecosystem crate, so widening it would ripple
517/// everywhere for a concept that only a couple of registries expose.
518///
519/// Both fields are `None` when the registry has nothing to say beyond "deprecated" —
520/// e.g. Packagist's bare `"abandoned": true`, with no replacement named.
521///
522/// # Examples
523///
524/// ```
525/// use deps_core::Deprecation;
526///
527/// let deprecation = Deprecation {
528/// reason: Some("no longer maintained".into()),
529/// replacement: Some("some-other/package".into()),
530/// };
531///
532/// assert_eq!(deprecation.reason.as_deref(), Some("no longer maintained"));
533/// assert_eq!(deprecation.replacement.as_deref(), Some("some-other/package"));
534/// ```
535#[derive(Debug, Clone, PartialEq, Eq)]
536pub struct Deprecation {
537 /// Free-text reason the registry gives for the deprecation, if any.
538 pub reason: Option<String>,
539 /// Registry-supplied replacement package name, if any. Only ever populated from a
540 /// structured registry field (never regex-extracted from free text — see #205's
541 /// typosquatting rationale), so it is safe to offer as a rename target.
542 pub replacement: Option<String>,
543}
544
545/// Version information trait.
546///
547/// All version types must implement this to work with generic handlers.
548pub trait Version: Send + Sync {
549 /// Version string (e.g., "1.0.214", "14.21.3").
550 fn version_string(&self) -> &ConcreteVersion;
551
552 /// This version's removal/deprecation status as reported by the registry.
553 ///
554 /// Default: [`RemovalStatus::Available`] — ecosystems with no
555 /// yank/deprecation signal need no override.
556 fn removal_status(&self) -> RemovalStatus {
557 RemovalStatus::Available
558 }
559
560 /// Package-level deprecation payload, when this version's registry data carries one.
561 ///
562 /// Default `None` — the `Option` is the capability gate: an ecosystem that never
563 /// overrides this produces no deprecation diagnostic/hover/quickfix at all, rather
564 /// than depending on a separate "does this ecosystem report deprecation" predicate.
565 fn deprecation(&self) -> Option<&Deprecation> {
566 None
567 }
568
569 /// Whether this version is a pre-release (alpha, beta, rc, etc.).
570 ///
571 /// Default implementation checks for common pre-release patterns via
572 /// [`has_default_prerelease_marker`].
573 fn is_prerelease(&self) -> bool {
574 has_default_prerelease_marker(self.version_string().as_str())
575 }
576
577 /// Available feature flags (empty if not supported by ecosystem).
578 fn features(&self) -> Vec<String> {
579 vec![]
580 }
581
582 /// Downcast to concrete version type
583 fn as_any(&self) -> &dyn Any;
584
585 /// Whether this version is stable: not hard-yanked and not a pre-release.
586 ///
587 /// An [`AdvisoryDeprecated`](RemovalStatus::AdvisoryDeprecated) version counts as
588 /// stable here — it remains fully resolvable, just flagged. Only
589 /// [`Yanked`](RemovalStatus::Yanked) disqualifies a version; see
590 /// [`RemovalStatus::blocks_resolution`].
591 fn is_stable(&self) -> bool {
592 !self.removal_status().blocks_resolution() && !self.is_prerelease()
593 }
594
595 /// When this version was published, if the registry exposes it.
596 ///
597 /// Default `None` — ecosystems without publish metadata (or where
598 /// fetching it would add a network round trip) degrade to pre-feature
599 /// behavior: no freshness signal shown, no error, no change in ranking.
600 fn published_at(&self) -> Option<crate::freshness::PublishTime> {
601 None
602 }
603}
604
605/// Finds the latest stable version from a list of versions.
606///
607/// Returns the first version that is:
608/// - Not hard-yanked ([`RemovalStatus::Yanked`]) — an
609/// [`AdvisoryDeprecated`](RemovalStatus::AdvisoryDeprecated) version still counts as
610/// stable, since it remains fully resolvable
611/// - Not a pre-release (alpha, beta, rc, etc.)
612///
613/// Assumes versions are sorted newest-first (as returned by registries).
614///
615/// # Examples
616///
617/// ```
618/// use deps_core::registry::{RemovalStatus, Version, find_latest_stable};
619/// use deps_core::ConcreteVersion;
620/// use std::any::Any;
621///
622/// struct MyVersion { version: ConcreteVersion, yanked: bool }
623///
624/// impl Version for MyVersion {
625/// fn version_string(&self) -> &ConcreteVersion { &self.version }
626/// fn removal_status(&self) -> RemovalStatus { RemovalStatus::from_yanked(self.yanked) }
627/// fn as_any(&self) -> &dyn Any { self }
628/// }
629///
630/// let versions: Vec<Box<dyn Version>> = vec![
631/// Box::new(MyVersion { version: "2.0.0-alpha.1".into(), yanked: false }),
632/// Box::new(MyVersion { version: "1.5.0".into(), yanked: true }),
633/// Box::new(MyVersion { version: "1.4.0".into(), yanked: false }),
634/// ];
635///
636/// let latest = find_latest_stable(&versions);
637/// assert_eq!(latest.map(|v| v.version_string().as_str()), Some("1.4.0"));
638/// ```
639pub fn find_latest_stable(versions: &[Box<dyn Version>]) -> Option<&dyn Version> {
640 versions.iter().find(|v| v.is_stable()).map(|v| v.as_ref())
641}
642
643/// Whether `req` is a wildcard/empty requirement (`""` or `"*"`, ignoring surrounding
644/// whitespace) rather than a concrete version constraint.
645///
646/// A wildcard requirement turns [`get_latest_matching`](Registry::get_latest_matching) and
647/// [`select_latest_matching`](Registry::select_latest_matching) into an *existence* check
648/// ("does this package exist / what is its newest version for display purposes") instead of
649/// an upgrade recommendation — see [`select_latest_for_existence`], which callers must gate
650/// on this function before use.
651///
652/// # Examples
653///
654/// ```
655/// use deps_core::{VersionReq, is_existence_wildcard};
656///
657/// assert!(is_existence_wildcard(&VersionReq::new("*")));
658/// assert!(is_existence_wildcard(&VersionReq::new("")));
659/// assert!(!is_existence_wildcard(&VersionReq::new("^1.2")));
660/// ```
661#[must_use]
662pub fn is_existence_wildcard(req: &crate::VersionReq) -> bool {
663 is_existence_wildcard_str(req.as_str())
664}
665
666/// [`is_existence_wildcard`] for a raw `&str`, without allocating a [`VersionReq`] wrapper.
667///
668/// Exists for callers that hold a version requirement as `&str` rather than a `VersionReq`
669/// (e.g. an inherent method taking `req_str: &str` for its own parsing needs) — going through
670/// [`is_existence_wildcard`] there would allocate a `String` via `VersionReq::new` on every
671/// call just to check it.
672///
673/// # Examples
674///
675/// ```
676/// use deps_core::registry::is_existence_wildcard_str;
677///
678/// assert!(is_existence_wildcard_str("*"));
679/// assert!(is_existence_wildcard_str(""));
680/// assert!(!is_existence_wildcard_str("^1.2"));
681/// ```
682#[must_use]
683pub fn is_existence_wildcard_str(req: &str) -> bool {
684 matches!(req.trim(), "" | "*")
685}
686
687/// Index of the version an *existence* check should report as "latest", ignoring `req`
688/// entirely.
689///
690/// This is the shared 3-rung fallback ladder used under a wildcard/empty requirement, where
691/// every version satisfies by definition and the question is only which one to prefer for
692/// display:
693///
694/// 1. The newest version that is neither flagged
695/// ([`RemovalStatus::is_flagged`]) nor a pre-release ([`Version::is_prerelease`]).
696/// 2. Else, the newest version that does not block resolution
697/// ([`RemovalStatus::blocks_resolution`]) — an `AdvisoryDeprecated` version counts here.
698/// 3. Else, index `0` unconditionally — the newest version overall, however it is flagged.
699/// A yanked-or-prerelease-only package still exists; this rung is what turns that case
700/// into "here is its newest version" instead of a false "Unknown package" (#347, #364).
701///
702/// `versions` must be sorted newest-first, as returned by [`Registry::get_versions`]. Returns
703/// `None` only when `versions` is empty.
704///
705/// # Requirement-blindness is deliberate and dangerous
706///
707/// This function takes no `req` parameter and does not check whether the caller is under a
708/// wildcard requirement — it always returns rung 3 as a last resort, regardless of what a
709/// concrete requirement might demand. It is **only correct once the caller has already
710/// confirmed the requirement is a wildcard** via [`is_existence_wildcard`]. Calling it
711/// ungated — e.g. under a concrete `^1.2` requirement — can return a version that does not
712/// satisfy that requirement at all, silently corrupting upgrade resolution.
713///
714/// # Examples
715///
716/// ```
717/// use deps_core::registry::{RemovalStatus, Version, select_latest_for_existence};
718/// use deps_core::ConcreteVersion;
719/// use std::any::Any;
720///
721/// struct MyVersion { version: ConcreteVersion, status: RemovalStatus, prerelease: bool }
722///
723/// impl Version for MyVersion {
724/// fn version_string(&self) -> &ConcreteVersion { &self.version }
725/// fn removal_status(&self) -> RemovalStatus { self.status }
726/// fn is_prerelease(&self) -> bool { self.prerelease }
727/// fn as_any(&self) -> &dyn Any { self }
728/// }
729///
730/// // Newest version is yanked; rung 3 still returns it rather than `None`.
731/// let versions = vec![
732/// MyVersion { version: "2.0.0".into(), status: RemovalStatus::Yanked, prerelease: false },
733/// MyVersion { version: "1.5.0".into(), status: RemovalStatus::Yanked, prerelease: false },
734/// ];
735///
736/// let idx = select_latest_for_existence(&versions, |v| v as &dyn Version);
737/// assert_eq!(idx, Some(0));
738///
739/// assert_eq!(select_latest_for_existence::<MyVersion>(&[], |v| v as &dyn Version), None);
740/// ```
741#[must_use]
742pub fn select_latest_for_existence<T>(
743 versions: &[T],
744 as_version: impl Fn(&T) -> &dyn Version,
745) -> Option<usize> {
746 if versions.is_empty() {
747 return None;
748 }
749 Some(
750 versions
751 .iter()
752 .position(|v| {
753 let v = as_version(v);
754 !v.removal_status().is_flagged() && !v.is_prerelease()
755 })
756 .or_else(|| {
757 versions
758 .iter()
759 .position(|v| !as_version(v).removal_status().blocks_resolution())
760 })
761 .unwrap_or(0),
762 )
763}
764
765/// Package metadata trait.
766///
767/// Used for completion items and hover documentation.
768pub trait Metadata: Send + Sync {
769 /// The package name as the *registry* reports it.
770 ///
771 /// This is the identifier the registry displays and that completion
772 /// pastes into a manifest. It is not guaranteed byte-identical to the
773 /// manifest-declared [`Dependency::name`](crate::ecosystem::Dependency::name)
774 /// for the same package: casing may differ (NuGet, Composer, Swift), and
775 /// for Maven/Gradle it is a `"group:artifact"` value synthesized from two
776 /// separate response fields. Do not compare it to a manifest name
777 /// without going through
778 /// [`PackageNaming::normalize_package_name`](crate::lsp_helpers::PackageNaming::normalize_package_name).
779 fn name(&self) -> &crate::PackageName;
780
781 /// Short description (optional).
782 fn description(&self) -> Option<&str>;
783
784 /// Repository URL (optional).
785 fn repository(&self) -> Option<&str>;
786
787 /// Documentation URL (optional).
788 fn documentation(&self) -> Option<&str>;
789
790 /// Latest stable version.
791 fn latest_version(&self) -> &ConcreteVersion;
792
793 /// Downcast to concrete metadata type
794 fn as_any(&self) -> &dyn Any;
795}
796
797#[cfg(test)]
798mod tests {
799 use super::*;
800
801 struct MockVersion {
802 version: ConcreteVersion,
803 yanked: bool,
804 }
805
806 impl Version for MockVersion {
807 fn version_string(&self) -> &ConcreteVersion {
808 &self.version
809 }
810
811 fn removal_status(&self) -> RemovalStatus {
812 RemovalStatus::from_yanked(self.yanked)
813 }
814
815 fn as_any(&self) -> &dyn Any {
816 self
817 }
818 }
819
820 #[test]
821 fn test_version_default_features() {
822 let version = MockVersion {
823 version: "1.0.0".into(),
824 yanked: false,
825 };
826
827 assert_eq!(version.features(), Vec::<String>::new());
828 }
829
830 #[test]
831 fn test_version_trait_object() {
832 let version = MockVersion {
833 version: "1.2.3".into(),
834 yanked: false,
835 };
836
837 let boxed: Box<dyn Version> = Box::new(version);
838 assert_eq!(boxed.version_string().as_str(), "1.2.3");
839 assert!(!boxed.removal_status().blocks_resolution());
840 }
841
842 #[test]
843 fn test_version_downcast() {
844 let version = MockVersion {
845 version: "1.0.0".into(),
846 yanked: true,
847 };
848
849 let boxed: Box<dyn Version> = Box::new(version);
850 let any = boxed.as_any();
851
852 assert!(any.is::<MockVersion>());
853 }
854
855 struct MockMetadata {
856 name: crate::PackageName,
857 latest: ConcreteVersion,
858 }
859
860 impl Metadata for MockMetadata {
861 fn name(&self) -> &crate::PackageName {
862 &self.name
863 }
864
865 fn description(&self) -> Option<&str> {
866 None
867 }
868
869 fn repository(&self) -> Option<&str> {
870 None
871 }
872
873 fn documentation(&self) -> Option<&str> {
874 None
875 }
876
877 fn latest_version(&self) -> &ConcreteVersion {
878 &self.latest
879 }
880
881 fn as_any(&self) -> &dyn Any {
882 self
883 }
884 }
885
886 #[test]
887 fn test_metadata_trait_object() {
888 let metadata = MockMetadata {
889 name: crate::PackageName::new("test-package"),
890 latest: "2.0.0".into(),
891 };
892
893 let boxed: Box<dyn Metadata> = Box::new(metadata);
894 assert_eq!(boxed.name(), "test-package");
895 assert_eq!(boxed.latest_version().as_str(), "2.0.0");
896 assert!(boxed.description().is_none());
897 assert!(boxed.repository().is_none());
898 assert!(boxed.documentation().is_none());
899 }
900
901 #[test]
902 fn test_metadata_with_full_info() {
903 struct FullMetadata {
904 name: crate::PackageName,
905 desc: String,
906 repo: String,
907 docs: String,
908 latest: ConcreteVersion,
909 }
910
911 impl Metadata for FullMetadata {
912 fn name(&self) -> &crate::PackageName {
913 &self.name
914 }
915 fn description(&self) -> Option<&str> {
916 Some(&self.desc)
917 }
918 fn repository(&self) -> Option<&str> {
919 Some(&self.repo)
920 }
921 fn documentation(&self) -> Option<&str> {
922 Some(&self.docs)
923 }
924 fn latest_version(&self) -> &ConcreteVersion {
925 &self.latest
926 }
927 fn as_any(&self) -> &dyn Any {
928 self
929 }
930 }
931
932 let meta = FullMetadata {
933 name: crate::PackageName::new("serde"),
934 desc: "Serialization framework".into(),
935 repo: "https://github.com/serde-rs/serde".into(),
936 docs: "https://docs.rs/serde".into(),
937 latest: "1.0.214".into(),
938 };
939
940 assert_eq!(meta.description(), Some("Serialization framework"));
941 assert_eq!(meta.repository(), Some("https://github.com/serde-rs/serde"));
942 assert_eq!(meta.documentation(), Some("https://docs.rs/serde"));
943 }
944
945 #[test]
946 fn test_is_prerelease_alpha() {
947 let version = MockVersion {
948 version: "4.0.0-alpha.13".into(),
949 yanked: false,
950 };
951 assert!(version.is_prerelease());
952 }
953
954 #[test]
955 fn test_is_prerelease_beta() {
956 let version = MockVersion {
957 version: "2.0.0-beta.1".into(),
958 yanked: false,
959 };
960 assert!(version.is_prerelease());
961 }
962
963 #[test]
964 fn test_is_prerelease_rc() {
965 let version = MockVersion {
966 version: "1.5.0-rc.2".into(),
967 yanked: false,
968 };
969 assert!(version.is_prerelease());
970 }
971
972 #[test]
973 fn test_is_prerelease_dev() {
974 let version = MockVersion {
975 version: "3.0.0-dev".into(),
976 yanked: false,
977 };
978 assert!(version.is_prerelease());
979 }
980
981 #[test]
982 fn test_is_prerelease_canary() {
983 let version = MockVersion {
984 version: "5.0.0-canary".into(),
985 yanked: false,
986 };
987 assert!(version.is_prerelease());
988 }
989
990 #[test]
991 fn test_is_prerelease_nightly() {
992 let version = MockVersion {
993 version: "6.0.0-nightly".into(),
994 yanked: false,
995 };
996 assert!(version.is_prerelease());
997 }
998
999 #[test]
1000 fn test_is_not_prerelease_stable() {
1001 let version = MockVersion {
1002 version: "1.2.3".into(),
1003 yanked: false,
1004 };
1005 assert!(!version.is_prerelease());
1006 }
1007
1008 #[test]
1009 fn test_is_not_prerelease_patch() {
1010 let version = MockVersion {
1011 version: "1.0.214".into(),
1012 yanked: false,
1013 };
1014 assert!(!version.is_prerelease());
1015 }
1016
1017 #[test]
1018 fn test_is_stable_true() {
1019 let version = MockVersion {
1020 version: "1.0.0".into(),
1021 yanked: false,
1022 };
1023 assert!(version.is_stable());
1024 }
1025
1026 #[test]
1027 fn test_is_stable_false_yanked() {
1028 let version = MockVersion {
1029 version: "1.0.0".into(),
1030 yanked: true,
1031 };
1032 assert!(!version.is_stable());
1033 }
1034
1035 #[test]
1036 fn test_is_stable_false_prerelease() {
1037 let version = MockVersion {
1038 version: "1.0.0-alpha.1".into(),
1039 yanked: false,
1040 };
1041 assert!(!version.is_stable());
1042 }
1043
1044 #[test]
1045 fn test_find_latest_stable_skips_prerelease() {
1046 let versions: Vec<Box<dyn Version>> = vec![
1047 Box::new(MockVersion {
1048 version: "2.0.0-alpha.1".into(),
1049 yanked: false,
1050 }),
1051 Box::new(MockVersion {
1052 version: "1.5.0".into(),
1053 yanked: false,
1054 }),
1055 ];
1056 let latest = super::find_latest_stable(&versions);
1057 assert_eq!(latest.map(|v| v.version_string().as_str()), Some("1.5.0"));
1058 }
1059
1060 #[test]
1061 fn test_find_latest_stable_skips_yanked() {
1062 let versions: Vec<Box<dyn Version>> = vec![
1063 Box::new(MockVersion {
1064 version: "2.0.0".into(),
1065 yanked: true,
1066 }),
1067 Box::new(MockVersion {
1068 version: "1.5.0".into(),
1069 yanked: false,
1070 }),
1071 ];
1072 let latest = super::find_latest_stable(&versions);
1073 assert_eq!(latest.map(|v| v.version_string().as_str()), Some("1.5.0"));
1074 }
1075
1076 #[test]
1077 fn test_find_latest_stable_returns_first_stable() {
1078 let versions: Vec<Box<dyn Version>> = vec![
1079 Box::new(MockVersion {
1080 version: "3.0.0-beta.1".into(),
1081 yanked: false,
1082 }),
1083 Box::new(MockVersion {
1084 version: "2.0.0".into(),
1085 yanked: true,
1086 }),
1087 Box::new(MockVersion {
1088 version: "1.5.0".into(),
1089 yanked: false,
1090 }),
1091 Box::new(MockVersion {
1092 version: "1.4.0".into(),
1093 yanked: false,
1094 }),
1095 ];
1096 let latest = super::find_latest_stable(&versions);
1097 assert_eq!(latest.map(|v| v.version_string().as_str()), Some("1.5.0"));
1098 }
1099
1100 #[test]
1101 fn test_find_latest_stable_empty_list() {
1102 let versions: Vec<Box<dyn Version>> = vec![];
1103 let latest = super::find_latest_stable(&versions);
1104 assert!(latest.is_none());
1105 }
1106
1107 #[test]
1108 fn test_find_latest_stable_no_stable_versions() {
1109 let versions: Vec<Box<dyn Version>> = vec![
1110 Box::new(MockVersion {
1111 version: "2.0.0-alpha.1".into(),
1112 yanked: false,
1113 }),
1114 Box::new(MockVersion {
1115 version: "1.0.0".into(),
1116 yanked: true,
1117 }),
1118 ];
1119 let latest = super::find_latest_stable(&versions);
1120 assert!(latest.is_none());
1121 }
1122
1123 struct StatusVersion {
1124 version: ConcreteVersion,
1125 status: RemovalStatus,
1126 }
1127
1128 impl Version for StatusVersion {
1129 fn version_string(&self) -> &ConcreteVersion {
1130 &self.version
1131 }
1132
1133 fn removal_status(&self) -> RemovalStatus {
1134 self.status
1135 }
1136
1137 fn as_any(&self) -> &dyn Any {
1138 self
1139 }
1140 }
1141
1142 #[test]
1143 fn test_is_stable_true_for_advisory_deprecated() {
1144 let version = StatusVersion {
1145 version: "1.0.0".into(),
1146 status: RemovalStatus::AdvisoryDeprecated,
1147 };
1148 assert!(version.is_stable());
1149 }
1150
1151 #[test]
1152 fn test_is_stable_false_for_yanked() {
1153 let version = StatusVersion {
1154 version: "1.0.0".into(),
1155 status: RemovalStatus::Yanked,
1156 };
1157 assert!(!version.is_stable());
1158 }
1159
1160 #[test]
1161 fn test_removal_status_blocks_resolution() {
1162 assert!(!RemovalStatus::Available.blocks_resolution());
1163 assert!(!RemovalStatus::AdvisoryDeprecated.blocks_resolution());
1164 assert!(RemovalStatus::Yanked.blocks_resolution());
1165 }
1166
1167 #[test]
1168 fn test_removal_status_is_flagged() {
1169 assert!(!RemovalStatus::Available.is_flagged());
1170 assert!(RemovalStatus::AdvisoryDeprecated.is_flagged());
1171 assert!(RemovalStatus::Yanked.is_flagged());
1172 }
1173
1174 #[test]
1175 fn test_removal_status_from_yanked() {
1176 assert_eq!(RemovalStatus::from_yanked(true), RemovalStatus::Yanked);
1177 assert_eq!(RemovalStatus::from_yanked(false), RemovalStatus::Available);
1178 }
1179
1180 #[test]
1181 fn test_removal_status_from_advisory() {
1182 assert_eq!(
1183 RemovalStatus::from_advisory(true),
1184 RemovalStatus::AdvisoryDeprecated
1185 );
1186 assert_eq!(
1187 RemovalStatus::from_advisory(false),
1188 RemovalStatus::Available
1189 );
1190 }
1191
1192 #[test]
1193 fn test_hash_routing_key_format() {
1194 let key = hash_routing_key("pypi-chain", ["https://example.test/simple/"].into_iter());
1195 assert!(key.starts_with("pypi-chain:"));
1196 assert_eq!(key.len(), "pypi-chain:".len() + 16);
1197 }
1198
1199 #[test]
1200 fn test_hash_routing_key_deterministic() {
1201 let parts = || ["https://a.test/", "https://b.test/", "true"].into_iter();
1202 assert_eq!(
1203 hash_routing_key("go-proxy", parts()),
1204 hash_routing_key("go-proxy", parts())
1205 );
1206 }
1207
1208 #[test]
1209 fn test_hash_routing_key_distinguishes_prefix() {
1210 let parts = || ["same"].into_iter();
1211 assert_ne!(
1212 hash_routing_key("pypi-chain", parts()),
1213 hash_routing_key("nuget-chain", parts())
1214 );
1215 }
1216
1217 #[test]
1218 fn test_hash_routing_key_distinguishes_parts() {
1219 assert_ne!(
1220 hash_routing_key("chain", ["a", "b"].into_iter()),
1221 hash_routing_key("chain", ["a", "c"].into_iter())
1222 );
1223 }
1224
1225 /// Hop order is part of chain identity (spec 034's fallback order, PyPI's FR-005
1226 /// primary-before-extras order, NuGet's declaration order all rely on this) — the same
1227 /// set of parts in a different order must not collide.
1228 #[test]
1229 fn test_hash_routing_key_order_sensitive() {
1230 assert_ne!(
1231 hash_routing_key("chain", ["a", "b", "c"].into_iter()),
1232 hash_routing_key("chain", ["c", "b", "a"].into_iter())
1233 );
1234 }
1235
1236 /// Documents that credential exclusion is a caller obligation, not something
1237 /// `hash_routing_key` enforces: two calls that differ only in a credential-shaped extra
1238 /// part produce different keys, so a caller who mistakenly includes one breaks the
1239 /// rotation-stability invariant this function's doc warns about.
1240 #[test]
1241 fn test_hash_routing_key_only_hashes_what_callers_pass() {
1242 let identity_only =
1243 hash_routing_key("nuget-chain", ["https://feed.test/", "corp"].into_iter());
1244 let with_credential_included = hash_routing_key(
1245 "nuget-chain",
1246 ["https://feed.test/", "corp", "hunter2"].into_iter(),
1247 );
1248 assert_ne!(identity_only, with_credential_included);
1249 }
1250}