Skip to main content

deps_pypi/
registry.rs

1//! PyPI registry client.
2//!
3//! Provides access to the PyPI registry via:
4//! - Simple API (<https://pypi.org/simple/{package}/>), PEP 691 JSON variant,
5//!   for version lookups (`get_versions`) — smaller than the full JSON API
6//! - Package metadata API (<https://pypi.org/pypi/{package}/json>) for hover
7//!   metadata (`get_package_metadata`), which needs `summary`/`project_urls`
8//!   that the Simple API doesn't carry
9//!
10//! All HTTP requests are cached aggressively using ETag/Last-Modified headers.
11
12use crate::config::{PypiIndexUrl, ResolvedChain};
13use crate::types::{PypiPackage, PypiVersion};
14use dashmap::DashMap;
15use deps_core::parser::DependencySource;
16use deps_core::{
17    DepsError, FreshnessSettings, HttpCache, Result, lsp_helpers::warn_rejected_value,
18};
19use pep440_rs::{Version, VersionSpecifiers};
20use serde::Deserialize;
21use std::any::Any;
22use std::future::Future;
23use std::str::FromStr;
24use std::sync::Arc;
25
26const PYPI_BASE: &str = "https://pypi.org/pypi";
27
28/// Base URL for the PEP 691 Simple API, used by `get_versions`.
29const PYPI_SIMPLE_BASE: &str = "https://pypi.org/simple";
30
31/// `Accept` header requesting the PEP 691 Simple API JSON representation.
32/// Roughly a third smaller than the full JSON API (verified against
33/// `django`: 619,755 bytes full vs 411,376 bytes Simple API).
34///
35/// Shared with `crate::search`, which requests the same representation for the
36/// full project index.
37pub(crate) const SIMPLE_API_ACCEPT: &str = "application/vnd.pypi.simple.v1+json";
38
39/// Display name for PyPI used in not-found and API-response error messages.
40pub const REGISTRY: &str = "PyPI";
41
42/// Upper bound on [`PypiRegistry::alternates`]' entry count. Generous for any realistic
43/// project's private-index configuration count; exists only to keep this map, keyed by
44/// workspace-controlled chain identities, from growing unbounded for the process lifetime.
45/// Mirrors `deps-npm`'s identical `MAX_ALTERNATE_REGISTRIES`. Once at capacity, a *new* chain
46/// is simply never registered (see [`PypiRegistry::register_chain`]) — a dependency resolved
47/// to an unregistered chain degrades to [`DepsError::PackageNotFound`], never to a
48/// `pypi.org` lookup by name (spec FR-010).
49///
50/// Note (plan.md §1's risk note): this cap counts distinct *chain identities*
51/// ([`ResolvedChain::key`]), not distinct index URLs — a monorepo with many files declaring
52/// different `--extra-index-url` combinations against the same primary could exhaust it
53/// faster than a simpler one-registration-per-URL model.
54const MAX_ALTERNATE_REGISTRIES: usize = 256;
55
56/// Which transport a [`PypiRegistry`] instance fetches through (spec FR-008).
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58enum PypiRegistryTier {
59    /// `pypi.org` (or a test override) — `HttpCache::get_cached_with_headers`, today's path,
60    /// unchanged.
61    Public,
62    /// A `requirements.txt`/`pyproject.toml`-declared private index —
63    /// `HttpCache::get_cached_workspace_with_headers`, so every redirect hop is re-classified
64    /// against the live [`deps_core::net_policy::RegistryAccessPolicy`] (mirrors
65    /// `deps-npm`'s identical `WorkspaceDeclared` routing).
66    WorkspaceDeclared,
67}
68
69/// Base URL for package pages on pypi.org
70pub const PYPI_URL: &str = "https://pypi.org/project";
71
72/// Returns the URL for a package's page on pypi.org.
73///
74/// Package names are normalized and URL-encoded to prevent path traversal attacks.
75/// Returns an empty string if `name` normalizes to nothing (e.g. `"---"`),
76/// rather than a dead link with an empty path segment — matching the
77/// `PackageNotFound` short-circuit `get_versions`/`get_package_metadata`
78/// apply for the same case.
79pub fn package_url(name: &str) -> String {
80    let normalized = crate::name::normalize(name);
81    if normalized.is_empty() {
82        warn_rejected_value(
83            "pypi_normalized_name_empty",
84            "PyPI package display URL",
85            name,
86        );
87        return String::new();
88    }
89    format!("{}/{}", PYPI_URL, urlencoding::encode(&normalized))
90}
91
92/// Builds the Simple API request URL for `normalized`'s version listing against `base`.
93///
94/// `base` is `PYPI_SIMPLE_BASE` for the public registry, or a resolved
95/// [`PypiIndexUrl`]'s own `simple_base` for a private/alternate index — the C4 fix (see
96/// `crates/deps-pypi/src/registry.rs`'s `PypiRegistry::simple_base` doc): version fetches
97/// must be parameterized on the index actually configured, not hardcoded to `pypi.org`.
98/// `metadata_url` deliberately stays unparameterized — see `metadata_url`'s own doc.
99///
100/// The name segment is URL-encoded (matching `package_url`) since
101/// `name::normalize` only collapses `-`/`_`/`.` separators and leaves
102/// characters like `/`, `?`, `#` untouched.
103fn simple_api_url(base: &str, normalized: &str) -> String {
104    format!("{base}/{}/", urlencoding::encode(normalized))
105}
106
107/// Builds the JSON API request URL for `normalized`'s package metadata.
108///
109/// Always built from the module-level `PYPI_BASE` constant, never parameterized on a
110/// resolved private index's base — `PYPI_BASE`/`PYPI_SIMPLE_BASE` are different URL roots
111/// (`/pypi` vs `/simple`), so parameterizing this on a private index's `simple_base` would
112/// 404. This is also moot in practice: [`PypiRegistry::get_package_metadata`] is tier-guarded
113/// off for any `WorkspaceDeclared` client (T008), so this is only ever reached by the public
114/// root.
115fn metadata_url(normalized: &str) -> String {
116    format!("{PYPI_BASE}/{}/json", urlencoding::encode(normalized))
117}
118
119/// Converts a 404 response into `DepsError::PackageNotFound`, passing through
120/// any other error unchanged.
121fn not_found_or(err: DepsError, name: &str) -> DepsError {
122    if matches!(err, DepsError::HttpStatus { status: 404, .. }) {
123        DepsError::PackageNotFound {
124            package: name.to_string(),
125            registry: REGISTRY,
126        }
127    } else {
128        err
129    }
130}
131
132/// Builds a search-result stub for `name`, a normalized name matched from the
133/// package-name search index.
134///
135/// [`PypiRegistry::search`] serves unranked prefix matches from a local index that
136/// carries only names, not metadata, so every other field is left at its "unknown"
137/// value. This is safe for completion: `build_package_completion`
138/// (`deps_core::completion`) and `create_package_completion_item`
139/// (`deps-lsp`'s fallback path) both already guard `detail` on `latest_version` being
140/// non-empty, so an empty `latest_version` here renders as no detail line rather than
141/// a misleading `Latest: `.
142fn package_stub(name: &str) -> PypiPackage {
143    PypiPackage {
144        name: deps_core::PackageName::new(name),
145        summary: None,
146        project_urls: Vec::new(),
147        latest_version: deps_core::ConcreteVersion::new(""),
148    }
149}
150
151/// Client for interacting with the PyPI registry.
152///
153/// Uses the PyPI JSON API for package metadata.
154/// All requests are cached via the provided HttpCache.
155///
156/// # Examples
157///
158/// ```no_run
159/// # use deps_pypi::PypiRegistry;
160/// # use deps_core::HttpCache;
161/// # use std::sync::Arc;
162/// # #[tokio::main]
163/// # async fn main() {
164/// let cache = Arc::new(HttpCache::new());
165/// let registry = PypiRegistry::new(cache);
166///
167/// let versions = registry.get_versions("requests").await.unwrap();
168/// assert!(!versions.is_empty());
169/// # }
170/// ```
171#[derive(Clone)]
172pub struct PypiRegistry {
173    cache: Arc<HttpCache>,
174    /// Base URL for the package-name search index (see [`Self::search`]).
175    /// Injectable for tests, mirroring `NuGetRegistry::with_service_index_url`.
176    ///
177    /// **Unchanged meaning** — package-name *search*-index base only, consumed solely by
178    /// `search`/`warm_search_index`. Distinct from [`Self::simple_base`] below (the C4 fix):
179    /// do not reuse one for the other.
180    index_url: String,
181    /// Version-fetch base for **this client's own hop**, consumed only by
182    /// [`simple_api_url`] (PEP 503/691 Simple API). `PYPI_SIMPLE_BASE` for the public root;
183    /// a resolved [`PypiIndexUrl`]'s own URL for a private/alternate hop.
184    ///
185    /// New field (fixes C4, plan.md's second critic pass finding N3): an earlier draft
186    /// reused [`Self::index_url`] as if it were the version-fetch base — but that field is
187    /// `crate::search::SIMPLE_INDEX_URL`, consumed only by `search`/`warm_search_index`, so
188    /// an alternate client would have silently kept fetching `pypi.org` for every version
189    /// lookup. Deliberately does **not** parameterize `metadata_url` too — see that
190    /// function's own doc for why (different URL root, would 404).
191    simple_base: String,
192    /// Build-once, in-memory package-name search index (issue #419). See
193    /// `crate::search` for the full design. Never populated for a `WorkspaceDeclared`-tier
194    /// client — `search`/`warm_search_index` are tier-guarded off before ever reaching it
195    /// (T008).
196    index: Arc<crate::search::IndexCell>,
197    /// Which transport [`Self::get_versions`]/[`Self::search`] fetch through (spec FR-008).
198    tier: PypiRegistryTier,
199    /// Resolved chain-router clients, keyed by [`ResolvedChain::key`] (a primary/extras
200    /// chain) or by a named source's own URL (Poetry `source =`/uv `index =`). Only the root
201    /// (`Public`-tier) instance this crate constructs via [`Self::new`] ever registers into
202    /// this or is ever looked up by [`Self::alternate_client`] — a chain-hop leaf's own map
203    /// is always empty by construction (never populated by [`Self::with_base`]), the same
204    /// invariant `deps-npm`'s `NpmRegistry::alternates` documents. `Arc<DashMap<..>>` (not a
205    /// bare `DashMap`) since `PypiRegistry` is `Clone` — a bare field would silently fork the
206    /// map.
207    alternates: Arc<DashMap<String, Arc<Self>>>,
208    /// Resolved, already-constructed hop clients this instance falls through to when it
209    /// (hop 0) misses (spec FR-005, fixes C1). Empty for the `Public`-tier root and every
210    /// named-source/leaf client — populated only on the *head* client
211    /// [`Self::register_chain`] builds for a multi-hop chain. Never looked up by string key at
212    /// fetch time; `Self::get_versions_chained` walks this `Vec` positionally.
213    fallback_chain: Vec<Arc<Self>>,
214}
215
216impl PypiRegistry {
217    /// Creates a new PyPI registry client with the given HTTP cache.
218    pub fn new(cache: Arc<HttpCache>) -> Self {
219        Self::with_index_url(cache, crate::search::SIMPLE_INDEX_URL.to_string())
220    }
221
222    /// Creates a registry client whose search index is built from `index_url`
223    /// rather than the real [`crate::search::SIMPLE_INDEX_URL`]. `pub(crate)` so
224    /// tests elsewhere in the crate (`crate::ecosystem`) can point it at a mock
225    /// server; mirrors `NuGetRegistry::with_service_index_url`.
226    ///
227    /// Always `Public`-tier with an empty `fallback_chain`: this is the pre-existing entry
228    /// point every non-alternate-index test in the workspace already uses. A test exercising
229    /// the private-index chain path constructs its mock client via [`Self::with_base`]
230    /// instead, so it goes through the workspace-gated transport (FR-008) and exercises the
231    /// same production routing.
232    pub(crate) fn with_index_url(cache: Arc<HttpCache>, index_url: String) -> Self {
233        Self {
234            cache,
235            index_url,
236            simple_base: PYPI_SIMPLE_BASE.to_string(),
237            index: Arc::new(crate::search::IndexCell::new()),
238            tier: PypiRegistryTier::Public,
239            alternates: Arc::new(DashMap::new()),
240            fallback_chain: Vec::new(),
241        }
242    }
243
244    /// Test-only: constructs a `Public`-tier root client with `simple_base` pointed at a
245    /// mock server, so the implicit public fallback hop (spec FR-005(b)) can be exercised in
246    /// a behavioral test — request order/count via mockito — without ever contacting the real
247    /// `pypi.org`. Mirrors [`PypiIndexUrl`]'s identical `cfg(test)`/`test-util`-gated loopback
248    /// carve-out (validator finding #9).
249    ///
250    /// [`Self::register_chain`]'s implicit-public-fallback hop is built from `root`'s own
251    /// `simple_base` (not the hardcoded `PYPI_SIMPLE_BASE` constant), so registering a chain
252    /// against a root constructed this way makes that hop resolve to the mock server too —
253    /// the same code path production uses, just pointed elsewhere.
254    #[cfg(any(test, feature = "test-util"))]
255    #[must_use]
256    pub fn with_public_base_for_test(cache: Arc<HttpCache>, simple_base: String) -> Self {
257        Self {
258            cache,
259            index_url: crate::search::SIMPLE_INDEX_URL.to_string(),
260            simple_base,
261            index: Arc::new(crate::search::IndexCell::new()),
262            tier: PypiRegistryTier::Public,
263            alternates: Arc::new(DashMap::new()),
264            fallback_chain: Vec::new(),
265        }
266    }
267
268    /// Creates a [`PypiRegistry`] client for one resolved private-index hop — an ordinary
269    /// production constructor, `WorkspaceDeclared`-tier so it fetches through
270    /// `HttpCache::get_cached_workspace_with_headers` (FR-008's redirect-hop gating) instead
271    /// of the ungated transport.
272    ///
273    /// `fallback_chain` is empty for every call except the *head* client
274    /// [`Self::register_chain`] builds for a multi-hop chain — every other hop (a chain's own
275    /// leaf hops, or a single-hop named-source client) is a dead end with nothing further to
276    /// fall through to, matching plan.md §1's "leaf clients are never themselves looked up by
277    /// key, only walked positionally" design. Its own `alternates` map starts empty and is
278    /// never populated — only the root ever registers a chain (see `Self::alternates`'s
279    /// doc).
280    #[must_use]
281    pub fn with_base(
282        cache: Arc<HttpCache>,
283        simple_base: &PypiIndexUrl,
284        fallback_chain: Vec<Arc<Self>>,
285    ) -> Self {
286        Self {
287            cache,
288            index_url: crate::search::SIMPLE_INDEX_URL.to_string(),
289            simple_base: simple_base.as_str().to_string(),
290            index: Arc::new(crate::search::IndexCell::new()),
291            tier: PypiRegistryTier::WorkspaceDeclared,
292            alternates: Arc::new(DashMap::new()),
293            fallback_chain,
294        }
295    }
296
297    /// Builds the full hop tree for one [`ResolvedChain`] and inserts the head into
298    /// `root.alternates` under `chain.key`. Idempotent per key (a repeat registration for the
299    /// same key is a no-op), capacity-capped at `MAX_ALTERNATE_REGISTRIES`.
300    ///
301    /// Called only from `PypiEcosystem::parse_manifest` over
302    /// `PypiIndexConfig::resolved_chains()`, at parse time only. Takes `root: &Arc<Self>` as a
303    /// plain parameter rather than `&self` (fixes N1, second critic pass) — `self: &Arc<Self>`
304    /// receivers are unstable, and building the implicit-public final hop needs an owned
305    /// `Arc<Self>`; `PypiEcosystem` already holds `registry: Arc<PypiRegistry>` and passes it
306    /// here directly.
307    ///
308    /// The implicit-public final hop (when `chain.implicit_public_fallback` is set) is a
309    /// **freshly-constructed `Public`-tier client** ([`Self::new`], same URL/transport as the
310    /// root), never `Arc::clone(root)` — cloning the root would create a
311    /// root→alternates→head→fallback_chain→root reference cycle (N1's second half).
312    pub fn register_chain(root: &Arc<Self>, chain: &ResolvedChain) {
313        let Some((first_hop, rest_hops)) = chain.hops.split_first() else {
314            // Defensive: `PypiIndexConfig::resolved_chains` never produces an empty-hop
315            // chain (the zero-hop case resolves to plain `DependencySource::Registry`
316            // instead, with nothing to register).
317            return;
318        };
319
320        // Read before `entry()`: `DashMap::len` read-locks every shard, and `entry()` holds
321        // a write guard on one — checking capacity from inside the `Vacant` arm would
322        // self-deadlock on that shard (mirrors `deps-npm::NpmRegistry::register_alternate`).
323        let at_capacity = root.alternates.len() >= MAX_ALTERNATE_REGISTRIES;
324
325        if let dashmap::mapref::entry::Entry::Vacant(slot) =
326            root.alternates.entry(chain.key.clone())
327        {
328            if at_capacity {
329                tracing::warn!(
330                    key = %chain.key,
331                    cap = MAX_ALTERNATE_REGISTRIES,
332                    "PyPI alternate registry cap reached; not registering a new chain"
333                );
334                return;
335            }
336
337            let mut fallback_chain: Vec<Arc<Self>> = rest_hops
338                .iter()
339                .map(|hop| Arc::new(Self::with_base(Arc::clone(&root.cache), hop, Vec::new())))
340                .collect();
341            if chain.implicit_public_fallback {
342                // Built from `root`'s own `simple_base`/`index_url` rather than
343                // `Self::new(..)`'s hardcoded `PYPI_SIMPLE_BASE` (validator finding #9) — in
344                // production `root` is always constructed via `Self::new`, so this is the
345                // exact same URL either way; in a test built via
346                // `Self::with_public_base_for_test`, this hop follows the root to a mock
347                // server, making the implicit-fallback ordering behaviorally testable.
348                fallback_chain.push(Arc::new(Self {
349                    cache: Arc::clone(&root.cache),
350                    index_url: root.index_url.clone(),
351                    simple_base: root.simple_base.clone(),
352                    index: Arc::new(crate::search::IndexCell::new()),
353                    tier: PypiRegistryTier::Public,
354                    alternates: Arc::new(DashMap::new()),
355                    fallback_chain: Vec::new(),
356                }));
357            }
358
359            let head = Self::with_base(Arc::clone(&root.cache), first_hop, fallback_chain);
360            slot.insert(Arc::new(head));
361        }
362    }
363
364    /// Registers a single-hop named-source client (Poetry `source =`/uv `index =`, spec
365    /// FR-007/FR-013) under `index`'s own URL into `root.alternates`. Same
366    /// idempotency/capacity rules and `root: &Arc<Self>` parameter shape as
367    /// [`Self::register_chain`].
368    pub fn register_named_source(root: &Arc<Self>, index: &PypiIndexUrl) {
369        let key = index.as_str().to_string();
370        let at_capacity = root.alternates.len() >= MAX_ALTERNATE_REGISTRIES;
371
372        if let dashmap::mapref::entry::Entry::Vacant(slot) = root.alternates.entry(key.clone()) {
373            if at_capacity {
374                tracing::warn!(
375                    key = %key,
376                    cap = MAX_ALTERNATE_REGISTRIES,
377                    "PyPI alternate registry cap reached; not registering a new named source"
378                );
379                return;
380            }
381            slot.insert(Arc::new(Self::with_base(
382                Arc::clone(&root.cache),
383                index,
384                Vec::new(),
385            )));
386        }
387    }
388
389    /// The registered client for `index` (a [`ResolvedChain::key`] or a named source's own
390    /// URL), if any — read-only, performs no registration, no validation.
391    ///
392    /// Intentionally only ever meaningful on the **root** — a chain-hop leaf's own
393    /// `alternates` map is always empty by construction ([`Self::with_base`] never populates
394    /// it), so calling this on a non-root client always returns `None`, documenting the
395    /// invariant rather than a bug: `Self::get_versions_chained` never calls this on
396    /// `self`, only walks the already-resolved `Self::fallback_chain` positionally.
397    #[must_use]
398    pub fn alternate_client(&self, index: &str) -> Option<Arc<Self>> {
399        self.alternates.get(index).map(|entry| Arc::clone(&entry))
400    }
401
402    /// FR-005/NFR-006: tries `self` (hop 0) first, then each already-resolved
403    /// `Self::fallback_chain` entry in order — no further map lookup at any point (verifies
404    /// [`Self::register_chain`]'s C1 fix actually resolves a hop end to end).
405    ///
406    /// Implements the plan's three-way failure taxonomy: `Ok(versions)` with `versions`
407    /// non-empty is terminal success; `Err(PackageNotFound)` or `Ok(versions)` with `versions`
408    /// empty (some PEP 503 indexes answer `200` with an empty listing for an unknown project)
409    /// continues to the next hop; any other `Err` (5xx, timeout, network error) is terminal,
410    /// propagated immediately — this is the confirmed trade-off (N4, second critic pass):
411    /// applied to a case-(b) chain (no explicit primary, hop 0 is a declared extra), an
412    /// unreachable hop 0 halts resolution for every dependency in that file, public ones
413    /// included, rather than silently falling through to `pypi.org` (which would leak the
414    /// package's name to the public index precisely when the private index is merely
415    /// unreachable — the exact disclosure NFR-003(2) exists to prevent). This terminal case
416    /// returns [`DepsError::ChainResolutionHalted`] (M2 fix) rather than the underlying
417    /// transport error unchanged — deps-core's `RateLimited`-precedented mechanism for a safe,
418    /// fixed diagnostic hint (`DepsError::fetch_failure` -> `FetchFailure::Actionable`) that
419    /// reaches hover/diagnostics text, not just the `tracing::warn!` below (which still logs
420    /// the real underlying error for debugging) — this is NFR-003(3)'s required
421    /// distinguishable diagnostic.
422    async fn get_versions_chained(&self, name: &str) -> Result<Vec<PypiVersion>> {
423        let mut last_miss: Result<Vec<PypiVersion>> = Err(DepsError::PackageNotFound {
424            package: name.to_string(),
425            registry: REGISTRY,
426        });
427
428        for hop in std::iter::once(self).chain(self.fallback_chain.iter().map(Arc::as_ref)) {
429            match hop.get_versions(name).await {
430                Ok(versions) if !versions.is_empty() => return Ok(versions),
431                Ok(empty) => last_miss = Ok(empty),
432                Err(DepsError::PackageNotFound { .. }) => {
433                    last_miss = Err(DepsError::PackageNotFound {
434                        package: name.to_string(),
435                        registry: REGISTRY,
436                    });
437                }
438                Err(other) => {
439                    tracing::warn!(
440                        package = name,
441                        error = %other,
442                        "PyPI alternate-index chain resolution halted on a transport error \
443                         — not falling back to pypi.org or the next configured index"
444                    );
445                    // M2 fix: returns deps-core's pre-vetted-message `ChainResolutionHalted`
446                    // rather than propagating `other` unchanged, so the diagnostic/hover path
447                    // (via `DepsError::fetch_failure`) can safely surface a fixed, safe hint
448                    // instead of only this log line — see this method's own doc.
449                    return Err(DepsError::ChainResolutionHalted);
450                }
451            }
452        }
453
454        last_miss
455    }
456
457    /// Fetches all versions for a package from PyPI's Simple API (PEP 691).
458    ///
459    /// Requests the JSON representation (`Accept:
460    /// application/vnd.pypi.simple.v1+json`), which is smaller than the full
461    /// JSON API and provides the version list directly, without needing to
462    /// derive versions from release-file names.
463    ///
464    /// Returns versions sorted newest-first. Filters out yanked versions by default.
465    ///
466    /// # Errors
467    ///
468    /// Returns an error if:
469    /// - HTTP request fails
470    /// - Response body is invalid UTF-8
471    /// - JSON parsing fails
472    /// - Package does not exist
473    ///
474    /// # Examples
475    ///
476    /// ```no_run
477    /// # use deps_pypi::PypiRegistry;
478    /// # use deps_core::HttpCache;
479    /// # use std::sync::Arc;
480    /// # #[tokio::main]
481    /// # async fn main() {
482    /// let cache = Arc::new(HttpCache::new());
483    /// let registry = PypiRegistry::new(cache);
484    ///
485    /// let versions = registry.get_versions("flask").await.unwrap();
486    /// assert!(!versions.is_empty());
487    /// # }
488    /// ```
489    pub async fn get_versions(&self, name: &str) -> Result<Vec<PypiVersion>> {
490        let normalized = crate::name::normalize(name);
491        if normalized.is_empty() {
492            warn_rejected_value(
493                "pypi_normalized_name_empty",
494                "PyPI simple API request URL",
495                name,
496            );
497            return Err(DepsError::PackageNotFound {
498                package: name.to_string(),
499                registry: REGISTRY,
500            });
501        }
502        let url = simple_api_url(&self.simple_base, &normalized);
503        let headers = [(reqwest::header::ACCEPT, SIMPLE_API_ACCEPT)];
504        let data = match self.tier {
505            PypiRegistryTier::Public => self.cache.get_cached_with_headers(&url, &headers).await,
506            PypiRegistryTier::WorkspaceDeclared => {
507                self.cache
508                    .get_cached_workspace_with_headers(&url, &headers)
509                    .await
510            }
511        }
512        .map_err(|e| not_found_or(e, name))?;
513
514        parse_simple_api_response(name, &data)
515    }
516
517    /// Finds the latest version matching the given PEP 440 version specifier.
518    ///
519    /// Only returns non-yanked, non-prerelease versions by default.
520    ///
521    /// # Errors
522    ///
523    /// Returns an error if:
524    /// - HTTP request fails
525    /// - Package does not exist
526    /// - Version specifier is invalid
527    ///
528    /// # Examples
529    ///
530    /// ```no_run
531    /// # use deps_pypi::PypiRegistry;
532    /// # use deps_core::HttpCache;
533    /// # use std::sync::Arc;
534    /// # #[tokio::main]
535    /// # async fn main() {
536    /// let cache = Arc::new(HttpCache::new());
537    /// let registry = PypiRegistry::new(cache);
538    ///
539    /// let latest = registry.get_latest_matching("flask", ">=3.0,<4.0").await.unwrap();
540    /// assert!(latest.is_some());
541    /// # }
542    /// ```
543    pub async fn get_latest_matching(
544        &self,
545        name: &str,
546        req_str: &str,
547    ) -> Result<Option<PypiVersion>> {
548        let versions = self.get_versions(name).await?;
549
550        // PEP 440 uses empty string for "any version"
551        let normalized_req = if req_str == "*" { "" } else { req_str };
552
553        let specs = VersionSpecifiers::from_str(normalized_req)
554            .map_err(|e| DepsError::InvalidVersionReq(format!("{req_str}: {e}")))?;
555
556        Ok(versions.into_iter().find(|v| {
557            if let Ok(version) = Version::from_str(v.version.as_str()) {
558                specs.contains(&version) && !v.yanked && !v.is_prerelease()
559            } else {
560                false
561            }
562        }))
563    }
564
565    /// Searches for packages whose PEP 503 normalized name starts with `query`.
566    ///
567    /// PyPI removed its XML-RPC search API and offers no first-party ranked search,
568    /// so this serves unranked, alphabetically-sorted prefix matches against a
569    /// lazily-built, in-memory index of the full PyPI Simple API project list
570    /// (~882k names) — the same approach PyCharm's PyPI completion uses. See
571    /// `crate::search` for the index's build/backoff lifecycle.
572    ///
573    /// On a cold start (the index has not finished building yet), this returns an
574    /// empty result immediately rather than blocking on a ~9.6 MB download, and
575    /// triggers a background build. Once built, the index is never rebuilt for the
576    /// life of the process — there is no TTL (see `crate::search`'s module doc for
577    /// why). Because the result set can be a truncated view of a much larger match
578    /// set, callers should treat every result (empty or not) as incomplete;
579    /// `PypiEcosystem::generate_completions` does this by reporting
580    /// [`deps_core::completion::Completions::is_incomplete`] for the `PackageName`
581    /// completion context this method backs.
582    ///
583    /// # Errors
584    ///
585    /// Never returns `Err`: a failed background build is logged and degrades to an
586    /// empty result, matching this method's pre-existing observable behavior.
587    ///
588    /// # Examples
589    ///
590    /// ```no_run
591    /// # use deps_pypi::PypiRegistry;
592    /// # use deps_core::HttpCache;
593    /// # use std::sync::Arc;
594    /// # #[tokio::main]
595    /// # async fn main() {
596    /// let cache = Arc::new(HttpCache::new());
597    /// let registry = PypiRegistry::new(cache);
598    ///
599    /// // May be empty on a cold start; a later call (once the index has built)
600    /// // returns matches.
601    /// let _results = registry.search("flask", 10).await.unwrap();
602    /// # }
603    /// ```
604    pub fn search(
605        &self,
606        query: &str,
607        limit: usize,
608    ) -> impl Future<Output = Result<Vec<PypiPackage>>> + use<> {
609        let normalized = crate::name::normalize(query);
610        let cache = Arc::clone(&self.cache);
611        let index_url = self.index_url.clone();
612        let index = Arc::clone(&self.index);
613        let tier = self.tier;
614        async move {
615            // T008 (fixes S4/M3): a `WorkspaceDeclared`-tier client never performs a
616            // package-*name* search — an unguarded search would trigger a full
617            // project-listing download from the private host (the multi-MB Simple index,
618            // not a single-package fetch). Enforced here rather than relied on via the call
619            // graph, mirroring `deps-npm::NpmRegistry::search`'s identical guard.
620            if tier == PypiRegistryTier::WorkspaceDeclared {
621                return Ok(Vec::new());
622            }
623            if normalized.is_empty() {
624                return Ok(Vec::new());
625            }
626            if let Some(ready) = index.ready() {
627                return Ok(ready
628                    .prefix_matches(&normalized, limit)
629                    .into_iter()
630                    .map(package_stub)
631                    .collect());
632            }
633            crate::search::trigger_index_build(cache, index_url, index);
634            Ok(Vec::new())
635        }
636    }
637
638    /// Starts building the package-name search index in the background if it isn't
639    /// ready yet (or a prior failed attempt's backoff window has elapsed).
640    ///
641    /// Safe to call unconditionally and often — a cheap no-op once the index is
642    /// `crate::search::IndexState::Ready` or while a prior failure
643    /// is still within its backoff window. `deps-pypi`'s `PypiEcosystem` calls this
644    /// on every completion request in a Python manifest (not only package-name
645    /// completion), so the index is typically already built by the time the user
646    /// starts typing a package name.
647    pub fn warm_search_index(&self) {
648        // T008: mirrors `Self::search`'s tier guard — never triggers `trigger_index_build`'s
649        // full-listing download for a private index client.
650        if self.tier == PypiRegistryTier::WorkspaceDeclared {
651            return;
652        }
653        crate::search::trigger_index_build(
654            Arc::clone(&self.cache),
655            self.index_url.clone(),
656            Arc::clone(&self.index),
657        );
658    }
659
660    /// Fetches package metadata including description and project URLs.
661    ///
662    /// # Errors
663    ///
664    /// Returns an error if:
665    /// - HTTP request fails
666    /// - Package does not exist
667    /// - JSON parsing fails
668    /// - `self` is `WorkspaceDeclared`-tier (T008, fixes S4/M3) — this method is `pub`,
669    ///   ungated, and unrouted by any in-workspace caller today, but a future call site
670    ///   reaching it on a private-index client would otherwise send that client's package
671    ///   name to `pypi.org`'s JSON API (`metadata_url` is always built from the hardcoded
672    ///   `PYPI_BASE`, never parameterized — see `metadata_url`'s doc) — closed here before
673    ///   any such call site exists, not relied on via the call graph
674    pub async fn get_package_metadata(&self, name: &str) -> Result<PypiPackage> {
675        if self.tier == PypiRegistryTier::WorkspaceDeclared {
676            return Err(DepsError::PackageNotFound {
677                package: name.to_string(),
678                registry: REGISTRY,
679            });
680        }
681        let normalized = crate::name::normalize(name);
682        if normalized.is_empty() {
683            warn_rejected_value(
684                "pypi_normalized_name_empty",
685                "PyPI package metadata request URL",
686                name,
687            );
688            return Err(DepsError::PackageNotFound {
689                package: name.to_string(),
690                registry: REGISTRY,
691            });
692        }
693        let url = metadata_url(&normalized);
694        let data = self
695            .cache
696            .get_cached(&url)
697            .await
698            .map_err(|e| not_found_or(e, name))?;
699
700        parse_package_info(name, &data)
701    }
702}
703
704// Implement Registry trait for PypiRegistry
705impl deps_core::Registry for PypiRegistry {
706    fn get_versions<'a>(
707        &'a self,
708        name: &'a deps_core::PackageName,
709    ) -> deps_core::ecosystem::BoxFuture<
710        'a,
711        deps_core::error::Result<Vec<Box<dyn deps_core::Version>>>,
712    > {
713        Box::pin(async move {
714            let versions = Self::get_versions(self, name.as_str()).await?;
715            Ok(versions
716                .into_iter()
717                .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
718                .collect())
719        })
720    }
721
722    fn get_latest_matching<'a>(
723        &'a self,
724        name: &'a deps_core::PackageName,
725        req: &'a deps_core::VersionReq,
726    ) -> deps_core::ecosystem::BoxFuture<
727        'a,
728        deps_core::error::Result<Option<Box<dyn deps_core::Version>>>,
729    > {
730        Box::pin(async move {
731            let version = Self::get_latest_matching(self, name.as_str(), req.as_str()).await?;
732            Ok(version.map(|v| Box::new(v) as Box<dyn deps_core::Version>))
733        })
734    }
735
736    /// Dispatches by `source` (spec FR-010): an `AlternateRegistry` whose index has a
737    /// registered client routes through `Self::get_versions_chained` (FR-005's chain
738    /// walk); one with **no** registered client is `PackageNotFound`, never a fall back to
739    /// `pypi.org` (PyPI always sets `mirrors_crates_io: false`, so Cargo's mirror-degradation
740    /// arm is dead here and must not be written — falling back would send a private package
741    /// name to the public index, the exact #248-class leak this feature closes). Every other
742    /// source keeps today's public-registry path unchanged.
743    fn get_versions_from<'a>(
744        &'a self,
745        name: &'a deps_core::PackageName,
746        source: &'a DependencySource,
747        freshness: FreshnessSettings,
748    ) -> deps_core::ecosystem::BoxFuture<
749        'a,
750        deps_core::error::Result<Vec<Box<dyn deps_core::Version>>>,
751    > {
752        Box::pin(async move {
753            match source {
754                DependencySource::AlternateRegistry { index, .. } => {
755                    match self.alternate_client(index) {
756                        Some(client) => {
757                            let versions = client.get_versions_chained(name.as_str()).await?;
758                            Ok(versions
759                                .into_iter()
760                                .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
761                                .collect())
762                        }
763                        None => Err(DepsError::PackageNotFound {
764                            package: name.to_string(),
765                            registry: "alternate registry (not registered)",
766                        }),
767                    }
768                }
769                _ => deps_core::Registry::get_versions_with(self, name, freshness).await,
770            }
771        })
772    }
773
774    /// `get_versions_from`'s `get_latest_matching`-shaped counterpart — same dispatch, same
775    /// "never fall back to `pypi.org` for an unregistered `AlternateRegistry`" invariant.
776    ///
777    /// Derived from `Self::get_versions_chained` +
778    /// [`Registry::select_latest_matching`](deps_core::Registry::select_latest_matching) (fixes
779    /// M4) rather than an independent per-hop version-matching walk: the winning hop (first
780    /// hop with a non-empty version list) is selected once by
781    /// `Self::get_versions_chained`, and matching happens only within that single hop's
782    /// list. If the winning hop has no version matching `req`, that is terminal (`Ok(None)`),
783    /// not a trigger to search later hops for a "better" match — continuing would reintroduce
784    /// the cross-index version comparison this design avoids for the same
785    /// dependency-confusion reasons FR-005(b)'s ordering exists.
786    fn get_latest_matching_from<'a>(
787        &'a self,
788        name: &'a deps_core::PackageName,
789        source: &'a DependencySource,
790        req: &'a deps_core::VersionReq,
791        _minimum_stability: Option<&'a str>,
792    ) -> deps_core::ecosystem::BoxFuture<
793        'a,
794        deps_core::error::Result<Option<Box<dyn deps_core::Version>>>,
795    > {
796        Box::pin(async move {
797            match source {
798                DependencySource::AlternateRegistry { index, .. } => {
799                    match self.alternate_client(index) {
800                        Some(client) => {
801                            let versions: Vec<Box<dyn deps_core::Version>> = client
802                                .get_versions_chained(name.as_str())
803                                .await?
804                                .into_iter()
805                                .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
806                                .collect();
807                            let idx = client.select_latest_matching(&versions, req);
808                            Ok(idx.and_then(|i| versions.into_iter().nth(i)))
809                        }
810                        None => Err(DepsError::PackageNotFound {
811                            package: name.to_string(),
812                            registry: "alternate registry (not registered)",
813                        }),
814                    }
815                }
816                _ => {
817                    let version =
818                        Self::get_latest_matching(self, name.as_str(), req.as_str()).await?;
819                    Ok(version.map(|v| Box::new(v) as Box<dyn deps_core::Version>))
820                }
821            }
822        })
823    }
824
825    fn search<'a>(
826        &'a self,
827        query: &'a str,
828        limit: usize,
829    ) -> deps_core::ecosystem::BoxFuture<
830        'a,
831        deps_core::error::Result<Vec<Box<dyn deps_core::Metadata>>>,
832    > {
833        Box::pin(async move {
834            let packages = Self::search(self, query, limit).await?;
835            Ok(packages
836                .into_iter()
837                .map(|p| Box::new(p) as Box<dyn deps_core::Metadata>)
838                .collect())
839        })
840    }
841
842    fn select_latest_matching(
843        &self,
844        versions: &[Box<dyn deps_core::Version>],
845        req: &deps_core::VersionReq,
846    ) -> Option<usize> {
847        if deps_core::is_existence_wildcard(req) {
848            return deps_core::select_latest_for_existence(versions, |v| v.as_ref());
849        }
850        // The wildcard gate above already consumed `""`/`"*"`, so `req_str` here is always a
851        // concrete PEP 440 specifier — no "any version" normalization needed.
852        let specs = VersionSpecifiers::from_str(req.as_str()).ok()?;
853
854        versions.iter().position(|v| {
855            // Parsed directly via `pep440_rs` rather than the trait's default
856            // `is_prerelease` heuristic (substring match on "-alpha"/"-rc"/...), which
857            // does not recognize PyPI's unhyphenated prerelease spellings like
858            // "1.0.0rc1" and would silently treat them as stable.
859            Version::from_str(v.version_string().as_str()).is_ok_and(|ver| {
860                specs.contains(&ver) && !v.removal_status().blocks_resolution() && !ver.is_pre()
861            })
862        })
863    }
864
865    fn as_any(&self) -> &dyn Any {
866        self
867    }
868}
869
870// JSON response types
871
872#[derive(Debug, Deserialize)]
873struct PypiResponse {
874    info: PypiInfo,
875}
876
877#[derive(Debug, Deserialize)]
878struct PypiInfo {
879    name: String,
880    summary: Option<String>,
881    project_urls: Option<std::collections::HashMap<String, String>>,
882    version: String,
883}
884
885// PEP 691 Simple API JSON response types
886
887#[derive(Debug, Deserialize)]
888struct SimpleApiResponse {
889    versions: Vec<String>,
890    files: Vec<SimpleFile>,
891}
892
893#[derive(Debug, Deserialize)]
894struct SimpleFile {
895    filename: String,
896    #[serde(default)]
897    yanked: Yanked,
898    /// PEP 700 upload timestamp (RFC 3339), per file rather than per version.
899    ///
900    /// Absent on older Simple API responses; `#[serde(default)]` keeps such
901    /// entries parseable.
902    #[serde(rename = "upload-time", default)]
903    upload_time: Option<String>,
904}
905
906/// A file's yanked status per PEP 592: either `false` (not yanked) or a
907/// string giving the yank reason. `get_versions` only needs the yanked/not
908/// distinction, so the reason text itself is discarded on parse.
909#[derive(Debug, Deserialize)]
910#[serde(untagged)]
911enum Yanked {
912    Flag(bool),
913    Reason(#[expect(dead_code, reason = "reason text not surfaced by get_versions")] String),
914}
915
916impl Default for Yanked {
917    fn default() -> Self {
918        Self::Flag(false)
919    }
920}
921
922impl Yanked {
923    const fn is_yanked(&self) -> bool {
924        match self {
925            Self::Flag(b) => *b,
926            Self::Reason(_) => true,
927        }
928    }
929}
930
931/// Archive/wheel file extensions recognized on PyPI's Simple API index, used
932/// to strip the trailing extension off an sdist-style filename (one with no
933/// further `-`-delimited tags after the version) when deriving its version.
934const KNOWN_ARCHIVE_EXTENSIONS: &[&str] = &[
935    ".tar.gz", ".tar.bz2", ".tar.xz", ".tar.lz", ".tar.Z", ".zip", ".whl", ".egg", ".tar",
936];
937
938/// Derives the release version directly from `filename`'s structure, given
939/// the package's PEP 503 normalized name, in O(filename length) with no
940/// dependency on the number of known versions.
941///
942/// PyPI release filenames are `{name}-{version}[-...].{ext}`, but the
943/// `{name}` segment on disk may use the project's original casing and
944/// original `-`/`_`/`.` separators (e.g. `zope.interface-3.3.0b1.tar.gz` for
945/// normalized name `zope-interface`) rather than the normalized spelling.
946/// This walks `normalized_name` against `filename` byte-for-byte,
947/// case-insensitively, treating any run of `-`/`_`/`.` in `filename` as
948/// equivalent to a single `-` in `normalized_name`, then cuts the remainder
949/// at the first `-` (wheel tags) or a recognized archive extension (sdists).
950///
951/// Returns `None` if `filename` doesn't conform closely enough to derive a
952/// version unambiguously; callers skip attributing that file rather than
953/// guess (see [`build_version_metadata`]).
954fn parse_version_from_filename<'a>(filename: &'a str, normalized_name: &str) -> Option<&'a str> {
955    let bytes = filename.as_bytes();
956    let mut fi = 0usize;
957    for nb in normalized_name.bytes() {
958        if nb == b'-' {
959            let start = fi;
960            while matches!(bytes.get(fi), Some(b'-' | b'_' | b'.')) {
961                fi += 1;
962            }
963            if fi == start {
964                return None;
965            }
966        } else {
967            match bytes.get(fi) {
968                Some(&fb) if fb.to_ascii_lowercase() == nb => fi += 1,
969                _ => return None,
970            }
971        }
972    }
973    if bytes.get(fi) != Some(&b'-') {
974        return None;
975    }
976    let rest = &filename[fi + 1..];
977    match rest.find('-') {
978        Some(end) => Some(&rest[..end]),
979        None => KNOWN_ARCHIVE_EXTENSIONS
980            .iter()
981            .find_map(|ext| rest.strip_suffix(ext)),
982    }
983}
984
985/// Aggregated per-version metadata derived from a Simple API `files` list.
986#[derive(Debug, Default, PartialEq, Eq)]
987struct VersionMetadata {
988    /// A version is yanked if any of its release files are yanked (PyPI
989    /// itself treats a release as yanked once any file under it is, since
990    /// new uploads to an already-yanked version are rejected).
991    yanked: bool,
992    /// Earliest `upload-time` across the version's release files (a version
993    /// can ship multiple files/wheels uploaded at different times). `None`
994    /// if no file reports one.
995    published_at: Option<deps_core::PublishTime>,
996}
997
998/// Builds a per-version metadata map from a Simple API `files` list.
999///
1000/// Derives each file's version in O(1) via [`parse_version_from_filename`]
1001/// and resolves it against `versions` in two tiers, the second tried only
1002/// if the first misses:
1003/// 1. Exact string match — the common case, and the only tier that can tell
1004///    apart distinct-but-PEP-440-equal strings like `1.0` and `1.0.0`
1005///    (legacy packages can list both as separate releases).
1006/// 2. Match as a parsed [`Version`] — PyPI filenames sometimes spell a
1007///    version differently than its canonical form (e.g. `4.21.0_rc_1` in a
1008///    wheel filename for the canonical `4.21.0rc1`), which `Version`'s
1009///    `Eq`/`Hash` normalize away.
1010///
1011/// A file whose filename doesn't structurally parse, or whose derived
1012/// version matches neither tier, is skipped rather than guessed at via
1013/// substring search — an earlier substring-based fallback could misattribute
1014/// a file to an unrelated version whose digits happened to appear elsewhere
1015/// in the filename (e.g. a platform tag), which is worse than the version's
1016/// yanked status resting on its other, better-formed release files.
1017fn build_version_metadata(
1018    files: &[SimpleFile],
1019    versions: &[String],
1020    normalized_name: &str,
1021) -> std::collections::HashMap<String, VersionMetadata> {
1022    let version_set: std::collections::HashSet<&str> =
1023        versions.iter().map(String::as_str).collect();
1024
1025    // Built lazily: most real-world responses resolve every file via the
1026    // exact-string tier and never need this.
1027    let mut parsed_versions: Option<std::collections::HashMap<Version, &str>> = None;
1028
1029    let mut metadata: std::collections::HashMap<String, VersionMetadata> =
1030        std::collections::HashMap::new();
1031    for file in files {
1032        let matched =
1033            parse_version_from_filename(&file.filename, normalized_name).and_then(|candidate| {
1034                version_set.get(candidate).copied().or_else(|| {
1035                    let parsed = Version::from_str(candidate).ok()?;
1036                    let map = parsed_versions.get_or_insert_with(|| {
1037                        versions
1038                            .iter()
1039                            .filter_map(|v| Some((Version::from_str(v).ok()?, v.as_str())))
1040                            .collect()
1041                    });
1042                    map.get(&parsed).copied()
1043                })
1044            });
1045
1046        let Some(version) = matched else {
1047            continue;
1048        };
1049        let entry = metadata.entry(version.to_string()).or_default();
1050        entry.yanked |= file.yanked.is_yanked();
1051        if let Some(uploaded) = file
1052            .upload_time
1053            .as_deref()
1054            .and_then(deps_core::PublishTime::parse_rfc3339)
1055        {
1056            entry.published_at = Some(
1057                entry
1058                    .published_at
1059                    .map_or(uploaded, |existing| existing.max(uploaded)),
1060            );
1061        }
1062    }
1063    metadata
1064}
1065
1066/// Parse the version list from a PyPI Simple API (PEP 691) JSON response.
1067fn parse_simple_api_response(package_name: &str, data: &[u8]) -> Result<Vec<PypiVersion>> {
1068    let response: SimpleApiResponse =
1069        deps_core::parse_json_checked(data).map_err(|e| DepsError::ApiResponse {
1070            package: package_name.to_string(),
1071            registry: REGISTRY,
1072            source: e,
1073        })?;
1074
1075    let normalized_name = crate::name::normalize(package_name);
1076    let metadata_map =
1077        build_version_metadata(&response.files, &response.versions, &normalized_name);
1078
1079    let mut versions_with_parsed: Vec<(PypiVersion, Version)> = response
1080        .versions
1081        .into_iter()
1082        .filter_map(|version_str| {
1083            let parsed = Version::from_str(&version_str).ok()?;
1084            let meta = metadata_map.get(&version_str);
1085            let yanked = meta.is_some_and(|m| m.yanked);
1086            let published_at = meta.and_then(|m| m.published_at);
1087            Some((
1088                PypiVersion {
1089                    version: version_str.into(),
1090                    yanked,
1091                    published_at,
1092                },
1093                parsed,
1094            ))
1095        })
1096        .collect();
1097
1098    // Sort by version (newest first) using pre-parsed versions
1099    versions_with_parsed.sort_by(|a, b| b.1.cmp(&a.1));
1100
1101    Ok(versions_with_parsed.into_iter().map(|(v, _)| v).collect())
1102}
1103
1104/// Parse package info from PyPI JSON response.
1105fn parse_package_info(package_name: &str, data: &[u8]) -> Result<PypiPackage> {
1106    let response: PypiResponse =
1107        deps_core::parse_json_checked(data).map_err(|e| DepsError::ApiResponse {
1108            package: package_name.to_string(),
1109            registry: REGISTRY,
1110            source: e,
1111        })?;
1112
1113    let project_urls = response
1114        .info
1115        .project_urls
1116        .unwrap_or_default()
1117        .into_iter()
1118        .collect();
1119
1120    Ok(PypiPackage {
1121        name: response.info.name.into(),
1122        summary: response.info.summary,
1123        project_urls,
1124        latest_version: response.info.version.into(),
1125    })
1126}
1127
1128#[cfg(test)]
1129mod tests {
1130    use super::*;
1131
1132    use deps_core::test_util::{capture_tracing_output, capture_tracing_output_async};
1133    use std::assert_matches;
1134
1135    #[test]
1136    fn test_package_url() {
1137        assert_eq!(package_url("requests"), "https://pypi.org/project/requests");
1138        assert_eq!(package_url("flask"), "https://pypi.org/project/flask");
1139    }
1140
1141    #[test]
1142    fn test_package_url_normalization() {
1143        assert_eq!(package_url("Flask"), "https://pypi.org/project/flask");
1144        assert_eq!(
1145            package_url("django_rest_framework"),
1146            "https://pypi.org/project/django-rest-framework"
1147        );
1148    }
1149
1150    #[test]
1151    fn test_package_url_encoding() {
1152        let url = package_url("my-package");
1153        assert!(url.starts_with("https://pypi.org/project/"));
1154        assert!(url.contains("my-package"));
1155    }
1156
1157    #[test]
1158    fn test_package_url_encodes_malicious_name() {
1159        let url = package_url("evil](https://evil.example)[pkg");
1160        assert!(!url.contains('('));
1161        assert!(!url.contains(')'));
1162        assert!(!url.contains('['));
1163        assert!(!url.contains(']'));
1164    }
1165
1166    #[test]
1167    fn test_package_url_encodes_newline_autolink_and_percent() {
1168        let url = package_url("evil\n<https://evil%zz.example>");
1169        assert!(!url.contains('\n'));
1170        assert!(!url.contains('<'));
1171        assert!(!url.contains('>'));
1172        // The literal '%' from the payload must itself be encoded (to %25), or a
1173        // browser/renderer double-decode could smuggle a raw byte back in.
1174        assert!(url.contains("%25"));
1175    }
1176
1177    #[test]
1178    fn test_package_url_empty_name() {
1179        assert_eq!(package_url(""), "");
1180    }
1181
1182    #[test]
1183    fn test_package_url_normalizes_to_empty() {
1184        // "---" normalizes to "" (all separators) — must not build a dead
1185        // link with an empty path segment.
1186        assert_eq!(package_url("---"), "");
1187    }
1188
1189    #[test]
1190    fn test_parse_simple_api_response() {
1191        // Shape captured live from `pypi.org/simple/requests/` with
1192        // `Accept: application/vnd.pypi.simple.v1+json`.
1193        let json = r#"{
1194            "meta": {"api-version": "1.4"},
1195            "name": "requests",
1196            "versions": ["2.27.0", "2.28.0", "2.28.1", "2.28.2"],
1197            "files": [
1198                {"filename": "requests-2.27.0.tar.gz", "yanked": false},
1199                {"filename": "requests-2.28.0.tar.gz", "yanked": true},
1200                {"filename": "requests-2.28.0-py3-none-any.whl", "yanked": true},
1201                {"filename": "requests-2.28.1.tar.gz", "yanked": false},
1202                {"filename": "requests-2.28.2.tar.gz", "yanked": false},
1203                {"filename": "requests-2.28.2-py3-none-any.whl", "yanked": false}
1204            ]
1205        }"#;
1206
1207        let versions = parse_simple_api_response("requests", json.as_bytes()).unwrap();
1208
1209        assert_eq!(versions.len(), 4);
1210        assert_eq!(versions[0].version, "2.28.2");
1211        assert!(!versions[0].yanked);
1212        assert!(
1213            versions
1214                .iter()
1215                .find(|v| v.version == "2.28.0")
1216                .unwrap()
1217                .yanked
1218        );
1219    }
1220
1221    #[test]
1222    fn test_parse_simple_api_response_nesting_at_max_depth_accepted() {
1223        let depth = deps_core::MAX_JSON_NESTING_DEPTH;
1224        let json = format!(
1225            r#"{{"versions": [], "files": [], "extra": {}1{}}}"#,
1226            "[".repeat(depth - 1),
1227            "]".repeat(depth - 1)
1228        );
1229        assert!(parse_simple_api_response("pkg", json.as_bytes()).is_ok());
1230    }
1231
1232    #[test]
1233    fn test_parse_simple_api_response_nesting_over_max_depth_rejected() {
1234        let depth = deps_core::MAX_JSON_NESTING_DEPTH + 1;
1235        let json = format!(
1236            r#"{{"versions": [], "files": [], "extra": {}1{}}}"#,
1237            "[".repeat(depth),
1238            "]".repeat(depth)
1239        );
1240        assert!(parse_simple_api_response("pkg", json.as_bytes()).is_err());
1241    }
1242
1243    #[test]
1244    fn test_parse_simple_api_response_with_upload_time() {
1245        let json = r#"{
1246            "meta": {"api-version": "1.4"},
1247            "name": "requests",
1248            "versions": ["2.28.2"],
1249            "files": [
1250                {"filename": "requests-2.28.2.tar.gz", "yanked": false, "upload-time": "2026-05-14T19:25:27.735762Z"}
1251            ]
1252        }"#;
1253
1254        let versions = parse_simple_api_response("requests", json.as_bytes()).unwrap();
1255        assert_eq!(versions.len(), 1);
1256        assert_eq!(
1257            versions[0].published_at,
1258            deps_core::PublishTime::parse_rfc3339("2026-05-14T19:25:27.735762Z")
1259        );
1260    }
1261
1262    #[test]
1263    fn test_parse_simple_api_response_without_upload_time() {
1264        let json = r#"{
1265            "meta": {"api-version": "1.4"},
1266            "name": "requests",
1267            "versions": ["2.28.2"],
1268            "files": [
1269                {"filename": "requests-2.28.2.tar.gz", "yanked": false}
1270            ]
1271        }"#;
1272
1273        let versions = parse_simple_api_response("requests", json.as_bytes()).unwrap();
1274        assert_eq!(versions.len(), 1);
1275        assert!(versions[0].published_at.is_none());
1276    }
1277
1278    #[test]
1279    fn test_parse_simple_api_response_with_malformed_upload_time() {
1280        let json = r#"{
1281            "meta": {"api-version": "1.4"},
1282            "name": "requests",
1283            "versions": ["2.28.2"],
1284            "files": [
1285                {"filename": "requests-2.28.2.tar.gz", "yanked": false, "upload-time": "not-a-timestamp"}
1286            ]
1287        }"#;
1288
1289        let versions = parse_simple_api_response("requests", json.as_bytes()).unwrap();
1290        assert_eq!(versions.len(), 1);
1291        assert!(
1292            versions[0].published_at.is_none(),
1293            "malformed upload-time degrades to None, not an error"
1294        );
1295    }
1296
1297    #[test]
1298    fn test_parse_simple_api_response_yanked_with_reason_string() {
1299        // PEP 592: `yanked` may be a non-empty string giving the reason,
1300        // which still means "yanked" (only `false` means not yanked).
1301        let json = r#"{
1302            "meta": {"api-version": "1.4"},
1303            "name": "urllib3",
1304            "versions": ["1.25"],
1305            "files": [
1306                {"filename": "urllib3-1.25-py2.py3-none-any.whl", "yanked": "Broken release"},
1307                {"filename": "urllib3-1.25.tar.gz", "yanked": "Broken release"}
1308            ]
1309        }"#;
1310
1311        let versions = parse_simple_api_response("urllib3", json.as_bytes()).unwrap();
1312        assert_eq!(versions.len(), 1);
1313        assert!(versions[0].yanked);
1314    }
1315
1316    #[test]
1317    fn test_build_version_metadata_disambiguates_version_prefixes() {
1318        // "1.0" is a substring of the "1.0.0" filename; trying the longer
1319        // version first must ensure "1.0"'s own (absent) file isn't
1320        // conflated with "1.0.0"'s file.
1321        let files = vec![SimpleFile {
1322            filename: "pkg-1.0.0.tar.gz".to_string(),
1323            yanked: Yanked::Flag(true),
1324            upload_time: None,
1325        }];
1326        let versions = vec!["1.0".to_string(), "1.0.0".to_string()];
1327        let map = build_version_metadata(&files, &versions, "pkg");
1328        assert!(map.get("1.0.0").unwrap().yanked);
1329        assert!(!map.contains_key("1.0"));
1330    }
1331
1332    #[test]
1333    fn test_build_version_metadata_any_file_yanked_marks_version_yanked() {
1334        let files = vec![
1335            SimpleFile {
1336                filename: "pkg-1.0.0-py3-none-any.whl".to_string(),
1337                yanked: Yanked::Flag(false),
1338                upload_time: None,
1339            },
1340            SimpleFile {
1341                filename: "pkg-1.0.0.tar.gz".to_string(),
1342                yanked: Yanked::Flag(true),
1343                upload_time: None,
1344            },
1345        ];
1346        let versions = vec!["1.0.0".to_string()];
1347        let map = build_version_metadata(&files, &versions, "pkg");
1348        assert!(map.get("1.0.0").unwrap().yanked);
1349    }
1350
1351    #[test]
1352    fn test_build_version_metadata_disambiguates_in_both_directions() {
1353        // Both "1.0" and "1.0.0" are real releases with their own files.
1354        // Matching must not let the longer version's absent file bleed into
1355        // the shorter version's real one, nor vice versa.
1356        let files = vec![
1357            SimpleFile {
1358                filename: "pkg-1.0.tar.gz".to_string(),
1359                yanked: Yanked::Flag(true),
1360                upload_time: None,
1361            },
1362            SimpleFile {
1363                filename: "pkg-1.0.0.tar.gz".to_string(),
1364                yanked: Yanked::Flag(false),
1365                upload_time: None,
1366            },
1367        ];
1368        let versions = vec!["1.0".to_string(), "1.0.0".to_string()];
1369        let map = build_version_metadata(&files, &versions, "pkg");
1370        assert!(map.get("1.0").unwrap().yanked);
1371        assert!(!map.get("1.0.0").unwrap().yanked);
1372    }
1373
1374    #[test]
1375    fn test_build_version_metadata_pre_post_dev_suffixes() {
1376        // Pre/post/dev-release version strings are '.'-delimited suffixes on
1377        // the base version and must not be conflated with it or each other.
1378        let files = vec![
1379            SimpleFile {
1380                filename: "pkg-1.0.0.tar.gz".to_string(),
1381                yanked: Yanked::Flag(false),
1382                upload_time: None,
1383            },
1384            SimpleFile {
1385                filename: "pkg-1.0.0rc1.tar.gz".to_string(),
1386                yanked: Yanked::Flag(true),
1387                upload_time: None,
1388            },
1389            SimpleFile {
1390                filename: "pkg-1.0.0.post1.tar.gz".to_string(),
1391                yanked: Yanked::Flag(false),
1392                upload_time: None,
1393            },
1394            SimpleFile {
1395                filename: "pkg-1.0.0.dev1.tar.gz".to_string(),
1396                yanked: Yanked::Flag(true),
1397                upload_time: None,
1398            },
1399        ];
1400        let versions = vec![
1401            "1.0.0".to_string(),
1402            "1.0.0rc1".to_string(),
1403            "1.0.0.post1".to_string(),
1404            "1.0.0.dev1".to_string(),
1405        ];
1406        let map = build_version_metadata(&files, &versions, "pkg");
1407        assert!(!map.get("1.0.0").unwrap().yanked);
1408        assert!(map.get("1.0.0rc1").unwrap().yanked);
1409        assert!(!map.get("1.0.0.post1").unwrap().yanked);
1410        assert!(map.get("1.0.0.dev1").unwrap().yanked);
1411    }
1412
1413    #[test]
1414    fn test_build_version_metadata_takes_maximum_upload_time() {
1415        // A version can ship multiple files (sdist + wheels) uploaded at
1416        // different times, and PyPI allows adding a new file to an already
1417        // published version. The most-recently-added file is what should
1418        // count for freshness (fail-closed against the cooldown window),
1419        // not the version's original release.
1420        let files = vec![
1421            SimpleFile {
1422                filename: "pkg-1.0.0-py3-none-any.whl".to_string(),
1423                yanked: Yanked::Flag(false),
1424                upload_time: Some("2026-05-14T19:25:27Z".to_string()),
1425            },
1426            SimpleFile {
1427                filename: "pkg-1.0.0.tar.gz".to_string(),
1428                yanked: Yanked::Flag(false),
1429                upload_time: Some("2026-05-14T10:00:00Z".to_string()),
1430            },
1431        ];
1432        let versions = vec!["1.0.0".to_string()];
1433        let map = build_version_metadata(&files, &versions, "pkg");
1434        assert_eq!(
1435            map.get("1.0.0").unwrap().published_at,
1436            deps_core::PublishTime::parse_rfc3339("2026-05-14T19:25:27Z")
1437        );
1438    }
1439
1440    #[test]
1441    fn test_build_version_metadata_absent_upload_time_is_none() {
1442        let files = vec![SimpleFile {
1443            filename: "pkg-1.0.0.tar.gz".to_string(),
1444            yanked: Yanked::Flag(false),
1445            upload_time: None,
1446        }];
1447        let versions = vec!["1.0.0".to_string()];
1448        let map = build_version_metadata(&files, &versions, "pkg");
1449        assert!(map.get("1.0.0").unwrap().published_at.is_none());
1450    }
1451
1452    #[test]
1453    fn test_build_version_metadata_malformed_upload_time_is_none() {
1454        let files = vec![SimpleFile {
1455            filename: "pkg-1.0.0.tar.gz".to_string(),
1456            yanked: Yanked::Flag(false),
1457            upload_time: Some("not-a-timestamp".to_string()),
1458        }];
1459        let versions = vec!["1.0.0".to_string()];
1460        let map = build_version_metadata(&files, &versions, "pkg");
1461        assert!(
1462            map.get("1.0.0").unwrap().published_at.is_none(),
1463            "malformed upload-time degrades to None, not an error"
1464        );
1465    }
1466
1467    #[test]
1468    fn test_parse_version_from_filename_wheel_and_sdist() {
1469        assert_eq!(
1470            parse_version_from_filename("requests-2.28.2.tar.gz", "requests"),
1471            Some("2.28.2")
1472        );
1473        assert_eq!(
1474            parse_version_from_filename("requests-2.28.2-py3-none-any.whl", "requests"),
1475            Some("2.28.2")
1476        );
1477    }
1478
1479    #[test]
1480    fn test_parse_version_from_filename_dotted_and_underscored_project_name() {
1481        // Real PyPI filenames keep the project's original separator style
1482        // even though `normalized_name` collapses it to hyphens.
1483        assert_eq!(
1484            parse_version_from_filename("zope.interface-3.3.0b1.tar.gz", "zope-interface"),
1485            Some("3.3.0b1")
1486        );
1487        assert_eq!(
1488            parse_version_from_filename(
1489                "typing_extensions-3.6.2-py3-none-any.whl",
1490                "typing-extensions"
1491            ),
1492            Some("3.6.2")
1493        );
1494    }
1495
1496    #[test]
1497    fn test_parse_version_from_filename_rejects_non_conforming() {
1498        // Doesn't start with the package name at all.
1499        assert_eq!(
1500            parse_version_from_filename("other-1.0.0.tar.gz", "pkg"),
1501            None
1502        );
1503        // Name matches but there's no name/version separator afterwards.
1504        assert_eq!(parse_version_from_filename("pkg1.0.0.tar.gz", "pkg"), None);
1505    }
1506
1507    #[test]
1508    fn test_parse_version_from_filename_no_digit_run_confusion() {
1509        // Structural parsing reads the version as the literal token right
1510        // after the name prefix, so "1.10.0" is never mistaken for "1.0.0"
1511        // the way a substring search could be.
1512        assert_eq!(
1513            parse_version_from_filename("pkg-1.10.0.tar.gz", "pkg"),
1514            Some("1.10.0")
1515        );
1516    }
1517
1518    #[test]
1519    fn test_build_version_metadata_uses_fast_path_not_quadratic_fallback() {
1520        // Regression guard for the O(files x versions) blowup: a well-formed
1521        // filename must resolve via the O(1) structural fast path,
1522        // regardless of how many other versions exist.
1523        let mut versions: Vec<String> = (0..2000).map(|i| format!("0.0.{i}")).collect();
1524        versions.push("9.9.9".to_string());
1525        let files = vec![SimpleFile {
1526            filename: "pkg-9.9.9.tar.gz".to_string(),
1527            yanked: Yanked::Flag(true),
1528            upload_time: None,
1529        }];
1530        assert_eq!(
1531            parse_version_from_filename(&files[0].filename, "pkg"),
1532            Some("9.9.9"),
1533            "fast path must derive the version directly from filename structure"
1534        );
1535        let map = build_version_metadata(&files, &versions, "pkg");
1536        assert!(map.get("9.9.9").unwrap().yanked);
1537    }
1538
1539    #[test]
1540    fn test_build_version_metadata_ignores_platform_tag_false_match() {
1541        // Regression for a live PyPI file (`pyobjc_core-2.2-py2.6-macosx-10.3-fat.egg`):
1542        // the old whole-filename substring scan could misattribute this file
1543        // to version "10.3" (a platform tag that happens to look like a
1544        // version and sorts before "2.2" by length) instead of "2.2". The
1545        // structural fast path only looks at the token right after the name
1546        // prefix, so it can't be fooled by tags later in the filename.
1547        let files = vec![SimpleFile {
1548            filename: "pyobjc_core-2.2-py2.6-macosx-10.3-fat.egg".to_string(),
1549            yanked: Yanked::Flag(true),
1550            upload_time: None,
1551        }];
1552        let versions = vec!["2.2".to_string(), "10.3".to_string()];
1553        let map = build_version_metadata(&files, &versions, "pyobjc-core");
1554        assert!(map.get("2.2").unwrap().yanked);
1555        assert!(!map.contains_key("10.3"));
1556    }
1557
1558    #[test]
1559    fn test_build_version_metadata_pep440_underscore_normalization() {
1560        // Regression for a live PyPI file
1561        // (`protobuf-4.21.0_rc_1-cp310-abi3-win_amd64.whl`): the wheel
1562        // filename spells the pre-release as `4.21.0_rc_1` while the
1563        // canonical version string PyPI lists is `4.21.0rc1`. An exact
1564        // string comparison misses this; PEP 440-normalized comparison
1565        // (`Version`'s `Eq`) does not.
1566        let files = vec![SimpleFile {
1567            filename: "protobuf-4.21.0_rc_1-cp310-abi3-win_amd64.whl".to_string(),
1568            yanked: Yanked::Flag(false),
1569            upload_time: None,
1570        }];
1571        let versions = vec!["4.21.0rc1".to_string()];
1572        let map = build_version_metadata(&files, &versions, "protobuf");
1573        assert!(!map.get("4.21.0rc1").unwrap().yanked);
1574    }
1575
1576    #[test]
1577    fn test_build_version_metadata_skips_non_conforming_filename_instead_of_guessing() {
1578        // A filename whose leading segment doesn't match the package's
1579        // normalized name at all can't resolve via the structural fast path.
1580        // Rather than fall back to a substring guess (the mechanism behind
1581        // the pyobjc-core misattribution above), that file is skipped —
1582        // it contributes nothing to the metadata map, but doesn't corrupt it
1583        // either. A well-formed file for the same version still resolves
1584        // correctly, and an unrelated version stays untouched.
1585        let files = vec![
1586            SimpleFile {
1587                filename: "unrelated-file-1.0.0.zip".to_string(),
1588                yanked: Yanked::Flag(true),
1589                upload_time: None,
1590            },
1591            SimpleFile {
1592                filename: "pkg-1.0.0-py3-none-any.whl".to_string(),
1593                yanked: Yanked::Flag(true),
1594                upload_time: None,
1595            },
1596        ];
1597        let versions = vec!["1.0.0".to_string(), "2.0.0".to_string()];
1598        assert_eq!(
1599            parse_version_from_filename(&files[0].filename, "pkg"),
1600            None,
1601            "filename doesn't start with the package name, must be skipped"
1602        );
1603        let map = build_version_metadata(&files, &versions, "pkg");
1604        assert!(map.get("1.0.0").unwrap().yanked);
1605        assert!(!map.contains_key("2.0.0"));
1606    }
1607
1608    #[test]
1609    fn test_simple_api_url_encodes_malicious_name() {
1610        // `normalize_package_name` only collapses `-`/`_`/`.` separators and
1611        // leaves characters like `/` untouched, so the URL builder itself
1612        // must encode them to prevent smuggling extra path segments.
1613        let url = simple_api_url(PYPI_SIMPLE_BASE, "evil/../secret");
1614        assert!(url.starts_with(PYPI_SIMPLE_BASE));
1615        assert!(!url.contains("/../"));
1616        assert_eq!(url, format!("{PYPI_SIMPLE_BASE}/evil%2F..%2Fsecret/"));
1617    }
1618
1619    #[test]
1620    fn test_metadata_url_encodes_malicious_name() {
1621        let url = metadata_url("pkg?x=1#frag");
1622        assert!(!url.contains('?'));
1623        assert!(!url.contains('#'));
1624    }
1625
1626    /// #365 regression sweep: exercises the real production `name::normalize`
1627    /// (collapsing a dot-segment to the empty string, rejected before the sink) and
1628    /// `simple_api_url` together against the shared adversarial input set. Uses the
1629    /// `_transformed` variant since `normalize` legitimately rewrites a compound input
1630    /// (e.g. `../../etc/passwd` -> `/-/etc/passwd`) rather than passing it through
1631    /// unchanged, so the survival check must compare against the normalized form, not the
1632    /// raw adversarial segment.
1633    #[test]
1634    fn test_simple_api_url_dot_segment_sweep() {
1635        deps_core::test_util::assert_dot_segment_gated_or_contained_transformed(
1636            |seg| {
1637                let normalized = crate::name::normalize(seg);
1638                (!normalized.is_empty()).then(|| simple_api_url(PYPI_SIMPLE_BASE, &normalized))
1639            },
1640            crate::name::normalize,
1641            "pypi.org",
1642            "/simple/",
1643        );
1644    }
1645
1646    /// #365 regression sweep: exercises the real production `name::normalize` and
1647    /// `metadata_url` together against the shared adversarial input set, mirroring
1648    /// `test_simple_api_url_dot_segment_sweep` for the sibling sink (#380).
1649    #[test]
1650    fn test_metadata_url_dot_segment_sweep() {
1651        deps_core::test_util::assert_dot_segment_gated_or_contained_transformed(
1652            |seg| {
1653                let normalized = crate::name::normalize(seg);
1654                (!normalized.is_empty()).then(|| metadata_url(&normalized))
1655            },
1656            crate::name::normalize,
1657            "pypi.org",
1658            "/pypi/",
1659        );
1660    }
1661
1662    #[test]
1663    fn test_simple_api_url_normal_names() {
1664        assert_eq!(
1665            simple_api_url(PYPI_SIMPLE_BASE, "requests"),
1666            "https://pypi.org/simple/requests/"
1667        );
1668        assert_eq!(
1669            simple_api_url(PYPI_SIMPLE_BASE, "zope-interface"),
1670            "https://pypi.org/simple/zope-interface/"
1671        );
1672    }
1673
1674    #[test]
1675    fn test_parse_package_info() {
1676        let json = r#"{
1677            "info": {
1678                "name": "flask",
1679                "summary": "A micro web framework",
1680                "version": "3.0.0",
1681                "project_urls": {
1682                    "Documentation": "https://flask.palletsprojects.com/",
1683                    "Repository": "https://github.com/pallets/flask"
1684                }
1685            }
1686        }"#;
1687
1688        let pkg = parse_package_info("flask", json.as_bytes()).unwrap();
1689
1690        assert_eq!(pkg.name, "flask");
1691        assert_eq!(pkg.summary, Some("A micro web framework".to_string()));
1692        assert_eq!(pkg.latest_version, "3.0.0");
1693        assert_eq!(pkg.project_urls.len(), 2);
1694    }
1695
1696    #[test]
1697    fn test_parse_package_info_nesting_at_max_depth_accepted() {
1698        let depth = deps_core::MAX_JSON_NESTING_DEPTH;
1699        let json = format!(
1700            r#"{{"info": {{"name": "pkg", "version": "1.0.0"}}, "extra": {}1{}}}"#,
1701            "[".repeat(depth - 1),
1702            "]".repeat(depth - 1)
1703        );
1704        assert!(parse_package_info("pkg", json.as_bytes()).is_ok());
1705    }
1706
1707    #[test]
1708    fn test_parse_package_info_nesting_over_max_depth_rejected() {
1709        let depth = deps_core::MAX_JSON_NESTING_DEPTH + 1;
1710        let json = format!(
1711            r#"{{"info": {{"name": "pkg", "version": "1.0.0"}}, "extra": {}1{}}}"#,
1712            "[".repeat(depth),
1713            "]".repeat(depth)
1714        );
1715        assert!(parse_package_info("pkg", json.as_bytes()).is_err());
1716    }
1717
1718    #[test]
1719    fn test_wildcard_specifier_normalization() {
1720        // Test that "*" is normalized to empty string for PEP 440 compatibility
1721        // The get_latest_matching method normalizes "*" to "" internally
1722        let normalized = if "*" == "*" { "" } else { "*" };
1723        assert_eq!(normalized, "");
1724
1725        // Verify that empty string is valid PEP 440 (matches any version)
1726        let specs = VersionSpecifiers::from_str("").unwrap();
1727        assert!(specs.contains(&Version::from_str("1.0.0").unwrap()));
1728        assert!(specs.contains(&Version::from_str("2.5.3").unwrap()));
1729        assert!(specs.contains(&Version::from_str("0.0.1").unwrap()));
1730    }
1731
1732    #[test]
1733    fn test_not_found_or_maps_404_to_package_not_found() {
1734        let err = DepsError::HttpStatus {
1735            url: "https://pypi.org/pypi/flask/json".into(),
1736            status: 404,
1737        };
1738        let result = not_found_or(err, "flask");
1739        assert_matches!(
1740            result,
1741            DepsError::PackageNotFound { package, registry }
1742                if package == "flask" && registry == REGISTRY
1743        );
1744    }
1745
1746    #[test]
1747    fn test_not_found_or_passes_through_non_404() {
1748        // Regression test: a package name containing the substring "404" must
1749        // not be misclassified as not-found for a non-404 failure, since the
1750        // fix replaced string matching on the formatted error with a
1751        // structural match on the HTTP status code.
1752        let err = DepsError::HttpStatus {
1753            url: "https://pypi.org/pypi/pytest-404/json".into(),
1754            status: 500,
1755        };
1756        let result = not_found_or(err, "pytest-404");
1757        assert_matches!(result, DepsError::HttpStatus { status: 500, .. });
1758    }
1759
1760    #[test]
1761    fn test_prerelease_detection() {
1762        let json = r#"{
1763            "meta": {"api-version": "1.4"},
1764            "name": "test",
1765            "versions": ["1.0.0", "1.0.0a1", "1.0.0b2", "1.0.0rc1"],
1766            "files": [
1767                {"filename": "test-1.0.0.tar.gz", "yanked": false},
1768                {"filename": "test-1.0.0a1.tar.gz", "yanked": false},
1769                {"filename": "test-1.0.0b2.tar.gz", "yanked": false},
1770                {"filename": "test-1.0.0rc1.tar.gz", "yanked": false}
1771            ]
1772        }"#;
1773
1774        let versions = parse_simple_api_response("test", json.as_bytes()).unwrap();
1775
1776        let stable: Vec<_> = versions.iter().filter(|v| !v.is_prerelease()).collect();
1777        let prerelease: Vec<_> = versions.iter().filter(|v| v.is_prerelease()).collect();
1778
1779        assert_eq!(stable.len(), 1);
1780        assert_eq!(prerelease.len(), 3);
1781    }
1782
1783    #[tokio::test]
1784    async fn test_get_versions_empty_normalized_name_short_circuits() {
1785        // `name::normalize("---")` is "" — must fail as PackageNotFound
1786        // before any HTTP request is attempted (an empty Simple API segment
1787        // would otherwise build `https://pypi.org/simple//`).
1788        let cache = std::sync::Arc::new(deps_core::HttpCache::new());
1789        let registry = PypiRegistry::new(cache);
1790        let err = registry.get_versions("---").await.unwrap_err();
1791        assert_matches!(
1792            err,
1793            DepsError::PackageNotFound { package, registry }
1794                if package == "---" && registry == REGISTRY
1795        );
1796    }
1797
1798    #[tokio::test]
1799    async fn test_get_package_metadata_empty_normalized_name_short_circuits() {
1800        let cache = std::sync::Arc::new(deps_core::HttpCache::new());
1801        let registry = PypiRegistry::new(cache);
1802        let err = registry.get_package_metadata("...").await.unwrap_err();
1803        assert_matches!(err, DepsError::PackageNotFound { .. });
1804    }
1805
1806    #[tokio::test]
1807    async fn test_get_versions_empty_normalized_name_logs_warn_rejected_value() {
1808        // #380 B3: the short-circuit test above only proves the `Err` return value, not
1809        // that `warn_rejected_value` actually fires — a deleted warn call would still pass it.
1810        let cache = std::sync::Arc::new(deps_core::HttpCache::new());
1811        let registry = PypiRegistry::new(cache);
1812        let output = capture_tracing_output_async(async {
1813            let _ = registry.get_versions("---").await;
1814        })
1815        .await;
1816        assert!(
1817            output.contains("pypi_normalized_name_empty"),
1818            "output was: {output}"
1819        );
1820        assert!(
1821            output.contains("PyPI simple API request URL"),
1822            "output was: {output}"
1823        );
1824    }
1825
1826    #[tokio::test]
1827    async fn test_get_package_metadata_empty_normalized_name_logs_warn_rejected_value() {
1828        let cache = std::sync::Arc::new(deps_core::HttpCache::new());
1829        let registry = PypiRegistry::new(cache);
1830        let output = capture_tracing_output_async(async {
1831            let _ = registry.get_package_metadata("...").await;
1832        })
1833        .await;
1834        assert!(
1835            output.contains("pypi_normalized_name_empty"),
1836            "output was: {output}"
1837        );
1838        assert!(
1839            output.contains("PyPI package metadata request URL"),
1840            "output was: {output}"
1841        );
1842    }
1843
1844    #[test]
1845    fn test_package_url_empty_normalized_name_logs_warn_rejected_value() {
1846        let output = capture_tracing_output(|| {
1847            let _ = package_url("---");
1848        });
1849        assert!(
1850            output.contains("pypi_normalized_name_empty"),
1851            "output was: {output}"
1852        );
1853        assert!(
1854            output.contains("PyPI package display URL"),
1855            "output was: {output}"
1856        );
1857        assert!(
1858            !output.contains("---"),
1859            "raw rejected value must not be logged: {output}"
1860        );
1861    }
1862
1863    #[test]
1864    fn test_package_url_accepted_logs_no_warn() {
1865        let output = capture_tracing_output(|| {
1866            let _ = package_url("requests");
1867        });
1868        assert!(output.is_empty(), "output was: {output}");
1869    }
1870
1871    #[test]
1872    fn test_select_latest_matching_not_default_none() {
1873        use deps_core::{Registry, VersionReq};
1874
1875        let cache = Arc::new(HttpCache::new());
1876        let registry = PypiRegistry::new(cache);
1877        let versions: Vec<Box<dyn deps_core::Version>> = vec![
1878            Box::new(PypiVersion {
1879                version: "2.0.0".into(),
1880                yanked: true,
1881                published_at: None,
1882            }),
1883            Box::new(PypiVersion {
1884                version: "1.0.0".into(),
1885                yanked: false,
1886                published_at: None,
1887            }),
1888        ];
1889        let req = VersionReq::new("*");
1890        assert_eq!(registry.select_latest_matching(&versions, &req), Some(1));
1891    }
1892
1893    #[test]
1894    fn test_select_latest_matching_excludes_unhyphenated_prerelease() {
1895        // Regression guard: the trait default `is_prerelease` heuristic (substring match
1896        // on "-rc"/"-alpha"/...) does not recognize PyPI's unhyphenated spelling
1897        // ("1.0.0rc1") and would wrongly treat it as stable if used here instead of the
1898        // pep440_rs-based check.
1899        use deps_core::{Registry, VersionReq};
1900
1901        let cache = Arc::new(HttpCache::new());
1902        let registry = PypiRegistry::new(cache);
1903        let versions: Vec<Box<dyn deps_core::Version>> = vec![
1904            Box::new(PypiVersion {
1905                version: "2.0.0rc1".into(),
1906                yanked: false,
1907                published_at: None,
1908            }),
1909            Box::new(PypiVersion {
1910                version: "1.0.0".into(),
1911                yanked: false,
1912                published_at: None,
1913            }),
1914        ];
1915        let req = VersionReq::new("*");
1916        assert_eq!(registry.select_latest_matching(&versions, &req), Some(1));
1917    }
1918
1919    #[test]
1920    fn test_select_latest_matching_all_yanked_returns_newest_yanked() {
1921        use deps_core::{Registry, VersionReq};
1922
1923        let cache = Arc::new(HttpCache::new());
1924        let registry = PypiRegistry::new(cache);
1925        let versions: Vec<Box<dyn deps_core::Version>> = vec![
1926            Box::new(PypiVersion {
1927                version: "2.0.0".into(),
1928                yanked: true,
1929                published_at: None,
1930            }),
1931            Box::new(PypiVersion {
1932                version: "1.0.0".into(),
1933                yanked: true,
1934                published_at: None,
1935            }),
1936        ];
1937        let req = VersionReq::new("*");
1938        assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
1939    }
1940
1941    #[test]
1942    fn test_select_latest_matching_all_prerelease_returns_newest_prerelease() {
1943        use deps_core::{Registry, VersionReq};
1944
1945        let cache = Arc::new(HttpCache::new());
1946        let registry = PypiRegistry::new(cache);
1947        let versions: Vec<Box<dyn deps_core::Version>> = vec![
1948            Box::new(PypiVersion {
1949                version: "2.0.0rc1".into(),
1950                yanked: false,
1951                published_at: None,
1952            }),
1953            Box::new(PypiVersion {
1954                version: "1.0.0rc1".into(),
1955                yanked: false,
1956                published_at: None,
1957            }),
1958        ];
1959        let req = VersionReq::new("*");
1960        assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
1961    }
1962
1963    #[tokio::test]
1964    async fn test_search_empty_query_returns_empty_without_building_index() {
1965        // A mock with `expect(0)` (the default) fails the test if it's ever hit —
1966        // an empty/whitespace query must short-circuit before touching the
1967        // network at all.
1968        let mut server = mockito::Server::new_async().await;
1969        let mock = server
1970            .mock("GET", "/simple/")
1971            .expect(0)
1972            .create_async()
1973            .await;
1974
1975        let cache = Arc::new(HttpCache::new());
1976        let index_url = format!("{}/simple/", server.url());
1977        let registry = PypiRegistry::with_index_url(cache, index_url);
1978
1979        assert!(registry.search("", 10).await.unwrap().is_empty());
1980        assert!(registry.search("---", 10).await.unwrap().is_empty());
1981        mock.assert_async().await;
1982    }
1983
1984    #[tokio::test]
1985    async fn test_search_cold_start_returns_empty_immediately() {
1986        let mut server = mockito::Server::new_async().await;
1987        let mock = server
1988            .mock("GET", "/simple/")
1989            .with_status(200)
1990            .with_body(crate::search::sample_index_body(&["requests"]))
1991            .expect(1)
1992            .create_async()
1993            .await;
1994
1995        let cache = Arc::new(HttpCache::new());
1996        let index_url = format!("{}/simple/", server.url());
1997        let registry = PypiRegistry::with_index_url(cache, index_url);
1998
1999        // The very first call must not block on the download.
2000        let results = registry.search("reque", 10).await.unwrap();
2001        assert!(
2002            results.is_empty(),
2003            "cold start must return empty immediately"
2004        );
2005
2006        // #419 M4 regression: the old permanent stub also satisfied the assertion
2007        // above, since it always returned empty. What must distinguish the real
2008        // implementation is that the cold-start call above actually triggered a
2009        // background build — confirmed here by waiting for the mock to be hit,
2010        // rather than stopping at "returned empty" alone.
2011        for _ in 0..100 {
2012            if mock.matched_async().await {
2013                break;
2014            }
2015            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2016        }
2017        mock.assert_async().await;
2018    }
2019
2020    #[tokio::test]
2021    async fn test_search_finds_match_after_index_builds() {
2022        let mut server = mockito::Server::new_async().await;
2023        let mock = server
2024            .mock("GET", "/simple/")
2025            .with_status(200)
2026            .with_body(crate::search::sample_index_body(&[
2027                "requests",
2028                "requests-oauthlib",
2029            ]))
2030            .expect(1)
2031            .create_async()
2032            .await;
2033
2034        let cache = Arc::new(HttpCache::new());
2035        let index_url = format!("{}/simple/", server.url());
2036        let registry = PypiRegistry::with_index_url(cache, index_url);
2037
2038        let mut results = registry.search("reque", 10).await.unwrap();
2039        for _ in 0..100 {
2040            if !results.is_empty() {
2041                break;
2042            }
2043            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2044            results = registry.search("reque", 10).await.unwrap();
2045        }
2046
2047        let names: Vec<String> = results.iter().map(|p| p.name.to_string()).collect();
2048        assert!(names.contains(&"requests".to_string()));
2049        assert!(names.contains(&"requests-oauthlib".to_string()));
2050        // C2 build-once: a second round of searches after the index is ready
2051        // must not trigger another fetch.
2052        let _ = registry.search("req", 10).await.unwrap();
2053        mock.assert_async().await;
2054    }
2055
2056    #[tokio::test]
2057    async fn test_search_no_match_returns_empty_once_index_is_ready() {
2058        let mut server = mockito::Server::new_async().await;
2059        let _mock = server
2060            .mock("GET", "/simple/")
2061            .with_status(200)
2062            .with_body(crate::search::sample_index_body(&["requests"]))
2063            .create_async()
2064            .await;
2065
2066        let cache = Arc::new(HttpCache::new());
2067        let index_url = format!("{}/simple/", server.url());
2068        let registry = PypiRegistry::with_index_url(cache, index_url);
2069
2070        // Poll on a query that IS expected to eventually match, purely to know
2071        // the index has finished building, then assert a non-matching query
2072        // against the now-ready index.
2073        let mut became_ready = false;
2074        for _ in 0..100 {
2075            if !registry.search("reque", 10).await.unwrap().is_empty() {
2076                became_ready = true;
2077                break;
2078            }
2079            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2080        }
2081        // #419 M4 regression: without this assertion, a version of the index that
2082        // never finishes building would make the final `no_match.is_empty()`
2083        // assertion vacuously true instead of exercising the intended "matched
2084        // against a ready index" case.
2085        assert!(
2086            became_ready,
2087            "index never became ready within the poll budget"
2088        );
2089
2090        let no_match = registry
2091            .search("this-prefix-matches-nothing-zzz", 10)
2092            .await
2093            .unwrap();
2094        assert!(no_match.is_empty());
2095    }
2096
2097    // --- T006/T007/T008: private-index chain infrastructure ---
2098
2099    fn all_policy() -> deps_core::net_policy::RegistryAccessPolicy {
2100        deps_core::net_policy::RegistryAccessPolicy::new(
2101            deps_core::net_policy::WorkspaceRegistryAccess::All,
2102        )
2103    }
2104
2105    /// Builds a validated [`PypiIndexUrl`] for a `mockito` loopback server — requires both
2106    /// the `cfg(test)` loopback carve-out and an `All` runtime policy.
2107    fn index_url(raw: &str) -> PypiIndexUrl {
2108        PypiIndexUrl::new(raw, &all_policy()).unwrap()
2109    }
2110
2111    /// C4 fix: an alternate client's version fetch must hit the configured private host, not
2112    /// `pypi.org` — proven by asserting the `simple_base`-derived request lands on the mock
2113    /// server, not by absence-of-request on a `pypi.org` mock (which this crate's existing
2114    /// tests never contact anyway).
2115    #[tokio::test]
2116    async fn test_with_base_fetches_configured_host_not_pypi_org() {
2117        let mut server = mockito::Server::new_async().await;
2118        let mock = server
2119            .mock("GET", "/simple/flask/")
2120            .with_status(200)
2121            .with_body(r#"{"versions": ["3.0.0"], "files": []}"#)
2122            .create_async()
2123            .await;
2124
2125        let cache = Arc::new(HttpCache::new());
2126        cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
2127        let base = index_url(&format!("{}/simple", server.url()));
2128        let client = PypiRegistry::with_base(Arc::clone(&cache), &base, Vec::new());
2129
2130        let versions = client.get_versions("flask").await.unwrap();
2131        assert_eq!(versions.len(), 1);
2132        assert_eq!(versions[0].version.as_str(), "3.0.0");
2133        mock.assert_async().await;
2134    }
2135
2136    /// FR-005(a)/NFR-006: an explicit-primary chain resolves via hop 0 first — a package
2137    /// present there never reaches the extra.
2138    #[tokio::test]
2139    async fn test_get_versions_from_case_a_primary_wins() {
2140        use deps_core::PackageName;
2141
2142        let mut primary_server = mockito::Server::new_async().await;
2143        let primary_mock = primary_server
2144            .mock("GET", "/simple/pkg/")
2145            .with_status(200)
2146            .with_body(r#"{"versions": ["1.0.0"], "files": []}"#)
2147            .create_async()
2148            .await;
2149
2150        let mut extra_server = mockito::Server::new_async().await;
2151        let extra_mock = extra_server
2152            .mock("GET", "/simple/pkg/")
2153            .with_status(200)
2154            .with_body(r#"{"versions": ["9.9.9"], "files": []}"#)
2155            .expect(0)
2156            .create_async()
2157            .await;
2158
2159        let cache = Arc::new(HttpCache::new());
2160        cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
2161        let root = Arc::new(PypiRegistry::new(Arc::clone(&cache)));
2162
2163        let primary = index_url(&format!("{}/simple", primary_server.url()));
2164        let extra = index_url(&format!("{}/simple", extra_server.url()));
2165        let chain = crate::config::ResolvedChain {
2166            key: "test-chain-a".to_string(),
2167            hops: vec![primary, extra],
2168            implicit_public_fallback: false,
2169        };
2170        PypiRegistry::register_chain(&root, &chain);
2171
2172        let source = DependencySource::AlternateRegistry {
2173            index: chain.key.clone(),
2174            mirrors_crates_io: false,
2175        };
2176        let versions = deps_core::Registry::get_versions_from(
2177            root.as_ref(),
2178            &PackageName::new("pkg"),
2179            &source,
2180            deps_core::FreshnessSettings::default(),
2181        )
2182        .await
2183        .unwrap();
2184        assert_eq!(versions.len(), 1);
2185        assert_eq!(versions[0].version_string().as_str(), "1.0.0");
2186
2187        primary_mock.assert_async().await;
2188        extra_mock.assert_async().await;
2189    }
2190
2191    /// S5 failure taxonomy: hop 0 misses (`PackageNotFound`, a 404) -> falls through to hop 1.
2192    #[tokio::test]
2193    async fn test_get_versions_chained_falls_through_on_package_not_found() {
2194        let mut hop0_server = mockito::Server::new_async().await;
2195        let hop0_mock = hop0_server
2196            .mock("GET", "/simple/pkg/")
2197            .with_status(404)
2198            .create_async()
2199            .await;
2200
2201        let mut hop1_server = mockito::Server::new_async().await;
2202        let hop1_mock = hop1_server
2203            .mock("GET", "/simple/pkg/")
2204            .with_status(200)
2205            .with_body(r#"{"versions": ["2.0.0"], "files": []}"#)
2206            .create_async()
2207            .await;
2208
2209        let cache = Arc::new(HttpCache::new());
2210        cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
2211        let hop1 = Arc::new(PypiRegistry::with_base(
2212            Arc::clone(&cache),
2213            &index_url(&format!("{}/simple", hop1_server.url())),
2214            Vec::new(),
2215        ));
2216        let head = PypiRegistry::with_base(
2217            Arc::clone(&cache),
2218            &index_url(&format!("{}/simple", hop0_server.url())),
2219            vec![hop1],
2220        );
2221
2222        let versions = head.get_versions_chained("pkg").await.unwrap();
2223        assert_eq!(versions.len(), 1);
2224        assert_eq!(versions[0].version.as_str(), "2.0.0");
2225
2226        hop0_mock.assert_async().await;
2227        hop1_mock.assert_async().await;
2228    }
2229
2230    /// S5 failure taxonomy: hop 0 answers `200` with an empty listing (not a 404) -> treated
2231    /// identically to `PackageNotFound`, falls through.
2232    #[tokio::test]
2233    async fn test_get_versions_chained_falls_through_on_empty_listing() {
2234        let mut hop0_server = mockito::Server::new_async().await;
2235        let hop0_mock = hop0_server
2236            .mock("GET", "/simple/pkg/")
2237            .with_status(200)
2238            .with_body(r#"{"versions": [], "files": []}"#)
2239            .create_async()
2240            .await;
2241
2242        let mut hop1_server = mockito::Server::new_async().await;
2243        let hop1_mock = hop1_server
2244            .mock("GET", "/simple/pkg/")
2245            .with_status(200)
2246            .with_body(r#"{"versions": ["2.0.0"], "files": []}"#)
2247            .create_async()
2248            .await;
2249
2250        let cache = Arc::new(HttpCache::new());
2251        cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
2252        let hop1 = Arc::new(PypiRegistry::with_base(
2253            Arc::clone(&cache),
2254            &index_url(&format!("{}/simple", hop1_server.url())),
2255            Vec::new(),
2256        ));
2257        let head = PypiRegistry::with_base(
2258            Arc::clone(&cache),
2259            &index_url(&format!("{}/simple", hop0_server.url())),
2260            vec![hop1],
2261        );
2262
2263        let versions = head.get_versions_chained("pkg").await.unwrap();
2264        assert_eq!(versions.len(), 1);
2265        assert_eq!(versions[0].version.as_str(), "2.0.0");
2266
2267        hop0_mock.assert_async().await;
2268        hop1_mock.assert_async().await;
2269    }
2270
2271    /// N4/second critic pass: a genuine transport error (5xx) on hop 0 is terminal — the
2272    /// chain does **not** try hop 1, even though hop 1 would have succeeded. This is the
2273    /// case-(b) "unreachable declared extra halts resolution for the whole file" trade-off,
2274    /// exercised directly at the chain-walk level.
2275    #[tokio::test]
2276    async fn test_get_versions_chained_terminates_on_transport_error_never_tries_next_hop() {
2277        let mut hop0_server = mockito::Server::new_async().await;
2278        let hop0_mock = hop0_server
2279            .mock("GET", "/simple/pkg/")
2280            .with_status(503)
2281            .create_async()
2282            .await;
2283
2284        let mut hop1_server = mockito::Server::new_async().await;
2285        let hop1_mock = hop1_server
2286            .mock("GET", "/simple/pkg/")
2287            .with_status(200)
2288            .with_body(r#"{"versions": ["2.0.0"], "files": []}"#)
2289            .expect(0)
2290            .create_async()
2291            .await;
2292
2293        let cache = Arc::new(HttpCache::new());
2294        cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
2295        let hop1 = Arc::new(PypiRegistry::with_base(
2296            Arc::clone(&cache),
2297            &index_url(&format!("{}/simple", hop1_server.url())),
2298            Vec::new(),
2299        ));
2300        let head = PypiRegistry::with_base(
2301            Arc::clone(&cache),
2302            &index_url(&format!("{}/simple", hop0_server.url())),
2303            vec![hop1],
2304        );
2305
2306        let err = head.get_versions_chained("pkg").await.unwrap_err();
2307        // M2 fix: a terminal transport error is reported as `ChainResolutionHalted` — not
2308        // `PackageNotFound` (which would wrongly trigger "continue to next hop" logic
2309        // anywhere else this error might be inspected), and not the raw underlying transport
2310        // error either (which `DepsError::fetch_failure` cannot safely classify as
2311        // `Actionable` — see `ChainResolutionHalted`'s own doc).
2312        assert!(
2313            matches!(err, DepsError::ChainResolutionHalted),
2314            "expected ChainResolutionHalted, got: {err:?}"
2315        );
2316        // NFR-003(3): this must reach hover/diagnostics as a distinguishable, safe hint via
2317        // deps-core's established `fetch_failure` -> `Actionable` mechanism, not stay
2318        // log-only.
2319        assert_eq!(
2320            err.fetch_failure(),
2321            deps_core::error::FetchFailure::Actionable(
2322                "index unreachable — resolution halted, not falling back to a less-trusted \
2323                 index"
2324                    .to_string()
2325            )
2326        );
2327
2328        hop0_mock.assert_async().await;
2329        hop1_mock.assert_async().await;
2330    }
2331
2332    /// NFR-003(3): the terminal-transport-error path logs a distinguishable diagnostic
2333    /// naming the halted-chain behavior, not a generic fetch-failed message.
2334    #[tokio::test]
2335    async fn test_transport_error_logs_distinguishable_diagnostic() {
2336        let mut hop0_server = mockito::Server::new_async().await;
2337        let hop0_mock = hop0_server
2338            .mock("GET", "/simple/pkg/")
2339            .with_status(503)
2340            .create_async()
2341            .await;
2342
2343        let cache = Arc::new(HttpCache::new());
2344        cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
2345        let head = PypiRegistry::with_base(
2346            Arc::clone(&cache),
2347            &index_url(&format!("{}/simple", hop0_server.url())),
2348            Vec::new(),
2349        );
2350
2351        let log = deps_core::test_util::capture_tracing_output_async(async {
2352            head.get_versions_chained("pkg").await.unwrap_err();
2353        })
2354        .await;
2355        assert!(
2356            log.contains("not falling back to pypi.org"),
2357            "expected a distinguishable halted-chain diagnostic, got: {log:?}"
2358        );
2359
2360        hop0_mock.assert_async().await;
2361    }
2362
2363    /// Zero-hop `ResolvedChain::hops` (defensive — `PypiIndexConfig` never actually produces
2364    /// one) is a no-op registration, not a panic.
2365    #[test]
2366    fn test_register_chain_empty_hops_is_noop() {
2367        let cache = Arc::new(HttpCache::new());
2368        let root = Arc::new(PypiRegistry::new(cache));
2369        let chain = crate::config::ResolvedChain {
2370            key: "empty".to_string(),
2371            hops: Vec::new(),
2372            implicit_public_fallback: false,
2373        };
2374        PypiRegistry::register_chain(&root, &chain);
2375        assert!(root.alternate_client("empty").is_none());
2376    }
2377
2378    /// `register_chain` is idempotent per key — a second registration for the same key is a
2379    /// no-op (mirrors `deps-npm::NpmRegistry::register_alternate`'s identical guarantee).
2380    #[test]
2381    fn test_register_chain_idempotent() {
2382        let cache = Arc::new(HttpCache::new());
2383        let root = Arc::new(PypiRegistry::new(cache));
2384        let chain = crate::config::ResolvedChain {
2385            key: "dup".to_string(),
2386            hops: vec![index_url("https://a.example/simple")],
2387            implicit_public_fallback: false,
2388        };
2389        PypiRegistry::register_chain(&root, &chain);
2390        let first = root.alternate_client("dup").unwrap();
2391        PypiRegistry::register_chain(&root, &chain);
2392        let second = root.alternate_client("dup").unwrap();
2393        assert!(Arc::ptr_eq(&first, &second));
2394    }
2395
2396    /// `MAX_ALTERNATE_REGISTRIES` cap: once reached, a new chain is not registered (degrades
2397    /// to `PackageNotFound` at fetch time, never a silent public fallback).
2398    #[test]
2399    fn test_register_chain_capacity_cap() {
2400        let cache = Arc::new(HttpCache::new());
2401        let root = Arc::new(PypiRegistry::new(cache));
2402        for i in 0..MAX_ALTERNATE_REGISTRIES {
2403            let chain = crate::config::ResolvedChain {
2404                key: format!("chain-{i}"),
2405                hops: vec![index_url("https://a.example/simple")],
2406                implicit_public_fallback: false,
2407            };
2408            PypiRegistry::register_chain(&root, &chain);
2409        }
2410        let overflow = crate::config::ResolvedChain {
2411            key: "overflow".to_string(),
2412            hops: vec![index_url("https://b.example/simple")],
2413            implicit_public_fallback: false,
2414        };
2415        PypiRegistry::register_chain(&root, &overflow);
2416        assert!(root.alternate_client("overflow").is_none());
2417    }
2418
2419    /// The C1 invariant: a chain-hop leaf's own `alternates` map is always empty — calling
2420    /// `alternate_client` on a non-root client returns `None` for everything, documented as
2421    /// intentional (T006), not a bug.
2422    #[test]
2423    fn test_alternate_client_only_meaningful_on_root() {
2424        let cache = Arc::new(HttpCache::new());
2425        let root = Arc::new(PypiRegistry::new(Arc::clone(&cache)));
2426        let chain = crate::config::ResolvedChain {
2427            key: "chain".to_string(),
2428            hops: vec![index_url("https://a.example/simple")],
2429            implicit_public_fallback: false,
2430        };
2431        PypiRegistry::register_chain(&root, &chain);
2432        let head = root.alternate_client("chain").unwrap();
2433        assert!(head.alternate_client("chain").is_none());
2434    }
2435
2436    /// N1's fix: the implicit-public final hop is a fresh `Public`-tier leaf, not
2437    /// `Arc::clone(&root)` — dropping the root (and every other strong reference) after
2438    /// registering an implicit-fallback chain must actually deallocate it, proving there is
2439    /// no root->alternates->head->fallback_chain->root reference cycle.
2440    #[test]
2441    fn test_implicit_public_hop_does_not_create_reference_cycle() {
2442        let cache = Arc::new(HttpCache::new());
2443        let root = Arc::new(PypiRegistry::new(Arc::clone(&cache)));
2444        let root_weak = Arc::downgrade(&root);
2445
2446        let chain = crate::config::ResolvedChain {
2447            key: "implicit".to_string(),
2448            hops: vec![index_url("https://a.example/simple")],
2449            implicit_public_fallback: true,
2450        };
2451        PypiRegistry::register_chain(&root, &chain);
2452
2453        drop(root);
2454        assert!(
2455            root_weak.upgrade().is_none(),
2456            "root must deallocate once its only strong reference is dropped — a cycle would \
2457             keep it alive"
2458        );
2459    }
2460
2461    /// FR-005(b)/N1: `register_chain` with `implicit_public_fallback: true` appends a
2462    /// freshly-constructed `Public`-tier leaf (same URL/transport as `pypi.org`) as the
2463    /// chain's final hop — verified structurally rather than by dispatching a live
2464    /// `get_versions_from` call, since walking off the end of this chain would otherwise
2465    /// contact the real `pypi.org` from a unit test.
2466    #[test]
2467    fn test_register_chain_implicit_public_fallback_hop_shape() {
2468        let cache = Arc::new(HttpCache::new());
2469        cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
2470        let root = Arc::new(PypiRegistry::new(Arc::clone(&cache)));
2471
2472        let extra = index_url("https://extra.example/simple");
2473        let chain = crate::config::ResolvedChain {
2474            key: "case-b".to_string(),
2475            hops: vec![extra],
2476            implicit_public_fallback: true,
2477        };
2478        PypiRegistry::register_chain(&root, &chain);
2479
2480        let head = root.alternate_client("case-b").unwrap();
2481        assert_eq!(head.simple_base, "https://extra.example/simple");
2482        assert_eq!(head.fallback_chain.len(), 1);
2483        assert_eq!(head.fallback_chain[0].tier, PypiRegistryTier::Public);
2484        assert_eq!(head.fallback_chain[0].simple_base, PYPI_SIMPLE_BASE);
2485    }
2486
2487    /// T012's explicit "must" criterion (validator finding #9), now actually testable via
2488    /// `Self::with_public_base_for_test`: FR-005(b) — a package present on **both** a
2489    /// declared extra and the (mocked) implicit public fallback resolves via the extra, and
2490    /// the public mock is **never contacted** for that name. `expect(0)` on the public mock
2491    /// makes this a hard request-count assertion, not just a check of the final result.
2492    #[tokio::test]
2493    async fn test_case_b_extra_wins_over_implicit_public_request_count_asserted() {
2494        use deps_core::PackageName;
2495
2496        let mut extra_server = mockito::Server::new_async().await;
2497        let extra_mock = extra_server
2498            .mock("GET", "/simple/mypkg/")
2499            .with_status(200)
2500            .with_body(r#"{"versions": ["1.0.0"], "files": []}"#)
2501            .expect(1)
2502            .create_async()
2503            .await;
2504
2505        let mut public_server = mockito::Server::new_async().await;
2506        let public_mock = public_server
2507            .mock("GET", mockito::Matcher::Any)
2508            .expect(0)
2509            .create_async()
2510            .await;
2511
2512        let cache = Arc::new(HttpCache::new());
2513        cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
2514        let root = Arc::new(PypiRegistry::with_public_base_for_test(
2515            Arc::clone(&cache),
2516            format!("{}/simple", public_server.url()),
2517        ));
2518
2519        let extra = index_url(&format!("{}/simple", extra_server.url()));
2520        let chain = crate::config::ResolvedChain {
2521            key: "case-b-request-count".to_string(),
2522            hops: vec![extra],
2523            implicit_public_fallback: true,
2524        };
2525        PypiRegistry::register_chain(&root, &chain);
2526
2527        let source = DependencySource::AlternateRegistry {
2528            index: chain.key.clone(),
2529            mirrors_crates_io: false,
2530        };
2531        let versions = deps_core::Registry::get_versions_from(
2532            root.as_ref(),
2533            &PackageName::new("mypkg"),
2534            &source,
2535            deps_core::FreshnessSettings::default(),
2536        )
2537        .await
2538        .unwrap();
2539        assert_eq!(versions.len(), 1);
2540
2541        extra_mock.assert_async().await;
2542        public_mock.assert_async().await;
2543    }
2544
2545    /// The mirror scenario: the extra misses (404), so the chain correctly falls through to
2546    /// the (mocked) implicit public fallback — proving the ordering is "extra first, public
2547    /// last", not "public only" or "extra only".
2548    #[tokio::test]
2549    async fn test_case_b_falls_through_to_implicit_public_when_extra_misses() {
2550        use deps_core::PackageName;
2551
2552        let mut extra_server = mockito::Server::new_async().await;
2553        let extra_mock = extra_server
2554            .mock("GET", "/simple/mypkg/")
2555            .with_status(404)
2556            .create_async()
2557            .await;
2558
2559        let mut public_server = mockito::Server::new_async().await;
2560        let public_mock = public_server
2561            .mock("GET", "/simple/mypkg/")
2562            .with_status(200)
2563            .with_body(r#"{"versions": ["9.9.9"], "files": []}"#)
2564            .create_async()
2565            .await;
2566
2567        let cache = Arc::new(HttpCache::new());
2568        cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
2569        let root = Arc::new(PypiRegistry::with_public_base_for_test(
2570            Arc::clone(&cache),
2571            format!("{}/simple", public_server.url()),
2572        ));
2573
2574        let extra = index_url(&format!("{}/simple", extra_server.url()));
2575        let chain = crate::config::ResolvedChain {
2576            key: "case-b-fallthrough".to_string(),
2577            hops: vec![extra],
2578            implicit_public_fallback: true,
2579        };
2580        PypiRegistry::register_chain(&root, &chain);
2581
2582        let source = DependencySource::AlternateRegistry {
2583            index: chain.key.clone(),
2584            mirrors_crates_io: false,
2585        };
2586        let versions = deps_core::Registry::get_versions_from(
2587            root.as_ref(),
2588            &PackageName::new("mypkg"),
2589            &source,
2590            deps_core::FreshnessSettings::default(),
2591        )
2592        .await
2593        .unwrap();
2594        assert_eq!(versions.len(), 1);
2595        assert_eq!(versions[0].version_string().as_str(), "9.9.9");
2596
2597        extra_mock.assert_async().await;
2598        public_mock.assert_async().await;
2599    }
2600
2601    /// Validator finding #10: a happy-path test for `get_latest_matching_from` — only the
2602    /// unregistered-alternate failure case was previously tested. Derived from
2603    /// `get_versions_chained` (M4, no independent chain walk), so this also confirms that
2604    /// path picks the right version out of the winning hop's list.
2605    #[tokio::test]
2606    async fn test_get_latest_matching_from_alternate_registry_happy_path() {
2607        use deps_core::PackageName;
2608
2609        let mut server = mockito::Server::new_async().await;
2610        let mock = server
2611            .mock("GET", "/simple/pkg/")
2612            .with_status(200)
2613            .with_body(r#"{"versions": ["1.0.0", "1.5.0", "2.0.0"], "files": []}"#)
2614            .create_async()
2615            .await;
2616
2617        let cache = Arc::new(HttpCache::new());
2618        cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
2619        let root = Arc::new(PypiRegistry::new(Arc::clone(&cache)));
2620
2621        let chain = crate::config::ResolvedChain {
2622            key: "latest-matching-happy-path".to_string(),
2623            hops: vec![index_url(&format!("{}/simple", server.url()))],
2624            implicit_public_fallback: false,
2625        };
2626        PypiRegistry::register_chain(&root, &chain);
2627
2628        let source = DependencySource::AlternateRegistry {
2629            index: chain.key.clone(),
2630            mirrors_crates_io: false,
2631        };
2632        let latest = deps_core::Registry::get_latest_matching_from(
2633            root.as_ref(),
2634            &PackageName::new("pkg"),
2635            &source,
2636            &deps_core::VersionReq::new(">=1.0.0,<2.0.0"),
2637            None,
2638        )
2639        .await
2640        .unwrap();
2641        assert_eq!(
2642            latest.map(|v| v.version_string().to_string()),
2643            Some("1.5.0".to_string())
2644        );
2645        mock.assert_async().await;
2646    }
2647
2648    /// Validator finding #11: a 3+-hop chain — every prior test caps at 2 hops. Confirms
2649    /// `get_versions_chained` correctly walks past a second miss to reach a third, winning
2650    /// hop, and that both earlier hops were actually queried in order (not skipped).
2651    #[tokio::test]
2652    async fn test_three_hop_chain_falls_through_to_third_hop() {
2653        let mut hop0_server = mockito::Server::new_async().await;
2654        let hop0_mock = hop0_server
2655            .mock("GET", "/simple/pkg/")
2656            .with_status(404)
2657            .create_async()
2658            .await;
2659
2660        let mut hop1_server = mockito::Server::new_async().await;
2661        let hop1_mock = hop1_server
2662            .mock("GET", "/simple/pkg/")
2663            .with_status(200)
2664            .with_body(r#"{"versions": [], "files": []}"#)
2665            .create_async()
2666            .await;
2667
2668        let mut hop2_server = mockito::Server::new_async().await;
2669        let hop2_mock = hop2_server
2670            .mock("GET", "/simple/pkg/")
2671            .with_status(200)
2672            .with_body(r#"{"versions": ["3.0.0"], "files": []}"#)
2673            .create_async()
2674            .await;
2675
2676        let cache = Arc::new(HttpCache::new());
2677        cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
2678        // `fallback_chain` is a flat list of every hop after hop 0, all direct children of
2679        // the head — never nested per-hop (that's how `register_chain` actually builds it;
2680        // `get_versions_chained` only ever walks `self.fallback_chain` one level deep, not
2681        // recursively).
2682        let hop1 = Arc::new(PypiRegistry::with_base(
2683            Arc::clone(&cache),
2684            &index_url(&format!("{}/simple", hop1_server.url())),
2685            Vec::new(),
2686        ));
2687        let hop2 = Arc::new(PypiRegistry::with_base(
2688            Arc::clone(&cache),
2689            &index_url(&format!("{}/simple", hop2_server.url())),
2690            Vec::new(),
2691        ));
2692        let head = PypiRegistry::with_base(
2693            Arc::clone(&cache),
2694            &index_url(&format!("{}/simple", hop0_server.url())),
2695            vec![hop1, hop2],
2696        );
2697
2698        let versions = head.get_versions_chained("pkg").await.unwrap();
2699        assert_eq!(versions.len(), 1);
2700        assert_eq!(versions[0].version.as_str(), "3.0.0");
2701
2702        hop0_mock.assert_async().await;
2703        hop1_mock.assert_async().await;
2704        hop2_mock.assert_async().await;
2705    }
2706
2707    /// `AlternateRegistry` with no registered client -> `PackageNotFound`, never a public
2708    /// fallback (FR-010).
2709    #[tokio::test]
2710    async fn test_get_versions_from_unregistered_alternate_never_falls_back() {
2711        use deps_core::PackageName;
2712
2713        let cache = Arc::new(HttpCache::new());
2714        let registry = PypiRegistry::new(cache);
2715        let source = DependencySource::AlternateRegistry {
2716            index: "pypi-chain:never-registered".to_string(),
2717            mirrors_crates_io: false,
2718        };
2719        let result = deps_core::Registry::get_versions_from(
2720            &registry,
2721            &PackageName::new("pkg"),
2722            &source,
2723            deps_core::FreshnessSettings::default(),
2724        )
2725        .await;
2726        assert_matches!(result.err(), Some(DepsError::PackageNotFound { .. }));
2727
2728        let result = deps_core::Registry::get_latest_matching_from(
2729            &registry,
2730            &PackageName::new("pkg"),
2731            &source,
2732            &deps_core::VersionReq::new("*"),
2733            None,
2734        )
2735        .await;
2736        assert_matches!(result.err(), Some(DepsError::PackageNotFound { .. }));
2737    }
2738
2739    // --- T008: tier guard on search/warm_search_index/get_package_metadata ---
2740
2741    #[tokio::test]
2742    async fn test_search_on_workspace_declared_tier_issues_no_request() {
2743        let mut server = mockito::Server::new_async().await;
2744        let mock = server
2745            .mock("GET", mockito::Matcher::Any)
2746            .expect(0)
2747            .create_async()
2748            .await;
2749
2750        let cache = Arc::new(HttpCache::new());
2751        cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
2752        let base = index_url(&format!("{}/simple", server.url()));
2753        let client = PypiRegistry::with_base(Arc::clone(&cache), &base, Vec::new());
2754
2755        assert!(client.search("flask", 10).await.unwrap().is_empty());
2756        mock.assert_async().await;
2757    }
2758
2759    #[tokio::test]
2760    async fn test_warm_search_index_on_workspace_declared_tier_issues_no_request() {
2761        let mut server = mockito::Server::new_async().await;
2762        let mock = server
2763            .mock("GET", mockito::Matcher::Any)
2764            .expect(0)
2765            .create_async()
2766            .await;
2767
2768        let cache = Arc::new(HttpCache::new());
2769        cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
2770        let base = index_url(&format!("{}/simple", server.url()));
2771        let client = PypiRegistry::with_base(Arc::clone(&cache), &base, Vec::new());
2772
2773        client.warm_search_index();
2774        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2775        mock.assert_async().await;
2776    }
2777
2778    #[tokio::test]
2779    async fn test_get_package_metadata_on_workspace_declared_tier_issues_no_request() {
2780        let mut server = mockito::Server::new_async().await;
2781        let mock = server
2782            .mock("GET", mockito::Matcher::Any)
2783            .expect(0)
2784            .create_async()
2785            .await;
2786
2787        let cache = Arc::new(HttpCache::new());
2788        cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
2789        let base = index_url(&format!("{}/simple", server.url()));
2790        let client = PypiRegistry::with_base(Arc::clone(&cache), &base, Vec::new());
2791
2792        let err = client.get_package_metadata("flask").await.unwrap_err();
2793        assert_matches!(err, DepsError::PackageNotFound { .. });
2794        mock.assert_async().await;
2795    }
2796}