Skip to main content

deps_core/
registry.rs

1use crate::error::{DepsError, Result};
2use crate::parser::DependencySource;
3use crate::{ConcreteVersion, EcosystemId, PackageName, VersionReq};
4use dashmap::DashMap;
5use dashmap::mapref::entry::Entry;
6use std::any::Any;
7use std::hash::Hash;
8use std::pin::Pin;
9
10type BoxFuture<'a, T> = Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
11
12/// Generic package registry interface.
13///
14/// Implementors provide access to a package registry (crates.io, npm, PyPI, etc.)
15/// with version lookup, search, and metadata retrieval capabilities.
16///
17/// All methods return `Result<T>` to allow graceful error handling.
18/// LSP handlers must never panic on registry errors.
19///
20/// # Type Erasure
21///
22/// This trait uses `Box<dyn Trait>` return types instead of associated types
23/// to allow runtime polymorphism and dynamic ecosystem registration.
24///
25/// # Registry client API contract (#834)
26///
27/// Each ecosystem crate's own registry struct (`CratesIoRegistry`, `NpmRegistry`,
28/// `MavenCentralRegistry`, ...) additionally exposes a set of **inherent** async methods
29/// with concrete (non-boxed) parameter/return types — this trait's own methods box and
30/// type-erase, which every hover/completion/diagnostic call site needs, but a caller that
31/// already knows the concrete ecosystem (most ecosystem-crate-internal code, and every
32/// conformance test) wants the unboxed value directly. The trait method of the same name is
33/// expected to delegate to its inherent counterpart (see `deps-cargo::registry::CratesIoRegistry`
34/// for the canonical shape). Naming previously drifted per crate (issue #760, #834); the
35/// canonical vocabulary going forward is:
36///
37/// - `get_versions` — fetch all versions.
38/// - `get_versions_with` — fetch all versions plus extra data (e.g. publish dates).
39/// - `get_latest_matching` — fetch the single version matching a requirement.
40/// - `search` — fetch search results for a query.
41/// - `register_alternate` — register an alternate/private registry source.
42/// - `package_url` — build the registry's web-display URL for a package (re-exported from
43///   the crate root wherever it exists).
44/// - `with_base` — construct a registry client pointed at a non-default base URL (tests,
45///   alternate-registry hops); a plain, ungated `with_base` for production use, distinct
46///   from any additional `#[cfg(test)]`/`test-util`-gated constructor a crate also needs
47///   (see `deps-npm::registry::NpmRegistry::with_public_base_for_test` for that shape).
48/// - Pure in-memory getters (no I/O) drop the `get_` prefix entirely, per the Rust API
49///   Guidelines' C-GETTER (e.g. `ResolvedPackages::version`/`all`,
50///   `EcosystemRegistry::for_filename`/`for_uri`/`for_lockfile`/`for_watched_config`).
51///
52/// Two sanctioned, named exceptions to this vocabulary (a deliberate #834 scope decision,
53/// not drift left to fix): ecosystem-specific display-URL builder names (`gem_url` in
54/// `deps-bundler`, `crate_url` in `deps-cargo`, `jsr_package_url` in `deps-deno`) stay as-is
55/// — only re-export consistency was in scope, not the name itself; and the metadata-fetch
56/// **return type** stays per-ecosystem (`GemInfo`/`PackageInfo`/`ArtifactInfo`/`CrateInfo`/
57/// `DenoMetadata`/`GoMetadata`, ...) — only the *method* name converges on
58/// `get_package_metadata`.
59///
60/// [`crate::registry_conformance!`]'s `ty:` form asserts the first four names exist on a
61/// given registry type at compile time (and, since #834 critic S5, actually asserts they
62/// are *inherent* — see [`crate::conformance::NotInherent`]'s own doc), so a future
63/// ecosystem crate cannot silently reintroduce this drift. Wired directly into 9 of the 14
64/// ecosystem crates as of #834; `deps-gradle` reuses `deps-maven`'s `MavenCentralRegistry`
65/// unchanged and so is covered transitively through `deps-maven`'s own check. Four crates
66/// are exempt, each for a distinct, documented reason rather than a silent gap:
67/// - `deps-gitlab-ci` — route-based, no name+version registry concept at all; every one of
68///   the four trait methods is an inline stub in the `Registry` impl itself, with no real
69///   per-package logic anywhere to extract.
70/// - `deps-deno` — `DenoRegistry`, the type that implements `Registry`, is a
71///   scheme-dispatching facade with no single struct holding all four methods (it dispatches
72///   by `jsr:`/`npm:` prefix to either its `JsrRegistry` field or an `NpmRegistry`, with
73///   different call shapes on each side). Note this is a statement about `DenoRegistry`
74///   specifically, not about the crate as a whole: `JsrRegistry` itself already has genuine
75///   inherent `get_versions`/`search`, just not `get_versions_with`/`get_latest_matching`,
76///   and its `get_versions(&self, scope: &str, name: &str)` is a fifth, two-part-key
77///   argument shape for the canonical `get_versions` name (JSR packages are scoped,
78///   `@scope/name`) that this contract's table does not otherwise cover.
79/// - `deps-go`, `deps-github-actions` — both have genuine inherent `get_versions`/
80///   `get_versions_with`/`get_latest_matching`; only `search` is missing, because neither
81///   registry (the Go module proxy, the GitHub tags API) exposes a search endpoint this
82///   crate can call at all. An inherent `search` here would be a fabricated always-empty
83///   stub whose only purpose is satisfying this check, not a real, callable operation — the
84///   `Registry` trait impl's own `search` (also always empty, for the same reason) is the
85///   honest place for that.
86///
87/// # Examples
88///
89/// ```no_run
90/// use deps_core::{Registry, Version, Metadata, PackageName, ConcreteVersion};
91/// use std::any::Any;
92/// use std::pin::Pin;
93///
94/// struct MyRegistry;
95///
96/// #[derive(Clone)]
97/// struct MyVersion { version: ConcreteVersion }
98///
99/// impl Version for MyVersion {
100///     fn version_string(&self) -> &ConcreteVersion { &self.version }
101///     fn as_any(&self) -> &dyn Any { self }
102/// }
103///
104/// #[derive(Clone)]
105/// struct MyMetadata { name: PackageName, latest: ConcreteVersion }
106///
107/// impl Metadata for MyMetadata {
108///     fn name(&self) -> &PackageName { &self.name }
109///     fn description(&self) -> Option<&str> { None }
110///     fn repository(&self) -> Option<&str> { None }
111///     fn documentation(&self) -> Option<&str> { None }
112///     fn latest_version(&self) -> &ConcreteVersion { &self.latest }
113///     fn as_any(&self) -> &dyn Any { self }
114/// }
115///
116/// impl Registry for MyRegistry {
117///     fn get_versions<'a>(&'a self, _name: &'a PackageName)
118///         -> Pin<Box<dyn std::future::Future<Output = deps_core::error::Result<Vec<Box<dyn Version>>>> + Send + 'a>>
119///     {
120///         Box::pin(async move { Ok(vec![Box::new(MyVersion { version: "1.0.0".into() }) as Box<dyn Version>]) })
121///     }
122///
123///     fn get_latest_matching<'a>(&'a self, _name: &'a PackageName, _req: &'a deps_core::VersionReq, _selection_context: &'a deps_core::SelectionContext)
124///         -> Pin<Box<dyn std::future::Future<Output = deps_core::error::Result<Option<Box<dyn Version>>>> + Send + 'a>>
125///     {
126///         Box::pin(async move { Ok(None) })
127///     }
128///
129///     fn search_raw<'a>(&'a self, _query: &'a str, _limit: usize)
130///         -> Pin<Box<dyn std::future::Future<Output = deps_core::error::Result<Vec<Box<dyn Metadata>>>> + Send + 'a>>
131///     {
132///         Box::pin(async move { Ok(vec![]) })
133///     }
134///
135///     fn as_any(&self) -> &dyn Any { self }
136/// }
137/// ```
138pub trait Registry: Send + Sync {
139    /// Fetches all available versions for a package.
140    ///
141    /// Returns versions sorted newest-first. May include yanked/deprecated versions.
142    ///
143    /// # Errors
144    ///
145    /// Returns error if:
146    /// - Package does not exist
147    /// - Network request fails
148    /// - Response parsing fails
149    fn get_versions<'a>(
150        &'a self,
151        name: &'a PackageName,
152    ) -> BoxFuture<'a, Result<Vec<Box<dyn Version>>>>;
153
154    /// Like [`get_versions`](Self::get_versions), but lets a registry that can obtain
155    /// [`Version::published_at`] only through an *extra* request gate that request behind
156    /// `freshness.is_enabled()` instead of always paying for it.
157    ///
158    /// Implementors overriding this method MUST keep every other aspect of
159    /// [`get_versions`](Self::get_versions)'s behavior — set, order, and content of the
160    /// returned versions — identical; the only difference the override may introduce is
161    /// populating [`Version::published_at`]. Callers that render publish ages MUST call this
162    /// method rather than [`get_versions`](Self::get_versions): the default implementation
163    /// below simply forwards to `get_versions` and ignores `freshness`, so a caller that
164    /// keeps calling `get_versions` silently gets no freshness signal even from a registry
165    /// that implements this override.
166    ///
167    /// Default: forwards to [`get_versions`](Self::get_versions), ignoring `freshness`. This
168    /// keeps the ten registries with no extra publish-time source unchanged, and keeps
169    /// [`FreshnessSettings`](crate::freshness::FreshnessSettings) — a `Copy + 'static` DTO —
170    /// out of every `Registry::new` signature and the `register!` ecosystem-registration
171    /// macro.
172    fn get_versions_with<'a>(
173        &'a self,
174        name: &'a PackageName,
175        freshness: crate::freshness::FreshnessSettings,
176    ) -> BoxFuture<'a, Result<Vec<Box<dyn Version>>>> {
177        let _ = freshness;
178        self.get_versions(name)
179    }
180
181    /// Like [`get_versions_with`](Self::get_versions_with), but additionally carries the
182    /// dependency's resolved [`DependencySource`], for a registry that routes a single
183    /// fetch across more than one underlying index (e.g. `deps-cargo`'s `CargoRegistry`,
184    /// which dispatches a `DependencySource::AlternateRegistry` to a private sparse index
185    /// instead of crates.io).
186    ///
187    /// Default: forwards to [`get_versions_with`](Self::get_versions_with), ignoring
188    /// `source` entirely. This keeps every registry with no per-dependency routing concept
189    /// — every ecosystem except Cargo today — bit-identical: `source` is accepted and
190    /// dropped, so a caller migrating to this method from `get_versions_with` changes no
191    /// observable behavior for them.
192    ///
193    /// Callers that know which source a dependency resolved to should call this rather
194    /// than [`get_versions_with`](Self::get_versions_with), even against a registry with no
195    /// override — the whole point is that call sites don't need to know which registries
196    /// route on source and which don't.
197    fn get_versions_from<'a>(
198        &'a self,
199        name: &'a PackageName,
200        source: &'a DependencySource,
201        freshness: crate::freshness::FreshnessSettings,
202    ) -> BoxFuture<'a, Result<Vec<Box<dyn Version>>>> {
203        let _ = source;
204        self.get_versions_with(name, freshness)
205    }
206
207    /// Finds the latest version matching a version requirement.
208    ///
209    /// Filter with [`RemovalStatus::blocks_resolution`], never with
210    /// [`RemovalStatus::is_flagged`]. An `AdvisoryDeprecated` version is fully
211    /// installable — excluding it turns an existing package into a false
212    /// "Unknown package" (#347). Under a wildcard/empty requirement
213    /// (`"*"`/`""`, see [`is_existence_wildcard`]) this is an *existence* check ("does this
214    /// package exist / what is its newest version for display purposes"), not an upgrade
215    /// recommendation: an implementation may prefer a non-yanked version but fall back to a
216    /// yanked one rather than returning `None` when no non-yanked version exists — a yanked
217    /// package still exists. `deps-npm` implements this fallback (mirrored by
218    /// [`select_latest_matching`](Self::select_latest_matching)'s wildcard branch on the
219    /// same registry, which every caller reaching this trait method through the shared
220    /// fetch loop actually goes through first); the exception never applies to a concrete
221    /// requirement.
222    ///
223    /// `selection_context` is the caller's [`crate::SelectionContext`] — opaque and
224    /// ecosystem-owned (see that type's own doc), mirroring [`get_versions_with`](Self::get_versions_with)'s
225    /// [`FreshnessSettings`](crate::freshness::FreshnessSettings) precedent for "an optional
226    /// extra parameter most registries ignore"; only the one registry that understands its
227    /// contents (`PackagistRegistry`, via [`crate::SelectionContext::minimum_stability`])
228    /// overrides this method to read it — every other registry ignores it. A caller with no
229    /// ecosystem-specific manifest state to thread passes [`crate::SelectionContext::none()`]
230    /// explicitly (#1444: this parameter is required, not defaulted, so skipping it is always
231    /// a visible, reviewable choice at the call site rather than an easy-to-forget separate
232    /// method name).
233    ///
234    /// # Arguments
235    ///
236    /// * `name` - Package name
237    /// * `req` - Version requirement string (e.g., "^1.0", ">=2.0")
238    /// * `selection_context` - Manifest-scoped selection state; [`crate::SelectionContext::none()`]
239    ///   when the caller has none
240    ///
241    /// # Returns
242    ///
243    /// - `Ok(Some(version))` - Latest matching version found
244    /// - `Ok(None)` - No matching version found
245    /// - `Err(_)` - Network or parsing error
246    fn get_latest_matching<'a>(
247        &'a self,
248        name: &'a PackageName,
249        req: &'a VersionReq,
250        selection_context: &'a crate::SelectionContext,
251    ) -> BoxFuture<'a, Result<Option<Box<dyn Version>>>>;
252
253    /// Like [`get_latest_matching`](Self::get_latest_matching), but additionally carries the
254    /// dependency's resolved [`DependencySource`] — the `get_latest_matching`-shaped
255    /// counterpart to [`get_versions_from`](Self::get_versions_from), covering the fallback
256    /// path a caller takes when the list-based pick fails on a non-empty
257    /// [`get_versions_from`](Self::get_versions_from) result (see
258    /// `deps_core::lsp_helpers::hover`'s `list_fallback_latest` and `deps-lsp`'s
259    /// background-fetch fallback for the two call sites this exists for).
260    ///
261    /// Default: forwards to [`get_latest_matching`](Self::get_latest_matching), ignoring
262    /// `source` — every registry with no per-dependency routing concept stays bit-identical,
263    /// exactly as [`get_versions_from`](Self::get_versions_from) does for the list-fetching
264    /// side.
265    fn get_latest_matching_from<'a>(
266        &'a self,
267        name: &'a PackageName,
268        source: &'a DependencySource,
269        req: &'a VersionReq,
270        selection_context: &'a crate::SelectionContext,
271    ) -> BoxFuture<'a, Result<Option<Box<dyn Version>>>> {
272        let _ = source;
273        self.get_latest_matching(name, req, selection_context)
274    }
275
276    /// Searches for packages by name or keywords.
277    ///
278    /// Returns up to `limit` results sorted by relevance/popularity.
279    ///
280    /// Named `search_raw`, not `search` (#1215): this required method carries no
281    /// credential-bearing-query gate of its own, and a `fn search` provided-method default
282    /// could be silently skipped by an override — an *inherent* `search` on `dyn Registry`
283    /// (below) wraps this method with [`crate::redact::is_credential_or_query_bearing`]
284    /// and cannot be silently overridden by an implementor, unlike a provided trait method
285    /// default — every `&dyn Registry`/`Arc<dyn Registry>` call site through the gate gets it
286    /// automatically. This is a dispatch-level guarantee, not a hard access barrier: `search_raw`
287    /// is still `pub`, so a caller holding a `&dyn Registry` can call it directly and skip the
288    /// gate with one extra token — call it directly only from a context that already knows
289    /// `query` is safe (e.g. a concrete registry's own inherent `search`, which independently
290    /// gates its own callers).
291    ///
292    /// # Errors
293    ///
294    /// Returns error if network request or parsing fails.
295    fn search_raw<'a>(
296        &'a self,
297        query: &'a str,
298        limit: usize,
299    ) -> BoxFuture<'a, Result<Vec<Box<dyn Metadata>>>>;
300
301    /// Whether [`search_raw`](Self::search_raw) matches `query` against a *normalized* form
302    /// of package names rather than the raw typed text.
303    ///
304    /// A `true` implementor guarantees its search is performed against normalized names (e.g.
305    /// PyPI's PEP 503 normalization, which folds `-`/`_`/`.` together), so a returned
306    /// [`Metadata::name`] is not guaranteed to be a literal subsequence of the raw `query` an
307    /// LSP client typed — the caller must not assume `filter_text` defaulting to `label` will
308    /// still match client-side. Callers use this to decide whether a completion response needs
309    /// its `filter_text` rewritten to the raw typed prefix (see
310    /// [`crate::completion::apply_raw_prefix_filter_text`]).
311    ///
312    /// Default `false` — the correct answer for every registry whose search already matches
313    /// literal substrings/prefixes of the raw query, which is every registry in this workspace
314    /// except `deps-pypi`'s `PypiRegistry` (see that crate's own override).
315    ///
316    /// An implementor returning `true` must be paired with its owning
317    /// `Ecosystem::package_search_is_incomplete` also returning `true`: the client re-issuing a
318    /// fresh search on the very next keystroke (and so discarding this response's rewritten
319    /// `filter_text` rather than continuing to filter it locally forever) is what makes the
320    /// rewrite in [`crate::completion::apply_raw_prefix_filter_text`] safe. This crate has no
321    /// compile-time way to enforce that pairing across the two traits — it is a contract on the
322    /// implementor, not a checked invariant.
323    fn search_normalizes_query(&self) -> bool {
324        false
325    }
326
327    /// Index of the latest version in `versions` satisfying `req`, with no I/O.
328    ///
329    /// Filter with [`RemovalStatus::blocks_resolution`], never with
330    /// [`RemovalStatus::is_flagged`]. An `AdvisoryDeprecated` version is fully
331    /// installable — excluding it turns an existing package into a false
332    /// "Unknown package" (#347). Under a wildcard/empty requirement (see
333    /// [`is_existence_wildcard`]) this is an *existence* check, not an upgrade
334    /// recommendation: Cargo, PyPI, Dart, npm, and Deno implement it by gating on
335    /// [`is_existence_wildcard`] and delegating to [`select_latest_for_existence`].
336    ///
337    /// `versions` must be a newest-first list as returned by this registry's
338    /// [`get_versions`](Self::get_versions). Returns an index rather than a reference so
339    /// callers holding an owned `Vec` can move the chosen element out
340    /// (`versions.into_iter().nth(i)`) — a borrow into the list would keep it frozen while
341    /// [`get_latest_matching`](Self::get_latest_matching) needs to return an owned
342    /// `Box<dyn Version>`, and `Version` has no `clone_box`.
343    ///
344    /// `selection_context` is the caller's [`crate::SelectionContext`] — see
345    /// [`get_latest_matching`](Self::get_latest_matching) for why this parameter is required
346    /// rather than defaulted; every registry with no ecosystem-specific manifest state to
347    /// refine selection with ignores it via this method's own default.
348    ///
349    /// Default: `None`. Every registry reachable from the LSP fetch path overrides this so
350    /// the fetch loop can obtain both "latest" and the full version list from one round
351    /// trip; the default exists so test doubles that never resolve a "latest" compile
352    /// unchanged.
353    fn select_latest_matching(
354        &self,
355        _versions: &[Box<dyn Version>],
356        _req: &VersionReq,
357        _selection_context: &crate::SelectionContext,
358    ) -> Option<usize> {
359        None
360    }
361
362    /// Whether [`get_versions`](Self::get_versions) results carry meaningful
363    /// per-version yank/deprecation data via [`Version::removal_status`].
364    ///
365    /// Default `true`: a registry is opted into the yanked-version diagnostic
366    /// unless it explicitly says it cannot answer. This fails toward
367    /// correctness — a registry whose `removal_status` later becomes real
368    /// data starts participating automatically, by deleting its opt-out
369    /// rather than by someone remembering to add an opt-in. Return `false`
370    /// only when `removal_status()` is hardcoded (e.g. always
371    /// `RemovalStatus::Available`) or otherwise cannot reflect real registry
372    /// data; a `true` return authorizes callers to trust `removal_status()`
373    /// on versions from this registry's normal
374    /// [`get_versions`](Self::get_versions)/
375    /// [`get_latest_matching`](Self::get_latest_matching) results — it does
376    /// not trigger any additional network request.
377    fn reports_yanked(&self) -> bool {
378        true
379    }
380
381    /// Downcast to concrete registry type for ecosystem-specific operations
382    fn as_any(&self) -> &dyn Any;
383}
384
385impl dyn Registry + '_ {
386    /// Rejects a credential- or query-bearing `query` before it ever reaches
387    /// [`Registry::search_raw`] (#1215), then delegates.
388    ///
389    /// An **inherent** method on the trait object, not a provided trait method: an inherent
390    /// method cannot be shadowed by a `Registry` implementor the way a provided trait method
391    /// can be silently overridden, so every `&dyn Registry`/`Arc<dyn Registry>` call site gets
392    /// this gate automatically, present and future — the same enforcement shape issue #1206
393    /// already established for [`crate::completion::reject_credential_bearing_value`],
394    /// generalized to the trait boundary itself rather than left to each call site to
395    /// remember. This is a dispatch-level guarantee, not a hard access barrier:
396    /// [`Registry::search_raw`] is still `pub`, so a caller holding a `&dyn Registry` can call
397    /// it directly and skip this gate with one extra token — see that method's own doc.
398    ///
399    /// Returns `Ok(vec![])` without calling [`Registry::search_raw`] at all when `query` is
400    /// flagged by [`crate::redact::is_credential_or_query_bearing`] — a redacted value is
401    /// not a useful search term, so the request is dropped rather than redacted-and-sent.
402    ///
403    /// # Errors
404    ///
405    /// Returns error if [`Registry::search_raw`]'s underlying network request or parsing
406    /// fails.
407    ///
408    /// # Examples
409    ///
410    /// ```
411    /// use deps_core::Registry;
412    ///
413    /// struct MyRegistry;
414    /// # use deps_core::{Metadata, PackageName, Version};
415    /// # use std::any::Any;
416    /// # use std::pin::Pin;
417    /// impl Registry for MyRegistry {
418    /// #   fn get_versions<'a>(&'a self, _name: &'a PackageName)
419    /// #       -> Pin<Box<dyn std::future::Future<Output = deps_core::error::Result<Vec<Box<dyn Version>>>> + Send + 'a>>
420    /// #   { Box::pin(async move { Ok(vec![]) }) }
421    /// #   fn get_latest_matching<'a>(&'a self, _name: &'a PackageName, _req: &'a deps_core::VersionReq, _selection_context: &'a deps_core::SelectionContext)
422    /// #       -> Pin<Box<dyn std::future::Future<Output = deps_core::error::Result<Option<Box<dyn Version>>>> + Send + 'a>>
423    /// #   { Box::pin(async move { Ok(None) }) }
424    ///     fn search_raw<'a>(&'a self, _query: &'a str, _limit: usize)
425    ///         -> Pin<Box<dyn std::future::Future<Output = deps_core::error::Result<Vec<Box<dyn Metadata>>>> + Send + 'a>>
426    ///     {
427    ///         panic!("must never be reached for a credential-bearing query");
428    ///     }
429    /// #   fn as_any(&self) -> &dyn Any { self }
430    /// }
431    ///
432    /// # #[tokio::main]
433    /// # async fn main() {
434    /// let registry: &dyn Registry = &MyRegistry;
435    /// let results = registry
436    ///     .search("user:hunter2@registry.example", 10)
437    ///     .await
438    ///     .unwrap();
439    /// assert!(results.is_empty());
440    /// # }
441    /// ```
442    pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<Box<dyn Metadata>>> {
443        if crate::redact::is_credential_or_query_bearing(query) {
444            crate::lsp_helpers::warn_rejected_value("credential_bearing", "registry search", query);
445            return Ok(vec![]);
446        }
447        self.search_raw(query, limit).await
448    }
449}
450
451/// Whether `version` contains one of the common pre-release substrings
452/// (`-alpha`, `-beta`, `-rc`, `-dev`, `-pre`, `-snapshot`, `-canary`,
453/// `-nightly`), case-insensitively.
454///
455/// This is [`Version::is_prerelease`]'s default heuristic, extracted so an
456/// ecosystem that overrides `is_prerelease` to cover format-specific gaps
457/// (e.g. Composer's `dev-` branch-alias prefix) can extend this baseline
458/// instead of copy-pasting the substring list out of sync with it.
459///
460/// # Examples
461///
462/// ```
463/// use deps_core::has_default_prerelease_marker;
464///
465/// assert!(has_default_prerelease_marker("1.0.0-beta"));
466/// assert!(has_default_prerelease_marker("1.0.0-RC1"));
467/// assert!(!has_default_prerelease_marker("1.0.0"));
468/// ```
469#[must_use]
470pub fn has_default_prerelease_marker(version: &str) -> bool {
471    let v = version.to_lowercase();
472    v.contains("-alpha")
473        || v.contains("-beta")
474        || v.contains("-rc")
475        || v.contains("-dev")
476        || v.contains("-pre")
477        || v.contains("-snapshot")
478        || v.contains("-canary")
479        || v.contains("-nightly")
480}
481
482/// Maps a `404` (plus any status in `also_not_found`) [`DepsError::HttpStatus`] to
483/// [`DepsError::PackageNotFound`], passing through any other error unchanged.
484///
485/// Extracted from four byte-identical copies of this mapper in `deps-pypi`, `deps-deno`,
486/// `deps-npm`, and `deps-go` (deps-lsp#929 item 1). `also_not_found` keeps ecosystem-specific
487/// extra statuses explicit at the call site instead of hardcoded here — `deps-go` passes
488/// `&[410]` for the Go module proxy's "retracted" status, which the other three ecosystems
489/// must not silently inherit.
490///
491/// # Examples
492///
493/// ```
494/// use deps_core::DepsError;
495/// use deps_core::redact::RedactedUrl;
496/// use deps_core::not_found_or;
497///
498/// let url = RedactedUrl::new("https://example.test/pkg");
499/// let not_found = DepsError::HttpStatus {
500///     url: url.clone(),
501///     status: 404,
502/// };
503/// assert!(matches!(
504///     not_found_or(not_found, "pkg", "example", &[]),
505///     DepsError::PackageNotFound { .. }
506/// ));
507///
508/// let server_error = DepsError::HttpStatus { url, status: 500 };
509/// assert!(matches!(
510///     not_found_or(server_error, "pkg", "example", &[]),
511///     DepsError::HttpStatus { status: 500, .. }
512/// ));
513/// ```
514#[must_use]
515pub fn not_found_or(
516    err: DepsError,
517    package: &str,
518    registry: &'static str,
519    also_not_found: &[u16],
520) -> DepsError {
521    match err {
522        DepsError::HttpStatus { status, .. }
523            if status == 404 || also_not_found.contains(&status) =>
524        {
525            DepsError::PackageNotFound {
526                package: package.into(),
527                registry,
528            }
529        }
530        other => other,
531    }
532}
533
534/// Classifies a manifest-declared registry URL against an ecosystem's own default-registry
535/// aliases.
536///
537/// A URL matching one of `default_urls` (ignoring a trailing slash) resolves to
538/// [`DependencySource::Registry`]; anything else has no client this LSP can query, so it
539/// becomes [`DependencySource::CustomRegistry`].
540///
541/// Extracted from byte-identical logic independently added to `deps-bundler` (rubygems.org)
542/// and `deps-dart` (pub.dev, plus its legacy `pub.dartlang.org` alias) in the same change
543/// (deps-lsp#980) — this project's cross-ecosystem-consistency rule treats that pattern as a
544/// bug class rather than an acceptable duplication (see `.claude/CLAUDE.md`, and the #976/#969
545/// precedent of centralizing similarly-shaped per-crate logic into `deps-core`).
546///
547/// # Examples
548///
549/// ```
550/// use deps_core::DependencySource;
551/// use deps_core::classify_default_registry_url;
552///
553/// assert_eq!(
554///     classify_default_registry_url("https://rubygems.org".to_string(), &["https://rubygems.org"]),
555///     DependencySource::Registry
556/// );
557///
558/// // A trailing slash is ignored when comparing against the default list.
559/// assert_eq!(
560///     classify_default_registry_url("https://pub.dev/".to_string(), &["https://pub.dev", "https://pub.dartlang.org"]),
561///     DependencySource::Registry
562/// );
563///
564/// assert_eq!(
565///     classify_default_registry_url("https://gems.mycorp.com".to_string(), &["https://rubygems.org"]),
566///     DependencySource::CustomRegistry { url: "https://gems.mycorp.com".to_string() }
567/// );
568/// ```
569#[must_use]
570pub fn classify_default_registry_url(url: String, default_urls: &[&str]) -> DependencySource {
571    let normalized = url.trim_end_matches('/');
572    if default_urls.contains(&normalized) {
573        DependencySource::Registry
574    } else {
575        DependencySource::CustomRegistry { url }
576    }
577}
578
579/// Hashes an ordered sequence of `&str` routing-hop parts into an opaque
580/// `"{prefix}:{digest:016x}"` chain-identity key, using
581/// [`std::collections::hash_map::DefaultHasher`].
582///
583/// Extracted from three independently hand-rolled copies of this exact pattern
584/// (`deps_pypi::config::ResolvedChain::chain`, `deps_nuget::config::NuGetSourceChain::chain`,
585/// `deps_go::config::GoProxyChain::keyed`, #579) so "never hash a credential" is a property of
586/// one function signature instead of a convention repeated in three doc comments.
587///
588/// Deliberately takes `&str`, not `impl Hash`: a caller with a typed credential wrapper (e.g.
589/// `NuGetAuth`, which deliberately does not derive `Hash` — see its doc) must explicitly
590/// convert it to a string before it can even be considered here, rather than being able to
591/// `.hash()` the whole struct in place. Prefer a small, explicit, non-`Debug` `as_key_str()`-
592/// style accessor on the hashed field's own type over `format!("{value:?}")` at the call
593/// site — chain identity would otherwise silently change if that type's `Debug` output is
594/// ever reworded (e.g. an enum variant rename), even though nothing routing-relevant changed.
595///
596/// **Security invariant**: `parts` must contain only routing-identity data (URLs, slot/hop
597/// kind markers, boolean flags) — **never** credential material. Hashing a credential would
598/// make the chain's identity key change whenever that credential value rotates, defeating
599/// rotation-stability guarantees callers rely on (e.g. NuGet issue #561's FR-016). This
600/// function has no way to enforce that; it is a caller obligation.
601///
602/// # Examples
603///
604/// ```
605/// use deps_core::hash_routing_key;
606///
607/// let key = hash_routing_key("pypi-chain", ["https://example.test/simple/", "true"].into_iter());
608/// assert!(key.starts_with("pypi-chain:"));
609/// assert_eq!(key.len(), "pypi-chain:".len() + 16);
610///
611/// // Same parts, same key — deterministic within a process.
612/// let key2 = hash_routing_key("pypi-chain", ["https://example.test/simple/", "true"].into_iter());
613/// assert_eq!(key, key2);
614///
615/// // Hop order is part of the identity.
616/// let forward = hash_routing_key("go-proxy", ["a", "b"].into_iter());
617/// let reversed = hash_routing_key("go-proxy", ["b", "a"].into_iter());
618/// assert_ne!(forward, reversed);
619/// ```
620#[must_use]
621pub fn hash_routing_key<'a>(prefix: &str, parts: impl Iterator<Item = &'a str>) -> String {
622    use std::hash::{Hash, Hasher};
623
624    let mut hasher = std::collections::hash_map::DefaultHasher::new();
625    for part in parts {
626        part.hash(&mut hasher);
627    }
628    format!("{prefix}:{:016x}", hasher.finish())
629}
630
631/// Outcome of a registry's per-version removal/deprecation signal.
632///
633/// Replaces a bare `is_yanked(): bool`, which could not distinguish a version
634/// a registry has *hard-removed from resolution* (`Yanked`) from one that is
635/// merely flagged as deprecated/abandoned but still fully installable
636/// (`AdvisoryDeprecated`). Conflating the two caused #347: Composer's
637/// package-level `abandoned` flag was read through `is_yanked()`, so every
638/// version of an abandoned-but-installable package was filtered out of
639/// resolution, turning an existing package into a false "Unknown package"
640/// diagnostic.
641///
642/// Call [`blocks_resolution`](Self::blocks_resolution) to decide whether a
643/// version may be selected as an upgrade/latest candidate. Call
644/// [`is_flagged`](Self::is_flagged) only to surface the registry's flag to
645/// the user (e.g. a yanked/deprecated diagnostic) — never to filter
646/// resolution.
647///
648/// # Examples
649///
650/// ```
651/// use deps_core::RemovalStatus;
652///
653/// assert!(!RemovalStatus::Available.blocks_resolution());
654/// assert!(!RemovalStatus::AdvisoryDeprecated.blocks_resolution());
655/// assert!(RemovalStatus::Yanked.blocks_resolution());
656///
657/// assert!(RemovalStatus::AdvisoryDeprecated.is_flagged());
658/// assert!(RemovalStatus::Yanked.is_flagged());
659/// assert!(!RemovalStatus::Available.is_flagged());
660/// ```
661#[non_exhaustive]
662#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
663pub enum RemovalStatus {
664    /// The registry reports no removal/deprecation signal for this version.
665    Available,
666    /// The registry flags this version (or its package) as
667    /// deprecated/abandoned, but the version remains fully resolvable and
668    /// installable.
669    AdvisoryDeprecated,
670    /// The registry has hard-removed this version from fresh resolution (a
671    /// real yank/retraction). Existing installs may keep using it, but it
672    /// must not be selected as an upgrade/latest candidate.
673    Yanked,
674}
675
676impl RemovalStatus {
677    /// Whether fresh resolution must skip this version.
678    ///
679    /// `true` only for [`Yanked`](Self::Yanked). An
680    /// [`AdvisoryDeprecated`](Self::AdvisoryDeprecated) version is fully
681    /// installable — excluding it from resolution is what caused #347.
682    ///
683    /// # Examples
684    ///
685    /// ```
686    /// use deps_core::RemovalStatus;
687    ///
688    /// assert!(!RemovalStatus::AdvisoryDeprecated.blocks_resolution());
689    /// assert!(RemovalStatus::Yanked.blocks_resolution());
690    /// ```
691    #[must_use]
692    pub const fn blocks_resolution(self) -> bool {
693        matches!(self, Self::Yanked)
694    }
695
696    /// Whether the registry flags this version at all, hard or advisory.
697    ///
698    /// Use this only to decide whether to surface a warning to the user —
699    /// never to filter resolution; use
700    /// [`blocks_resolution`](Self::blocks_resolution) for that.
701    ///
702    /// # Examples
703    ///
704    /// ```
705    /// use deps_core::RemovalStatus;
706    ///
707    /// assert!(!RemovalStatus::Available.is_flagged());
708    /// assert!(RemovalStatus::AdvisoryDeprecated.is_flagged());
709    /// assert!(RemovalStatus::Yanked.is_flagged());
710    /// ```
711    #[must_use]
712    pub const fn is_flagged(self) -> bool {
713        !matches!(self, Self::Available)
714    }
715
716    /// Builds a status from a registry's hard yanked/retracted boolean flag.
717    ///
718    /// # Examples
719    ///
720    /// ```
721    /// use deps_core::RemovalStatus;
722    ///
723    /// assert_eq!(RemovalStatus::from_yanked(true), RemovalStatus::Yanked);
724    /// assert_eq!(RemovalStatus::from_yanked(false), RemovalStatus::Available);
725    /// ```
726    #[must_use]
727    pub const fn from_yanked(flag: bool) -> Self {
728        if flag { Self::Yanked } else { Self::Available }
729    }
730
731    /// Builds a status from a registry's advisory deprecated/abandoned boolean flag.
732    ///
733    /// # Examples
734    ///
735    /// ```
736    /// use deps_core::RemovalStatus;
737    ///
738    /// assert_eq!(RemovalStatus::from_advisory(true), RemovalStatus::AdvisoryDeprecated);
739    /// assert_eq!(RemovalStatus::from_advisory(false), RemovalStatus::Available);
740    /// ```
741    #[must_use]
742    pub const fn from_advisory(flag: bool) -> Self {
743        if flag {
744            Self::AdvisoryDeprecated
745        } else {
746            Self::Available
747        }
748    }
749}
750
751/// Package-level deprecation/abandonment payload: why a package is deprecated, and what
752/// to use instead.
753///
754/// Distinct from [`RemovalStatus`], which answers *"may resolution select this
755/// version"*. `Deprecation` answers *"what should the user be told"* — the reason text
756/// and/or a registry-supplied replacement package name. Kept as a separate type rather
757/// than a payload-carrying `RemovalStatus` variant: `RemovalStatus` is `Copy`, returned
758/// from `const fn`s, and matched in every ecosystem crate, so widening it would ripple
759/// everywhere for a concept that only a couple of registries expose.
760///
761/// Both fields are `None` when the registry has nothing to say beyond "deprecated" —
762/// e.g. Packagist's bare `"abandoned": true`, with no replacement named.
763///
764/// # Examples
765///
766/// ```
767/// use deps_core::Deprecation;
768///
769/// let deprecation = Deprecation {
770///     reason: Some("no longer maintained".into()),
771///     replacement: Some("some-other/package".into()),
772/// };
773///
774/// assert_eq!(deprecation.reason.as_deref(), Some("no longer maintained"));
775/// assert_eq!(deprecation.replacement.as_deref(), Some("some-other/package"));
776/// ```
777// Exhaustive, not `#[non_exhaustive]`: 25+ existing struct-literal sites across `deps-npm`,
778// `deps-composer`, and `deps-lsp` already build this by field, not worth migrating for a
779// two-field type (issue #755).
780#[derive(Debug, Clone, PartialEq, Eq)]
781pub struct Deprecation {
782    /// Free-text reason the registry gives for the deprecation, if any.
783    pub reason: Option<String>,
784    /// Registry-supplied replacement package name, if any. Only ever populated from a
785    /// structured registry field (never regex-extracted from free text — see #205's
786    /// typosquatting rationale), so it is safe to offer as a rename target.
787    pub replacement: Option<String>,
788}
789
790/// Version information trait.
791///
792/// All version types must implement this to work with generic handlers.
793pub trait Version: Send + Sync {
794    /// Version string (e.g., "1.0.214", "14.21.3").
795    fn version_string(&self) -> &ConcreteVersion;
796
797    /// This version's removal/deprecation status as reported by the registry.
798    ///
799    /// Default: [`RemovalStatus::Available`] — ecosystems with no
800    /// yank/deprecation signal need no override.
801    fn removal_status(&self) -> RemovalStatus {
802        RemovalStatus::Available
803    }
804
805    /// Package-level deprecation payload, when this version's registry data carries one.
806    ///
807    /// Default `None` — the `Option` is the capability gate: an ecosystem that never
808    /// overrides this produces no deprecation diagnostic/hover/quickfix at all, rather
809    /// than depending on a separate "does this ecosystem report deprecation" predicate.
810    fn deprecation(&self) -> Option<&Deprecation> {
811        None
812    }
813
814    /// Whether this version is a pre-release (alpha, beta, rc, etc.).
815    ///
816    /// Default implementation checks for common pre-release patterns via
817    /// [`has_default_prerelease_marker`].
818    fn is_prerelease(&self) -> bool {
819        has_default_prerelease_marker(self.version_string().as_str())
820    }
821
822    /// Available feature flags (empty if not supported by ecosystem).
823    fn features(&self) -> Vec<String> {
824        vec![]
825    }
826
827    /// Downcast to concrete version type
828    fn as_any(&self) -> &dyn Any;
829
830    /// Whether this version is stable: not hard-yanked and not a pre-release.
831    ///
832    /// An [`AdvisoryDeprecated`](RemovalStatus::AdvisoryDeprecated) version counts as
833    /// stable here — it remains fully resolvable, just flagged. Only
834    /// [`Yanked`](RemovalStatus::Yanked) disqualifies a version; see
835    /// [`RemovalStatus::blocks_resolution`].
836    fn is_stable(&self) -> bool {
837        !self.removal_status().blocks_resolution() && !self.is_prerelease()
838    }
839
840    /// When this version was published, if the registry exposes it.
841    ///
842    /// Default `None` — ecosystems without publish metadata (or where
843    /// fetching it would add a network round trip) degrade to pre-feature
844    /// behavior: no freshness signal shown, no error, no change in ranking.
845    fn published_at(&self) -> Option<crate::freshness::PublishTime> {
846        None
847    }
848
849    /// SPDX license identifier(s) declared for this version, when the registry's
850    /// already-fetched version-list response carries them.
851    ///
852    /// Default: empty slice — the capability gate for ecosystems whose hot-path
853    /// version fetch doesn't carry license data (issue #204). `Vec<String>`, never
854    /// `Option<String>`, everywhere this is implemented: a package can declare more
855    /// than one license (an SPDX `OR`/dual-license split reported as discrete
856    /// identifiers by the registry), and a single shape avoids an `Option` vs `Vec`
857    /// split across ecosystems (spec 010 plan.md's "License shape" decision). An
858    /// empty slice means "unknown/not reported", never "explicitly no license".
859    fn license(&self) -> &[String] {
860        &[]
861    }
862}
863
864/// Finds the latest stable version from a list of versions.
865///
866/// Returns the first version that is:
867/// - Not hard-yanked ([`RemovalStatus::Yanked`]) — an
868///   [`AdvisoryDeprecated`](RemovalStatus::AdvisoryDeprecated) version still counts as
869///   stable, since it remains fully resolvable
870/// - Not a pre-release (alpha, beta, rc, etc.)
871///
872/// Assumes versions are sorted newest-first (as returned by registries).
873///
874/// # Examples
875///
876/// ```
877/// use deps_core::registry::{RemovalStatus, Version, find_latest_stable};
878/// use deps_core::ConcreteVersion;
879/// use std::any::Any;
880///
881/// struct MyVersion { version: ConcreteVersion, yanked: bool }
882///
883/// impl Version for MyVersion {
884///     fn version_string(&self) -> &ConcreteVersion { &self.version }
885///     fn removal_status(&self) -> RemovalStatus { RemovalStatus::from_yanked(self.yanked) }
886///     fn as_any(&self) -> &dyn Any { self }
887/// }
888///
889/// let versions: Vec<Box<dyn Version>> = vec![
890///     Box::new(MyVersion { version: "2.0.0-alpha.1".into(), yanked: false }),
891///     Box::new(MyVersion { version: "1.5.0".into(), yanked: true }),
892///     Box::new(MyVersion { version: "1.4.0".into(), yanked: false }),
893/// ];
894///
895/// let latest = find_latest_stable(&versions);
896/// assert_eq!(latest.map(|v| v.version_string().as_str()), Some("1.4.0"));
897/// ```
898pub fn find_latest_stable(versions: &[Box<dyn Version>]) -> Option<&dyn Version> {
899    versions.iter().find(|v| v.is_stable()).map(|v| v.as_ref())
900}
901
902/// Whether `req` is a wildcard/empty requirement (`""` or `"*"`, ignoring surrounding
903/// whitespace) rather than a concrete version constraint.
904///
905/// A wildcard requirement turns [`get_latest_matching`](Registry::get_latest_matching) and
906/// [`select_latest_matching`](Registry::select_latest_matching) into an *existence* check
907/// ("does this package exist / what is its newest version for display purposes") instead of
908/// an upgrade recommendation — see [`select_latest_for_existence`], which callers must gate
909/// on this function before use.
910///
911/// # Examples
912///
913/// ```
914/// use deps_core::{VersionReq, is_existence_wildcard};
915///
916/// assert!(is_existence_wildcard(&VersionReq::new("*")));
917/// assert!(is_existence_wildcard(&VersionReq::new("")));
918/// assert!(!is_existence_wildcard(&VersionReq::new("^1.2")));
919/// ```
920#[must_use]
921pub fn is_existence_wildcard(req: &crate::VersionReq) -> bool {
922    is_existence_wildcard_str(req.as_str())
923}
924
925/// [`is_existence_wildcard`] for a raw `&str`, without allocating a [`VersionReq`] wrapper.
926///
927/// Exists for callers that hold a version requirement as `&str` rather than a `VersionReq`
928/// (e.g. an inherent method taking `req_str: &str` for its own parsing needs) — going through
929/// [`is_existence_wildcard`] there would allocate a `String` via `VersionReq::new` on every
930/// call just to check it.
931///
932/// # Examples
933///
934/// ```
935/// use deps_core::registry::is_existence_wildcard_str;
936///
937/// assert!(is_existence_wildcard_str("*"));
938/// assert!(is_existence_wildcard_str(""));
939/// assert!(!is_existence_wildcard_str("^1.2"));
940/// ```
941#[must_use]
942pub fn is_existence_wildcard_str(req: &str) -> bool {
943    matches!(req.trim(), "" | "*")
944}
945
946/// Builds the canonical existence-check requirement (`"*"`) [`is_existence_wildcard`] recognizes.
947///
948/// [`Registry::select_latest_matching`] callers pass this when they want "the newest version
949/// for display purposes" rather than an upgrade recommendation against a real constraint.
950/// Centralizes what was three independent `VersionReq::new("*")` call sites (hover, version
951/// completion, code-action quick-fixes) all constructing the identical value for the
952/// identical purpose.
953///
954/// # Examples
955///
956/// ```
957/// use deps_core::{existence_wildcard_req, is_existence_wildcard};
958///
959/// assert!(is_existence_wildcard(&existence_wildcard_req()));
960/// ```
961#[must_use]
962pub fn existence_wildcard_req() -> crate::VersionReq {
963    crate::VersionReq::new("*")
964}
965
966/// Index of the version an *existence* check should report as "latest", ignoring `req`
967/// entirely.
968///
969/// This is the shared 3-rung fallback ladder used under a wildcard/empty requirement, where
970/// every version satisfies by definition and the question is only which one to prefer for
971/// display:
972///
973/// 1. The newest version that is neither flagged
974///    ([`RemovalStatus::is_flagged`]) nor a pre-release ([`Version::is_prerelease`]).
975/// 2. Else, the newest version that does not block resolution
976///    ([`RemovalStatus::blocks_resolution`]) — an `AdvisoryDeprecated` version counts here.
977/// 3. Else, index `0` unconditionally — the newest version overall, however it is flagged.
978///    A yanked-or-prerelease-only package still exists; this rung is what turns that case
979///    into "here is its newest version" instead of a false "Unknown package" (#347, #364).
980///
981/// `versions` must be sorted newest-first, as returned by [`Registry::get_versions`]. Returns
982/// `None` only when `versions` is empty.
983///
984/// # Requirement-blindness is deliberate and dangerous
985///
986/// This function takes no `req` parameter and does not check whether the caller is under a
987/// wildcard requirement — it always returns rung 3 as a last resort, regardless of what a
988/// concrete requirement might demand. It is **only correct once the caller has already
989/// confirmed the requirement is a wildcard** via [`is_existence_wildcard`]. Calling it
990/// ungated — e.g. under a concrete `^1.2` requirement — can return a version that does not
991/// satisfy that requirement at all, silently corrupting upgrade resolution.
992///
993/// # Examples
994///
995/// ```
996/// use deps_core::registry::{RemovalStatus, Version, select_latest_for_existence};
997/// use deps_core::ConcreteVersion;
998/// use std::any::Any;
999///
1000/// struct MyVersion { version: ConcreteVersion, status: RemovalStatus, prerelease: bool }
1001///
1002/// impl Version for MyVersion {
1003///     fn version_string(&self) -> &ConcreteVersion { &self.version }
1004///     fn removal_status(&self) -> RemovalStatus { self.status }
1005///     fn is_prerelease(&self) -> bool { self.prerelease }
1006///     fn as_any(&self) -> &dyn Any { self }
1007/// }
1008///
1009/// // Newest version is yanked; rung 3 still returns it rather than `None`.
1010/// let versions = vec![
1011///     MyVersion { version: "2.0.0".into(), status: RemovalStatus::Yanked, prerelease: false },
1012///     MyVersion { version: "1.5.0".into(), status: RemovalStatus::Yanked, prerelease: false },
1013/// ];
1014///
1015/// let idx = select_latest_for_existence(&versions, |v| v as &dyn Version);
1016/// assert_eq!(idx, Some(0));
1017///
1018/// assert_eq!(select_latest_for_existence::<MyVersion>(&[], |v| v as &dyn Version), None);
1019/// ```
1020#[must_use]
1021pub fn select_latest_for_existence<T>(
1022    versions: &[T],
1023    as_version: impl Fn(&T) -> &dyn Version,
1024) -> Option<usize> {
1025    if versions.is_empty() {
1026        return None;
1027    }
1028    Some(
1029        versions
1030            .iter()
1031            .position(|v| {
1032                let v = as_version(v);
1033                !v.removal_status().is_flagged() && !v.is_prerelease()
1034            })
1035            .or_else(|| {
1036                versions
1037                    .iter()
1038                    .position(|v| !as_version(v).removal_status().blocks_resolution())
1039            })
1040            .unwrap_or(0),
1041    )
1042}
1043
1044/// Package metadata trait.
1045///
1046/// Used for completion items and hover documentation.
1047pub trait Metadata: Send + Sync {
1048    /// The package name as the *registry* reports it.
1049    ///
1050    /// This is the identifier the registry displays and that completion
1051    /// pastes into a manifest. It is not guaranteed byte-identical to the
1052    /// manifest-declared [`Dependency::name`](crate::ecosystem::Dependency::name)
1053    /// for the same package: casing may differ (NuGet, Composer, Swift), and
1054    /// for Maven/Gradle it is a `"group:artifact"` value synthesized from two
1055    /// separate response fields. Do not compare it to a manifest name
1056    /// without going through
1057    /// [`PackageNaming::normalize_package_name`](crate::lsp_helpers::PackageNaming::normalize_package_name).
1058    fn name(&self) -> &crate::PackageName;
1059
1060    /// Short description (optional).
1061    fn description(&self) -> Option<&str>;
1062
1063    /// Repository URL (optional).
1064    fn repository(&self) -> Option<&str>;
1065
1066    /// Documentation URL (optional).
1067    fn documentation(&self) -> Option<&str>;
1068
1069    /// Latest stable version.
1070    fn latest_version(&self) -> &ConcreteVersion;
1071
1072    /// Downcast to concrete metadata type
1073    fn as_any(&self) -> &dyn Any;
1074}
1075
1076/// Shared capacity ceiling for a per-ecosystem alternate-registry client map.
1077///
1078/// Reached via each ecosystem's own `register_alternate` (`Cargo`, `npm`, `PyPI`, `NuGet`,
1079/// `Go`, #976). Once reached, a not-yet-registered key is refused rather than evicting an
1080/// existing, possibly still-in-use, client.
1081pub const MAX_ALTERNATE_REGISTRIES: usize = 256;
1082
1083/// How a [`register_capped`]/[`register_capped_with_occupied`] key must be rendered in the
1084/// cap-reached log line (#969 S3).
1085///
1086/// A URL-shaped key is redacted via [`crate::redact::RedactedUrl`]; an already-opaque
1087/// token (e.g. a [`hash_routing_key`] digest) is logged verbatim, since `RedactedUrl`'s
1088/// Maven-coordinate/REST-path false-positive scan would otherwise collapse every such key —
1089/// which is exactly one colon plus a hex digest, the shape that scan is designed to catch —
1090/// to an identical, information-free `"<prefix>:***"`, destroying the very correlation handle
1091/// the log line exists to provide. A closed, two-variant choice rather than an open `Display`
1092/// bypass: a call site declares which shape its key is, but cannot smuggle an unredacted URL
1093/// through under the `Opaque` label by accident in the type system's own vocabulary — though
1094/// nothing stops a caller from mislabeling one at the value level, so get this right: labeling
1095/// a URL-shaped key `Opaque` leaks any query-string credential it carries (the dangerous
1096/// direction); labeling an opaque token `Url` only over-redacts a value that had no secret to
1097/// begin with (the safe direction).
1098#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1099pub enum KeyShape {
1100    /// Redact via [`crate::redact::RedactedUrl`] before logging.
1101    Url,
1102    /// Log verbatim — this value is known to never carry a credential (e.g. a
1103    /// [`hash_routing_key`] digest, or another fixed, non-URL literal).
1104    Opaque,
1105}
1106
1107/// Outcome of a [`register_capped`]/[`register_capped_with_occupied`] call.
1108///
1109/// `#[non_exhaustive]`: this is a result-shaped enum (introduced in #1218), and a distinct
1110/// outcome for a new capacity-related edge case is a realistic future addition — e.g.
1111/// distinguishing an eviction from a refusal, should this map ever gain eviction. No
1112/// consumer outside this workspace depends on it yet, making now the cheapest point to
1113/// mark it non-exhaustive; doing so later, once external crates match on it, would itself
1114/// be a breaking change (part of #1226).
1115#[non_exhaustive]
1116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1117pub enum CapResult {
1118    /// `make()` ran and its value was inserted (the `Vacant`, under-capacity arm).
1119    Inserted,
1120    /// `key` was already registered; the existing value stuck (`on_occupied` ran, if any).
1121    AlreadyPresent,
1122    /// `key` was absent but the map was at [`MAX_ALTERNATE_REGISTRIES`]; nothing was
1123    /// inserted and a cap-reached `warn!` was emitted.
1124    RefusedAtCapacity,
1125}
1126
1127/// Registers `key` in a capacity-bounded alternate-client map, refusing (never evicting)
1128/// once full.
1129///
1130/// A no-op when `key` is already registered — the first successful registration for a given
1131/// key sticks. A no-op, with a `tracing::warn!`, once [`MAX_ALTERNATE_REGISTRIES`] is reached
1132/// and `key` is not already present.
1133///
1134/// Reads `map.len()` **before** calling `entry()`: `DashMap::len` read-locks every shard,
1135/// while `entry()` holds a write guard on one — checking capacity from inside the `Vacant`
1136/// arm would self-deadlock on that shard.
1137///
1138/// `ecosystem` names the caller in the cap-reached log line.
1139/// `key_shape` picks how `key` is rendered in that same line — see [`KeyShape`].
1140///
1141/// Returns [`CapResult::Inserted`] when `make()` was inserted, [`CapResult::AlreadyPresent`]
1142/// when `key` was already occupied, or [`CapResult::RefusedAtCapacity`] when the map was at
1143/// capacity.
1144///
1145/// # Examples
1146///
1147/// ```
1148/// use dashmap::DashMap;
1149/// use deps_core::EcosystemId;
1150/// use deps_core::registry::{CapResult, KeyShape, register_capped};
1151///
1152/// let map: DashMap<String, u32> = DashMap::new();
1153/// assert_eq!(
1154///     register_capped(&map, "a".to_string(), EcosystemId::Cargo, KeyShape::Opaque, || 1),
1155///     CapResult::Inserted
1156/// );
1157/// assert_eq!(
1158///     register_capped(&map, "a".to_string(), EcosystemId::Cargo, KeyShape::Opaque, || 2),
1159///     CapResult::AlreadyPresent
1160/// );
1161/// assert_eq!(*map.get("a").unwrap(), 1);
1162/// ```
1163pub fn register_capped<K, V>(
1164    map: &DashMap<K, V>,
1165    key: K,
1166    ecosystem: EcosystemId,
1167    key_shape: KeyShape,
1168    make: impl FnOnce() -> V,
1169) -> CapResult
1170where
1171    K: Eq + Hash + AsRef<str>,
1172{
1173    register_capped_with_occupied(map, key, ecosystem, key_shape, make, |_| {})
1174}
1175
1176/// [`register_capped`], plus an `on_occupied` callback for an already-registered key.
1177///
1178/// Lets an ecosystem still act on that key — folding a re-registration's trust tier
1179/// (`deps-cargo`) or rebuilding a client whose credential digest changed (`deps-nuget`).
1180///
1181/// `on_occupied` runs instead of `make`/the capacity check, never gated by
1182/// [`MAX_ALTERNATE_REGISTRIES`] — replacing an already-occupied slot does not grow the map,
1183/// so gating it would strand a legitimate in-place update (e.g. a rotated credential) once
1184/// the cap is hit for unrelated keys.
1185///
1186/// Returns [`CapResult::Inserted`] when `make()` was inserted (the `Vacant` arm),
1187/// [`CapResult::AlreadyPresent`] or [`CapResult::RefusedAtCapacity`] otherwise —
1188/// `on_occupied` signals its own outcome (e.g. via `tracing::warn!`) rather than through this
1189/// return value.
1190///
1191/// # Examples
1192///
1193/// ```
1194/// use dashmap::DashMap;
1195/// use deps_core::EcosystemId;
1196/// use deps_core::registry::{CapResult, KeyShape, register_capped_with_occupied};
1197///
1198/// let map: DashMap<String, u32> = DashMap::new();
1199/// register_capped_with_occupied(
1200///     &map,
1201///     "a".to_string(),
1202///     EcosystemId::Cargo,
1203///     KeyShape::Opaque,
1204///     || 1,
1205///     |_| {},
1206/// );
1207/// let outcome = register_capped_with_occupied(
1208///     &map,
1209///     "a".to_string(),
1210///     EcosystemId::Cargo,
1211///     KeyShape::Opaque,
1212///     || 1,
1213///     |v| *v += 10,
1214/// );
1215/// assert_eq!(outcome, CapResult::AlreadyPresent);
1216/// assert_eq!(*map.get("a").unwrap(), 11);
1217/// ```
1218pub fn register_capped_with_occupied<K, V>(
1219    map: &DashMap<K, V>,
1220    key: K,
1221    ecosystem: EcosystemId,
1222    key_shape: KeyShape,
1223    make: impl FnOnce() -> V,
1224    on_occupied: impl FnOnce(&mut V),
1225) -> CapResult
1226where
1227    K: Eq + Hash + AsRef<str>,
1228{
1229    let at_capacity = map.len() >= MAX_ALTERNATE_REGISTRIES;
1230
1231    match map.entry(key) {
1232        Entry::Occupied(mut slot) => {
1233            on_occupied(slot.get_mut());
1234            CapResult::AlreadyPresent
1235        }
1236        Entry::Vacant(slot) => {
1237            if at_capacity {
1238                // `slot.key()` reads the key back out of the entry rather than requiring a
1239                // borrow held across the `entry()` call above, so the (possibly allocating)
1240                // redaction work below stays entirely off the far more common
1241                // successful-registration path.
1242                let key_ref = slot.key().as_ref();
1243                let rendered_key = match key_shape {
1244                    KeyShape::Url => crate::redact::RedactedUrl::new(key_ref).into_inner(),
1245                    KeyShape::Opaque => key_ref.to_string(),
1246                };
1247                tracing::warn!(
1248                    key = %rendered_key,
1249                    cap = MAX_ALTERNATE_REGISTRIES,
1250                    %ecosystem,
1251                    "{ecosystem} alternate registry cap reached; not registering a new entry"
1252                );
1253                return CapResult::RefusedAtCapacity;
1254            }
1255            slot.insert(make());
1256            CapResult::Inserted
1257        }
1258    }
1259}
1260
1261#[cfg(test)]
1262mod tests {
1263    use super::*;
1264
1265    use crate::test_util::MockVersion;
1266
1267    /// A [`Registry`] whose `search_raw` records whether it was called, for
1268    /// [`dyn_registry_search_rejects_credential_bearing_query_before_calling_search_raw`]
1269    /// (#1215) — mirrors `conformance.rs`'s `AlwaysHasResultsRegistry::search_was_called`.
1270    struct ProbeRegistry {
1271        search_raw_called: std::sync::atomic::AtomicBool,
1272    }
1273
1274    impl ProbeRegistry {
1275        fn new() -> Self {
1276            Self {
1277                search_raw_called: std::sync::atomic::AtomicBool::new(false),
1278            }
1279        }
1280
1281        fn search_raw_was_called(&self) -> bool {
1282            self.search_raw_called
1283                .load(std::sync::atomic::Ordering::SeqCst)
1284        }
1285    }
1286
1287    impl Registry for ProbeRegistry {
1288        fn get_versions<'a>(
1289            &'a self,
1290            _name: &'a PackageName,
1291        ) -> BoxFuture<'a, Result<Vec<Box<dyn Version>>>> {
1292            Box::pin(async move { Ok(vec![]) })
1293        }
1294
1295        fn get_latest_matching<'a>(
1296            &'a self,
1297            _name: &'a PackageName,
1298            _req: &'a VersionReq,
1299            _selection_context: &'a crate::SelectionContext,
1300        ) -> BoxFuture<'a, Result<Option<Box<dyn Version>>>> {
1301            Box::pin(async move { Ok(None) })
1302        }
1303
1304        fn search_raw<'a>(
1305            &'a self,
1306            _query: &'a str,
1307            _limit: usize,
1308        ) -> BoxFuture<'a, Result<Vec<Box<dyn Metadata>>>> {
1309            self.search_raw_called
1310                .store(true, std::sync::atomic::Ordering::SeqCst);
1311            Box::pin(async move {
1312                let metadata = crate::test_util::MockMetadata::new("probe", "1.0.0");
1313                Ok(vec![Box::new(metadata) as Box<dyn Metadata>])
1314            })
1315        }
1316
1317        fn as_any(&self) -> &dyn Any {
1318            self
1319        }
1320    }
1321
1322    #[tokio::test]
1323    async fn dyn_registry_search_rejects_credential_bearing_query_before_calling_search_raw() {
1324        let probe = ProbeRegistry::new();
1325        let registry: &dyn Registry = &probe;
1326
1327        let results = registry
1328            .search("user:hunter2@registry.example", 10)
1329            .await
1330            .unwrap();
1331
1332        assert!(
1333            results.is_empty(),
1334            "a credential-shaped query must return no results"
1335        );
1336        assert!(
1337            !probe.search_raw_was_called(),
1338            "a credential-shaped query must be rejected before ever calling `search_raw` \
1339             (issue #1215) — redacting the value and searching anyway, then dropping the \
1340             result, would still satisfy the empty-result assertion above but fail this one"
1341        );
1342    }
1343
1344    #[tokio::test]
1345    async fn dyn_registry_search_delegates_to_search_raw_for_a_safe_query() {
1346        let probe = ProbeRegistry::new();
1347        let registry: &dyn Registry = &probe;
1348
1349        let results = registry.search("serde", 10).await.unwrap();
1350
1351        assert!(
1352            !results.is_empty(),
1353            "a safe query must reach `search_raw` and return its result"
1354        );
1355        assert!(probe.search_raw_was_called());
1356    }
1357
1358    #[test]
1359    fn test_version_default_features() {
1360        let version = MockVersion::new("1.0.0").yanked(false);
1361
1362        assert_eq!(version.features(), Vec::<String>::new());
1363    }
1364
1365    #[test]
1366    fn test_version_trait_object() {
1367        let version = MockVersion::new("1.2.3").yanked(false);
1368
1369        let boxed: Box<dyn Version> = Box::new(version);
1370        assert_eq!(boxed.version_string().as_str(), "1.2.3");
1371        assert!(!boxed.removal_status().blocks_resolution());
1372    }
1373
1374    #[test]
1375    fn test_version_downcast() {
1376        let version = MockVersion::new("1.0.0").yanked(true);
1377
1378        let boxed: Box<dyn Version> = Box::new(version);
1379        let any = boxed.as_any();
1380
1381        assert!(any.is::<MockVersion>());
1382    }
1383
1384    struct MockMetadata {
1385        name: crate::PackageName,
1386        latest: ConcreteVersion,
1387    }
1388
1389    impl Metadata for MockMetadata {
1390        fn name(&self) -> &crate::PackageName {
1391            &self.name
1392        }
1393
1394        fn description(&self) -> Option<&str> {
1395            None
1396        }
1397
1398        fn repository(&self) -> Option<&str> {
1399            None
1400        }
1401
1402        fn documentation(&self) -> Option<&str> {
1403            None
1404        }
1405
1406        fn latest_version(&self) -> &ConcreteVersion {
1407            &self.latest
1408        }
1409
1410        fn as_any(&self) -> &dyn Any {
1411            self
1412        }
1413    }
1414
1415    #[test]
1416    fn test_metadata_trait_object() {
1417        let metadata = MockMetadata {
1418            name: crate::PackageName::new("test-package"),
1419            latest: "2.0.0".into(),
1420        };
1421
1422        let boxed: Box<dyn Metadata> = Box::new(metadata);
1423        assert_eq!(boxed.name(), "test-package");
1424        assert_eq!(boxed.latest_version().as_str(), "2.0.0");
1425        assert!(boxed.description().is_none());
1426        assert!(boxed.repository().is_none());
1427        assert!(boxed.documentation().is_none());
1428    }
1429
1430    #[test]
1431    fn test_metadata_with_full_info() {
1432        struct FullMetadata {
1433            name: crate::PackageName,
1434            desc: String,
1435            repo: String,
1436            docs: String,
1437            latest: ConcreteVersion,
1438        }
1439
1440        impl Metadata for FullMetadata {
1441            fn name(&self) -> &crate::PackageName {
1442                &self.name
1443            }
1444            fn description(&self) -> Option<&str> {
1445                Some(&self.desc)
1446            }
1447            fn repository(&self) -> Option<&str> {
1448                Some(&self.repo)
1449            }
1450            fn documentation(&self) -> Option<&str> {
1451                Some(&self.docs)
1452            }
1453            fn latest_version(&self) -> &ConcreteVersion {
1454                &self.latest
1455            }
1456            fn as_any(&self) -> &dyn Any {
1457                self
1458            }
1459        }
1460
1461        let meta = FullMetadata {
1462            name: crate::PackageName::new("serde"),
1463            desc: "Serialization framework".into(),
1464            repo: "https://github.com/serde-rs/serde".into(),
1465            docs: "https://docs.rs/serde".into(),
1466            latest: "1.0.214".into(),
1467        };
1468
1469        assert_eq!(meta.description(), Some("Serialization framework"));
1470        assert_eq!(meta.repository(), Some("https://github.com/serde-rs/serde"));
1471        assert_eq!(meta.documentation(), Some("https://docs.rs/serde"));
1472    }
1473
1474    #[test]
1475    fn test_is_prerelease_alpha() {
1476        let version = MockVersion::new("4.0.0-alpha.13").yanked(false);
1477        assert!(version.is_prerelease());
1478    }
1479
1480    #[test]
1481    fn test_is_prerelease_beta() {
1482        let version = MockVersion::new("2.0.0-beta.1").yanked(false);
1483        assert!(version.is_prerelease());
1484    }
1485
1486    #[test]
1487    fn test_is_prerelease_rc() {
1488        let version = MockVersion::new("1.5.0-rc.2").yanked(false);
1489        assert!(version.is_prerelease());
1490    }
1491
1492    #[test]
1493    fn test_is_prerelease_dev() {
1494        let version = MockVersion::new("3.0.0-dev").yanked(false);
1495        assert!(version.is_prerelease());
1496    }
1497
1498    #[test]
1499    fn test_is_prerelease_canary() {
1500        let version = MockVersion::new("5.0.0-canary").yanked(false);
1501        assert!(version.is_prerelease());
1502    }
1503
1504    #[test]
1505    fn test_is_prerelease_nightly() {
1506        let version = MockVersion::new("6.0.0-nightly").yanked(false);
1507        assert!(version.is_prerelease());
1508    }
1509
1510    #[test]
1511    fn test_is_not_prerelease_stable() {
1512        let version = MockVersion::new("1.2.3").yanked(false);
1513        assert!(!version.is_prerelease());
1514    }
1515
1516    #[test]
1517    fn test_is_not_prerelease_patch() {
1518        let version = MockVersion::new("1.0.214").yanked(false);
1519        assert!(!version.is_prerelease());
1520    }
1521
1522    #[test]
1523    fn test_is_stable_true() {
1524        let version = MockVersion::new("1.0.0").yanked(false);
1525        assert!(version.is_stable());
1526    }
1527
1528    #[test]
1529    fn test_is_stable_false_yanked() {
1530        let version = MockVersion::new("1.0.0").yanked(true);
1531        assert!(!version.is_stable());
1532    }
1533
1534    #[test]
1535    fn test_is_stable_false_prerelease() {
1536        let version = MockVersion::new("1.0.0-alpha.1").yanked(false);
1537        assert!(!version.is_stable());
1538    }
1539
1540    #[test]
1541    fn test_find_latest_stable_skips_prerelease() {
1542        let versions: Vec<Box<dyn Version>> = vec![
1543            Box::new(MockVersion::new("2.0.0-alpha.1").yanked(false)),
1544            Box::new(MockVersion::new("1.5.0").yanked(false)),
1545        ];
1546        let latest = super::find_latest_stable(&versions);
1547        assert_eq!(latest.map(|v| v.version_string().as_str()), Some("1.5.0"));
1548    }
1549
1550    #[test]
1551    fn test_find_latest_stable_skips_yanked() {
1552        let versions: Vec<Box<dyn Version>> = vec![
1553            Box::new(MockVersion::new("2.0.0").yanked(true)),
1554            Box::new(MockVersion::new("1.5.0").yanked(false)),
1555        ];
1556        let latest = super::find_latest_stable(&versions);
1557        assert_eq!(latest.map(|v| v.version_string().as_str()), Some("1.5.0"));
1558    }
1559
1560    #[test]
1561    fn test_find_latest_stable_returns_first_stable() {
1562        let versions: Vec<Box<dyn Version>> = vec![
1563            Box::new(MockVersion::new("3.0.0-beta.1").yanked(false)),
1564            Box::new(MockVersion::new("2.0.0").yanked(true)),
1565            Box::new(MockVersion::new("1.5.0").yanked(false)),
1566            Box::new(MockVersion::new("1.4.0").yanked(false)),
1567        ];
1568        let latest = super::find_latest_stable(&versions);
1569        assert_eq!(latest.map(|v| v.version_string().as_str()), Some("1.5.0"));
1570    }
1571
1572    #[test]
1573    fn test_find_latest_stable_empty_list() {
1574        let versions: Vec<Box<dyn Version>> = vec![];
1575        let latest = super::find_latest_stable(&versions);
1576        assert!(latest.is_none());
1577    }
1578
1579    #[test]
1580    fn test_find_latest_stable_no_stable_versions() {
1581        let versions: Vec<Box<dyn Version>> = vec![
1582            Box::new(MockVersion::new("2.0.0-alpha.1").yanked(false)),
1583            Box::new(MockVersion::new("1.0.0").yanked(true)),
1584        ];
1585        let latest = super::find_latest_stable(&versions);
1586        assert!(latest.is_none());
1587    }
1588
1589    struct StatusVersion {
1590        version: ConcreteVersion,
1591        status: RemovalStatus,
1592    }
1593
1594    impl Version for StatusVersion {
1595        fn version_string(&self) -> &ConcreteVersion {
1596            &self.version
1597        }
1598
1599        fn removal_status(&self) -> RemovalStatus {
1600            self.status
1601        }
1602
1603        fn as_any(&self) -> &dyn Any {
1604            self
1605        }
1606    }
1607
1608    #[test]
1609    fn test_is_stable_true_for_advisory_deprecated() {
1610        let version = StatusVersion {
1611            version: "1.0.0".into(),
1612            status: RemovalStatus::AdvisoryDeprecated,
1613        };
1614        assert!(version.is_stable());
1615    }
1616
1617    #[test]
1618    fn test_is_stable_false_for_yanked() {
1619        let version = StatusVersion {
1620            version: "1.0.0".into(),
1621            status: RemovalStatus::Yanked,
1622        };
1623        assert!(!version.is_stable());
1624    }
1625
1626    #[test]
1627    fn test_removal_status_blocks_resolution() {
1628        assert!(!RemovalStatus::Available.blocks_resolution());
1629        assert!(!RemovalStatus::AdvisoryDeprecated.blocks_resolution());
1630        assert!(RemovalStatus::Yanked.blocks_resolution());
1631    }
1632
1633    #[test]
1634    fn test_removal_status_is_flagged() {
1635        assert!(!RemovalStatus::Available.is_flagged());
1636        assert!(RemovalStatus::AdvisoryDeprecated.is_flagged());
1637        assert!(RemovalStatus::Yanked.is_flagged());
1638    }
1639
1640    #[test]
1641    fn test_removal_status_from_yanked() {
1642        assert_eq!(RemovalStatus::from_yanked(true), RemovalStatus::Yanked);
1643        assert_eq!(RemovalStatus::from_yanked(false), RemovalStatus::Available);
1644    }
1645
1646    #[test]
1647    fn test_removal_status_from_advisory() {
1648        assert_eq!(
1649            RemovalStatus::from_advisory(true),
1650            RemovalStatus::AdvisoryDeprecated
1651        );
1652        assert_eq!(
1653            RemovalStatus::from_advisory(false),
1654            RemovalStatus::Available
1655        );
1656    }
1657
1658    #[test]
1659    fn test_hash_routing_key_format() {
1660        let key = hash_routing_key("pypi-chain", ["https://example.test/simple/"].into_iter());
1661        assert!(key.starts_with("pypi-chain:"));
1662        assert_eq!(key.len(), "pypi-chain:".len() + 16);
1663    }
1664
1665    #[test]
1666    fn test_hash_routing_key_deterministic() {
1667        let parts = || ["https://a.test/", "https://b.test/", "true"].into_iter();
1668        assert_eq!(
1669            hash_routing_key("go-proxy", parts()),
1670            hash_routing_key("go-proxy", parts())
1671        );
1672    }
1673
1674    #[test]
1675    fn test_hash_routing_key_distinguishes_prefix() {
1676        let parts = || ["same"].into_iter();
1677        assert_ne!(
1678            hash_routing_key("pypi-chain", parts()),
1679            hash_routing_key("nuget-chain", parts())
1680        );
1681    }
1682
1683    #[test]
1684    fn test_hash_routing_key_distinguishes_parts() {
1685        assert_ne!(
1686            hash_routing_key("chain", ["a", "b"].into_iter()),
1687            hash_routing_key("chain", ["a", "c"].into_iter())
1688        );
1689    }
1690
1691    /// Hop order is part of chain identity (spec 034's fallback order, PyPI's FR-005
1692    /// primary-before-extras order, NuGet's declaration order all rely on this) — the same
1693    /// set of parts in a different order must not collide.
1694    #[test]
1695    fn test_hash_routing_key_order_sensitive() {
1696        assert_ne!(
1697            hash_routing_key("chain", ["a", "b", "c"].into_iter()),
1698            hash_routing_key("chain", ["c", "b", "a"].into_iter())
1699        );
1700    }
1701
1702    /// Documents that credential exclusion is a caller obligation, not something
1703    /// `hash_routing_key` enforces: two calls that differ only in a credential-shaped extra
1704    /// part produce different keys, so a caller who mistakenly includes one breaks the
1705    /// rotation-stability invariant this function's doc warns about.
1706    #[test]
1707    fn test_hash_routing_key_only_hashes_what_callers_pass() {
1708        let identity_only =
1709            hash_routing_key("nuget-chain", ["https://feed.test/", "corp"].into_iter());
1710        let with_credential_included = hash_routing_key(
1711            "nuget-chain",
1712            ["https://feed.test/", "corp", "hunter2"].into_iter(),
1713        );
1714        assert_ne!(identity_only, with_credential_included);
1715    }
1716
1717    /// #969 S1: a `KeyShape::Url` key must be redacted before it reaches the cap-reached log
1718    /// line — the query-string credential must never appear, but the non-secret host must.
1719    #[cfg(feature = "test-util")]
1720    #[test]
1721    fn test_register_capped_url_shape_redacts_credential_on_cap_reached() {
1722        let map: DashMap<String, usize> = DashMap::new();
1723        for i in 0..MAX_ALTERNATE_REGISTRIES {
1724            assert_eq!(
1725                register_capped(
1726                    &map,
1727                    format!("https://index{i}.example"),
1728                    EcosystemId::Cargo,
1729                    KeyShape::Url,
1730                    || i,
1731                ),
1732                CapResult::Inserted
1733            );
1734        }
1735
1736        let log = crate::test_util::capture_tracing_output(|| {
1737            register_capped(
1738                &map,
1739                "https://overflow.example/?api_key=SUPERSECRET_TOKEN".to_string(),
1740                EcosystemId::Cargo,
1741                KeyShape::Url,
1742                || 0,
1743            );
1744        });
1745
1746        assert!(
1747            log.contains("cargo alternate registry cap reached"),
1748            "log: {log}"
1749        );
1750        assert!(!log.contains("SUPERSECRET_TOKEN"), "log: {log}");
1751        assert!(log.contains("overflow.example"), "log: {log}");
1752    }
1753
1754    /// #969 S3: a `KeyShape::Opaque` key (e.g. a [`hash_routing_key`] digest) must survive
1755    /// the cap-reached log line intact — `RedactedUrl`'s Maven-coordinate false-positive scan
1756    /// would otherwise collapse a `"<prefix>:<hex>"` shape to an information-free
1757    /// `"<prefix>:***"`, destroying the correlation handle to other diagnostics about the
1758    /// same chain.
1759    #[cfg(feature = "test-util")]
1760    #[test]
1761    fn test_register_capped_opaque_shape_is_not_redacted_on_cap_reached() {
1762        let map: DashMap<String, usize> = DashMap::new();
1763        for i in 0..MAX_ALTERNATE_REGISTRIES {
1764            let key = hash_routing_key("test-chain", std::iter::once(i.to_string().as_str()));
1765            assert_eq!(
1766                register_capped(&map, key, EcosystemId::Cargo, KeyShape::Opaque, || i),
1767                CapResult::Inserted
1768            );
1769        }
1770
1771        let overflow_key = hash_routing_key("test-chain", std::iter::once("overflow"));
1772        let log = crate::test_util::capture_tracing_output(|| {
1773            register_capped(
1774                &map,
1775                overflow_key.clone(),
1776                EcosystemId::Cargo,
1777                KeyShape::Opaque,
1778                || 0,
1779            );
1780        });
1781
1782        assert!(
1783            log.contains("cargo alternate registry cap reached"),
1784            "log: {log}"
1785        );
1786        assert!(
1787            log.contains(&overflow_key),
1788            "opaque key must not be redacted: {log}"
1789        );
1790        assert!(
1791            !log.contains("***"),
1792            "opaque key must not collapse to '***': {log}"
1793        );
1794    }
1795}