Skip to main content

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}