Skip to main content

deps_maven/
registry.rs

1//! Maven Central registry client.
2//!
3//! Uses `maven-metadata.xml` from Maven Central CDN for version fetching
4//! (fast, CDN-cached) and Solr search API for package search (full-text).
5
6use crate::types::{ArtifactInfo, MavenVersion};
7use crate::version::compare_versions;
8use bytes::Bytes;
9use dashmap::DashMap;
10use deps_core::{
11    DepsError, HttpCache, PublishTime, Result, is_safe_maven_coordinate_segment,
12    lsp_helpers::warn_rejected_value,
13};
14use quick_xml::events::Event;
15use quick_xml::reader::Reader;
16use serde::Deserialize;
17use std::any::Any;
18use std::collections::HashMap;
19use std::sync::Arc;
20use std::time::Duration;
21
22const MAVEN_REPO_BASE: &str = "https://repo1.maven.org/maven2";
23
24/// Display name for Maven Central used in not-found and API-response error
25/// messages. Reused by `deps-gradle`, which resolves through this registry.
26pub const REGISTRY: &str = "Maven Central";
27const GOOGLE_MAVEN_BASE: &str = "https://dl.google.com/dl/android/maven2";
28const GRADLE_PLUGIN_PORTAL_BASE: &str = "https://plugins.gradle.org/m2";
29const MAVEN_SEARCH_BASE: &str = "https://search.maven.org/solrsearch/select";
30
31/// Per-attempt timeouts for `search_typed`'s retry loop: first attempt, then second.
32///
33/// `search.maven.org/solrsearch` fails intermittently as a silent, zero-byte hang
34/// rather than a clean HTTP error — live-verified 2026-08-24 (#274): identical
35/// back-to-back requests for the same popular query (`guava`, `spring`) alternate
36/// between a fast success and a full hang, with no correlation to query content that
37/// would make the failure predictable or avoidable by query shape alone; failures also
38/// arrive in multi-second correlated bursts, not independently per request.
39///
40/// The second attempt gets a larger timeout than the first: a cold TCP+TLS handshake
41/// on a higher-latency link can push a genuinely healthy response past a tight budget
42/// (live-verified successful response time: ~0.4s on a low-latency link vs. an
43/// estimated ~1.0s on a 150ms-RTT one), and `tokio::time::timeout` cancels the first
44/// attempt's connection without returning it to `reqwest`'s pool, so the second attempt
45/// always pays the handshake cold again and needs the extra headroom.
46///
47/// Their sum, plus [`SEARCH_RETRY_DELAY`], is deliberately larger than
48/// `deps_core::completion::COMPLETION_SEARCH_TIMEOUT` — enforced by
49/// `test_search_attempt_budget_exceeds_completion_search_timeout` below. On a total
50/// failure with no stale cache to serve (see `search_with_retry`'s `stale` fallback),
51/// `search_typed` must not finish before the caller's own timeout does: `deps-lsp`'s
52/// completion handler otherwise cannot tell a fast empty/error result apart from
53/// "genuinely no results," and re-runs a wasted fallback search against the same
54/// struggling registry instead of taking its existing skip-fallback path.
55///
56/// This guarantee only covers `solrsearch`'s dominant *hang* failure mode, where both
57/// attempts run their full timeout. A fast-failing, retryable error (e.g. connection
58/// refused, DNS failure) still exhausts both attempts and returns well inside the 2s
59/// deadline — the handler's `Ok(empty)` branch still runs the fallback search for that
60/// failure mode, same as before this fix. Not a regression, but worth noting since M1's
61/// 4xx-is-terminal guard means that failure mode is the one most likely to return fast.
62const SEARCH_ATTEMPT_TIMEOUTS: [Duration; 2] =
63    [Duration::from_millis(1000), Duration::from_millis(1200)];
64
65/// Delay between `search_typed` retry attempts.
66const SEARCH_RETRY_DELAY: Duration = Duration::from_millis(100);
67
68/// `rows` value used for every `solrsearch` request, regardless of the caller's
69/// requested `limit` (#282 gap 2).
70///
71/// `search_typed`'s caller-facing result count is still capped at `limit` by
72/// `parse_search_response`'s `.take(limit)` — this only fixes the *request URL*,
73/// which doubles as `HttpCache`'s cache key. `deps-lsp`'s completion handler calls
74/// `search_typed` with `limit=20` for its primary (typed-field) search and `limit=50`
75/// for its fallback search; before this constant, those two calls built different
76/// URLs (`rows=20` vs `rows=50`) and so never shared a cache entry, making
77/// `HttpCache::peek_cached`'s stale-fallback (see `search_with_retry`) always miss on
78/// the fallback path even when a same-query result was already cached moments earlier
79/// by the primary path. Set to the largest `limit` any caller passes, so a smaller
80/// request is always answerable from the same cache entry as a larger one.
81const SEARCH_CACHE_ROWS: usize = 50;
82
83/// TTL for `MavenCentralRegistry::recent_search_failures` (#282 gap 1).
84///
85/// A completion request's own fallback search (`deps-lsp`'s `completion.rs`, once its
86/// extracted query matches the primary path's — see `extract_prefix`'s XML-tag
87/// stripping) issues a second `search_typed` call for the same query within the same
88/// request, well inside `COMPLETION_SEARCH_TIMEOUT`, whenever the first call fails
89/// *fast* (DNS failure, connection refused) rather than hanging —
90/// `SEARCH_ATTEMPT_TIMEOUTS`'s hang-mode guarantee (see its doc) only protects against
91/// the hang case, not this one. Reusing `COMPLETION_SEARCH_TIMEOUT` as the TTL is sized
92/// to comfortably span that in-request duplicate-call window without also suppressing a
93/// legitimate retry on a later, unrelated completion request for the same prefix.
94///
95/// By design, this memo is never populated for the hang failure mode itself: a hang
96/// exhausts `SEARCH_ATTEMPT_TIMEOUTS`' full budget, which `deps-lsp`'s own outer
97/// `COMPLETION_SEARCH_TIMEOUT` deadline always wins against first (see that constant's
98/// doc) — the whole `search_typed` future, including the code that would record a
99/// failure here, is cancelled before it can run. That's fine: the hang case is already
100/// handled by the caller's existing skip-fallback path, so no second call happens for
101/// this memo to prevent.
102const RECENT_FAILURE_TTL: Duration = deps_core::completion::COMPLETION_SEARCH_TIMEOUT;
103
104const GOOGLE_PREFIXES: &[&str] = &[
105    "androidx.",
106    "com.google.firebase.",
107    "com.google.android.",
108    "com.google.gms.",
109    "com.android.",
110];
111
112fn is_google_group(group_id: &str) -> bool {
113    GOOGLE_PREFIXES.iter().any(|p| group_id.starts_with(p))
114}
115
116fn repo_base_for_group(group_id: &str) -> &'static str {
117    if is_google_group(group_id) {
118        GOOGLE_MAVEN_BASE
119    } else {
120        MAVEN_REPO_BASE
121    }
122}
123
124/// Returns the URL for a coordinate's page on Maven Central (or maven.google.com for a
125/// Google-group coordinate), falling back to a Central search query when `name` is not a
126/// `groupId:artifactId` pair.
127///
128/// Display link only, never fetched by this process — unlike `metadata_urls` (a fetch
129/// sink), so it is deliberately not gated against a `.`/`..` segment (see
130/// [`deps_core::is_dot_segment`]'s doc for the fetch-sink-vs-display-link scope split, #379).
131pub fn package_url(name: &str) -> String {
132    let parts: Vec<&str> = name.splitn(2, ':').collect();
133    if parts.len() == 2 {
134        let group_id = parts[0];
135        let artifact_id = parts[1];
136        if is_google_group(group_id) {
137            format!(
138                "https://maven.google.com/web/index.html#{}:{}",
139                urlencoding::encode(group_id),
140                urlencoding::encode(artifact_id)
141            )
142        } else {
143            format!(
144                "https://central.sonatype.com/artifact/{}/{}",
145                urlencoding::encode(group_id),
146                urlencoding::encode(artifact_id)
147            )
148        }
149    } else {
150        format!(
151            "https://central.sonatype.com/search?q={}",
152            urlencoding::encode(name)
153        )
154    }
155}
156
157/// Moves the entry that should be considered "latest" to the front of `versions`, in place:
158///
159/// - If `release` (maven-metadata.xml's `<release>` element — the authoritative "last
160///   deployed" version, which is not necessarily the qualifier-sorted top entry: it can
161///   legitimately be a milestone/RC that was the most recent deploy) names an entry present
162///   in `versions`, that entry moves to the front. A `release` naming something absent from
163///   `versions` (malformed/inconsistent metadata) leaves the list untouched — there's
164///   nothing in it to move, and nothing can be synthesized without violating the "index into
165///   an existing slice" contract `select_latest_matching` needs.
166/// - If `release` is absent (`<release>` missing from the metadata entirely, common for
167///   Gradle Plugin Portal and older artifacts), the first non-prerelease entry moves to the
168///   front instead — reproducing `get_latest_matching_typed`'s pre-existing else-branch, so
169///   an artifact without `<release>` doesn't report a prerelease as "latest" just because it
170///   happens to sort first (S7: this was the actual bug behind an earlier version of this
171///   fix, which only handled the `release`-present case).
172///
173/// This lets every consumer of the (already sorted) list — `select_latest_matching`'s
174/// pure `Some(0)` pick (`Registry::get_versions` is the only round trip available to it,
175/// with no side channel for `<release>`), hover's "Recent versions" `*(latest)*` marker,
176/// and completion's version list — agree with the wildcard pick without a second registry
177/// call: `get_versions_typed` and `get_latest_matching_typed` already fetch
178/// `(versions, release)` from the same single `get_metadata` call, so reordering here is free.
179fn move_release_to_front(versions: &mut Vec<MavenVersion>, release: Option<&str>) {
180    let target = match release {
181        Some(release) => versions.iter().position(|v| v.version == release),
182        None => versions
183            .iter()
184            .position(|v| !crate::version::is_prerelease(v.version.as_str())),
185    };
186    let Some(pos) = target else { return };
187    if pos != 0 {
188        let entry = versions.remove(pos);
189        versions.insert(0, entry);
190    }
191}
192
193/// Picks the "latest" version for a wildcard (`*`/empty) requirement from already-fetched
194/// `(versions, release)` metadata: prefers the `<release>`-designated entry — synthesizing a
195/// placeholder `MavenVersion` if `release` names something absent from `versions`, since
196/// `<release>` is still authoritative even when the metadata is otherwise inconsistent,
197/// *unless* `release` itself is a prerelease (#340 edge case — see below) — else the first
198/// non-prerelease entry, else the first entry.
199///
200/// Shares its release-present/absent decision shape with [`move_release_to_front`], but
201/// differs in the one case that function structurally cannot handle: `release` naming an
202/// entry absent from `versions`. `move_release_to_front` must return an index into the
203/// existing slice (or no-op), so it can't invent an entry; this function returns an owned
204/// `MavenVersion` and has no such constraint, so it trusts `release` unconditionally instead
205/// — the same asymmetry `get_latest_matching_typed` had before this extraction. The one
206/// exception: when `release` is absent from `versions` AND is itself a prerelease, this
207/// returns `None` rather than synthesizing it. This is the sole path through which this
208/// function is reachable in production (`Registry::get_latest_matching`'s only caller,
209/// `deps-lsp`'s bulk fetch loop, only falls back to it when
210/// `Registry::select_latest_matching`'s wildcard branch returned `None`, which for Maven
211/// only happens when `versions` is empty) — so without this guard, a
212/// `<release>`-names-a-prerelease-absent-from-an-empty-`<versions>`-list metadata shape
213/// (malformed but real: `parse_metadata_xml` parses `<release>` and `<versions>`
214/// independently) would reproduce #340 through this one narrow, otherwise-unscoped corner.
215/// Trade-off, not a free fix: `None` here surfaces as diagnostics' "Unknown package" for
216/// this narrow malformed shape — the inverse of #338's general "don't report a resolvable
217/// package as unknown" principle. Accepted deliberately (returning `None` rather than
218/// inventing a version is the safer failure mode for metadata this inconsistent), not
219/// something this function's normal contract silently absorbs.
220fn pick_wildcard_latest(versions: &[MavenVersion], release: Option<&str>) -> Option<MavenVersion> {
221    if let Some(rel) = release {
222        if let Some(found) = versions.iter().find(|v| v.version == rel) {
223            return Some(found.clone());
224        }
225        if crate::version::is_prerelease(rel) {
226            return None;
227        }
228        return Some(MavenVersion {
229            version: rel.into(),
230            published_at: None,
231        });
232    }
233    versions
234        .iter()
235        .find(|v| !crate::version::is_prerelease(v.version.as_str()))
236        .or_else(|| versions.first())
237        .cloned()
238}
239
240/// Whether `base` (a metadata directory URL returned by `get_metadata`) is served by
241/// Maven Central specifically, i.e. whether fetching its directory listing is worth the
242/// request.
243///
244/// Google Maven's listing always 404s (no negative caching in [`HttpCache`], so an
245/// unconditional fetch would retry forever) and the Gradle Plugin Portal's listing has no
246/// date column (a wasted fetch+parse every time) — both must cost zero extra requests, not
247/// one doomed one, so this checks the specific winning base rather than "some base exists".
248fn should_fetch_listing(base: &str) -> bool {
249    base.starts_with(MAVEN_REPO_BASE)
250}
251
252/// Attaches `published_at` to each version whose string matches an entry in `times`.
253///
254/// A version present in `versions` but absent from `times` (or vice versa) is not an
255/// error: it simply keeps/never gets a `published_at`. Order is untouched — this must run
256/// before [`move_release_to_front`] so ordering stays governed by that function alone.
257fn attach_publish_times(versions: &mut [MavenVersion], times: &HashMap<String, PublishTime>) {
258    for v in versions {
259        v.published_at = times.get(v.version.as_str()).copied();
260    }
261}
262
263#[derive(Clone)]
264pub struct MavenCentralRegistry {
265    cache: Arc<HttpCache>,
266    /// Query -> instant of its last unrecoverable live search failure (#282 gap 1).
267    ///
268    /// Checked at the top of `search_typed`: a repeat call for the same query within
269    /// `RECENT_FAILURE_TTL` returns immediately without a network attempt. A `DashMap`
270    /// (like `HttpCache::entries`) rather than a `Mutex<HashMap<_>>`, so concurrent
271    /// completion requests for different queries don't contend on one lock and a panic
272    /// while holding a shard can't poison the whole map. Wrapped in `Arc` (`DashMap`
273    /// itself clones its contents, not a shared handle) so every `Clone` of this
274    /// registry shares one map, matching `cache`'s sharing semantics.
275    recent_search_failures: Arc<DashMap<String, tokio::time::Instant>>,
276}
277
278impl MavenCentralRegistry {
279    pub fn new(cache: Arc<HttpCache>) -> Self {
280        Self {
281            cache,
282            recent_search_failures: Arc::new(DashMap::new()),
283        }
284    }
285
286    /// Fetches and parses `maven-metadata.xml`, also returning the directory URL of
287    /// whichever repository base (Maven Central, Google Maven, or the Gradle Plugin
288    /// Portal fallback) actually served it — `metadata_urls`' bases differ per group, so
289    /// the winning base can only be known after the fetch succeeds, not guessed upfront.
290    async fn get_metadata(
291        &self,
292        name: &str,
293    ) -> Result<(Vec<MavenVersion>, Option<String>, Option<String>)> {
294        let urls = metadata_urls(name)?;
295        if urls.is_empty() {
296            tracing::debug!(package = %name, "skipping: invalid groupId:artifactId format");
297            return Ok((vec![], None, None));
298        }
299
300        let mut last_err = None;
301        for url in &urls {
302            match self.cache.get_cached(url).await {
303                Ok(data) => {
304                    let (versions, release) = parse_metadata_xml(&data)?;
305                    let base = url.strip_suffix("maven-metadata.xml").map(str::to_string);
306                    return Ok((versions, release, base));
307                }
308                Err(e) => {
309                    tracing::debug!(package = %name, url = %url, error = %e, "metadata fetch failed, trying next");
310                    last_err = Some(e);
311                }
312            }
313        }
314
315        let e = last_err.expect("urls is non-empty");
316        tracing::warn!(package = %name, error = %e, "all metadata URLs failed");
317        Err(e)
318    }
319
320    /// Fetches the directory listing at `base` (a Maven Central artifact directory URL,
321    /// trailing slash) and returns the version → publish-time map parsed from it.
322    ///
323    /// Never fails the caller: any fetch error, timeout, or unparseable body degrades to
324    /// an empty map, logged at `debug`, so a listing outage never affects the version list
325    /// itself — only whether ages are shown alongside it.
326    async fn fetch_publish_times(&self, base: &str) -> HashMap<String, PublishTime> {
327        match self.cache.get_cached(base).await {
328            Ok(data) => parse_publish_times(&data),
329            Err(e) => {
330                tracing::debug!(url = %base, error = %e, "listing fetch failed, publish times unavailable");
331                HashMap::new()
332            }
333        }
334    }
335
336    /// Same as [`Self::get_versions_typed`], but attaches [`MavenVersion::published_at`]
337    /// from the `repo1.maven.org` directory listing when `freshness_enabled` and the
338    /// artifact resolved through Maven Central.
339    ///
340    /// The listing fetch is gated on the winning base being Maven Central specifically —
341    /// not merely present — because Google Maven's listing always 404s (no negative
342    /// caching in [`HttpCache`], so an unconditional fetch would retry forever) and the
343    /// Gradle Plugin Portal's listing has no date column (a wasted fetch+parse on every
344    /// call). Both degrade to zero extra requests here rather than one doomed one.
345    pub async fn get_versions_typed_with(
346        &self,
347        name: &str,
348        freshness_enabled: bool,
349    ) -> Result<Vec<MavenVersion>> {
350        let (mut versions, release, base) = self.get_metadata(name).await?;
351        if freshness_enabled && let Some(base) = base.as_deref().filter(|b| should_fetch_listing(b))
352        {
353            let times = self.fetch_publish_times(base).await;
354            attach_publish_times(&mut versions, &times);
355        }
356        move_release_to_front(&mut versions, release.as_deref());
357        Ok(versions)
358    }
359
360    /// Fetches all available versions, without publish-time enrichment.
361    ///
362    /// Delegates to [`Self::get_versions_typed_with`] with freshness disabled so the two
363    /// paths cannot drift apart.
364    pub async fn get_versions_typed(&self, name: &str) -> Result<Vec<MavenVersion>> {
365        self.get_versions_typed_with(name, false).await
366    }
367
368    pub async fn get_latest_matching_typed(
369        &self,
370        name: &str,
371        req: &str,
372    ) -> Result<Option<MavenVersion>> {
373        let (versions, release, _base) = self.get_metadata(name).await?;
374        // For Maven MVP: exact string match, or latest stable if req is empty/wildcard
375        if req.is_empty() || req == "*" {
376            return Ok(pick_wildcard_latest(&versions, release.as_deref()));
377        }
378        Ok(versions.into_iter().find(|v| v.version == req))
379    }
380
381    /// Searches Maven Central for artifacts matching `query`.
382    ///
383    /// Retries against `solrsearch` via `search_with_retry`, falling back to a stale
384    /// cached result (if any) on failure, to work around its silent-timeout
385    /// unreliability (#274) without exceeding the caller's own completion deadline. See
386    /// `SEARCH_ATTEMPT_TIMEOUTS` and `search_with_retry` for the retry/fallback policy.
387    ///
388    /// A query that failed live within the last `RECENT_FAILURE_TTL` short-circuits to
389    /// the same error with no network attempt at all (#282 gap 1) — this is what keeps
390    /// a completion request's own fallback search (`deps-lsp`'s `completion.rs`) from
391    /// doubling live request volume against an endpoint that is already fast-failing
392    /// for this query, since a fast failure (unlike a hang) returns well inside the
393    /// caller's own deadline and so does not get skipped by that deadline alone.
394    ///
395    /// The request always asks `solrsearch` for `SEARCH_CACHE_ROWS` rows regardless of
396    /// `limit` (#282 gap 2), so that calls with different `limit`s for the same `query`
397    /// share one `HttpCache` entry — `limit` only trims the parsed result afterward via
398    /// `parse_search_response`.
399    ///
400    /// # Errors
401    ///
402    /// Returns the last error (an HTTP/network error, or a synthesized timeout error)
403    /// if every attempt fails and no cached result is available to fall back to.
404    pub async fn search_typed(&self, query: &str, limit: usize) -> Result<Vec<ArtifactInfo>> {
405        debug_assert!(
406            limit <= SEARCH_CACHE_ROWS,
407            "search_typed's limit ({limit}) exceeds SEARCH_CACHE_ROWS ({SEARCH_CACHE_ROWS}); \
408             raise SEARCH_CACHE_ROWS to cover every caller's requested limit"
409        );
410
411        if is_recently_failed(&self.recent_search_failures, query) {
412            return Err(DepsError::CacheError(format!(
413                "solrsearch recently failed for query {query:?}, \
414                 skipping duplicate live attempt"
415            )));
416        }
417
418        let url = search_url(query);
419        let data = search_with_retry(
420            || self.cache.get_cached(&url),
421            || self.cache.peek_cached(&url),
422        )
423        .await;
424
425        let data = match data {
426            Ok(data) => data,
427            Err(e) => {
428                // Skip recording an offline block (issue #483 M2): unlike a genuine
429                // registry failure, this isn't evidence the query itself is problematic,
430                // and poisoning `recent_search_failures` with `RECENT_FAILURE_TTL` would
431                // leave search silently broken after `network.offline` flips back to
432                // `false`, until the TTL expires on its own.
433                if !e.is_offline() {
434                    record_search_failure(&self.recent_search_failures, query);
435                }
436                return Err(e);
437            }
438        };
439
440        // Only clear the failure memo once the body is confirmed parseable: an HTTP 200
441        // with a zero-byte/garbage body is `solrsearch`'s documented failure signature
442        // (#274), and `search_with_retry` reports that as `Ok`. Clearing on fetch alone
443        // would erase a real failure record without recording the parse failure that
444        // follows.
445        match parse_search_response(&data, limit) {
446            Ok(results) => {
447                record_search_success(&self.recent_search_failures, query);
448                Ok(results)
449            }
450            Err(e) => {
451                record_search_failure(&self.recent_search_failures, query);
452                Err(e)
453            }
454        }
455    }
456}
457
458/// Builds the `solrsearch` request URL for `query`.
459///
460/// Deliberately takes no `limit`: the URL doubles as `HttpCache`'s cache key, and
461/// always requesting [`SEARCH_CACHE_ROWS`] rows keeps that key identical across calls
462/// for the same `query` regardless of the caller's requested `limit` (#282 gap 2).
463fn search_url(query: &str) -> String {
464    format!(
465        "{MAVEN_SEARCH_BASE}?q={q}&rows={SEARCH_CACHE_ROWS}&wt=json",
466        q = urlencoding::encode(query),
467    )
468}
469
470/// Whether `query` failed live within [`RECENT_FAILURE_TTL`], per `failures`.
471fn is_recently_failed(failures: &DashMap<String, tokio::time::Instant>, query: &str) -> bool {
472    failures
473        .get(query)
474        .is_some_and(|failed_at| failed_at.elapsed() < RECENT_FAILURE_TTL)
475}
476
477/// Records `query`'s live search failure, pruning already-expired entries first so
478/// `failures` doesn't grow unbounded over the server's lifetime. Pruning only happens
479/// here (on a new failure), not on every lookup, so `failures` can transiently hold
480/// entries older than `RECENT_FAILURE_TTL` between failures — harmless, since
481/// `is_recently_failed` still checks each entry's age itself before trusting it.
482fn record_search_failure(failures: &DashMap<String, tokio::time::Instant>, query: &str) {
483    failures.retain(|_, failed_at| failed_at.elapsed() < RECENT_FAILURE_TTL);
484    failures.insert(query.to_string(), tokio::time::Instant::now());
485}
486
487/// Clears `query`'s failure record, if any, after a fully successful (fetched and
488/// parsed) `search_typed` call.
489fn record_search_success(failures: &DashMap<String, tokio::time::Instant>, query: &str) {
490    failures.remove(query);
491}
492
493/// Whether a failed search attempt is worth retrying live (#274).
494///
495/// A client-side timeout is always worth retrying — `solrsearch`'s dominant failure
496/// mode is a silent hang, not a clean error. An HTTP 5xx may be transient. Any 4xx is
497/// treated as terminal: a 400 means the query itself is malformed and retrying changes
498/// nothing, and a 429 specifically must NOT be retried immediately — that adds to the
499/// very request volume this endpoint's undocumented rate limiting reacts to.
500///
501/// `DepsError::Offline` (issue #483) is likewise terminal: it is deterministic and
502/// permanent for the duration of the config, so retrying it just pays
503/// [`SEARCH_RETRY_DELAY`] and an extra iteration for nothing, on a path with a
504/// user-facing completion deadline. Before the M2 fix this cost was absorbed by
505/// `recent_search_failures` after the first query; now that `search_typed` skips
506/// recording an offline block there, every offline completion would otherwise pay it.
507fn is_retryable_error(e: &DepsError) -> bool {
508    !matches!(e, DepsError::HttpStatus { status, .. } if (400..500).contains(status))
509        && !e.is_offline()
510}
511
512/// Retries `fetch` across [`SEARCH_ATTEMPT_TIMEOUTS`], each attempt bounded by its own
513/// timeout, falling back to a synchronous, non-network `stale` cache peek after any
514/// failed attempt (whether it timed out or returned a retryable error) before trying
515/// again or giving up (#274).
516///
517/// The stale check runs after *every* failed attempt, not only once all attempts are
518/// exhausted: `solrsearch`'s dominant failure mode is a multi-second hang, and
519/// [`SEARCH_ATTEMPT_TIMEOUTS`] plus [`SEARCH_RETRY_DELAY`] are deliberately sized to
520/// outlast the caller's own completion deadline on total failure — checking only after
521/// full exhaustion would mean this fallback rarely gets a chance to run for that
522/// dominant failure mode, since the caller's own timeout would cancel the whole future
523/// first. Checking eagerly also serves a repeat query without paying out the full
524/// retry budget when cached data is already available (though still only after the
525/// first attempt's timeout has elapsed, not instantly).
526///
527/// Extracted from `search_typed` so the retry/timeout/backoff/stale-fallback mechanics
528/// can be unit-tested with a fake `fetch`/`stale` pair under `tokio::time::pause`,
529/// without a real HTTPS endpoint: `HttpCache` has no test-mode escape hatch for a
530/// mocked server across a crate boundary (its `ensure_https`, in
531/// `crates/deps-core/src/cache.rs`, only relaxes for `#[cfg(test)]` within `deps-core`'s
532/// own compilation, which does not apply when `deps-maven` links it as a normal
533/// dependency).
534async fn search_with_retry<F, Fut, S>(mut fetch: F, stale: S) -> Result<Bytes>
535where
536    F: FnMut() -> Fut,
537    Fut: std::future::Future<Output = Result<Bytes>>,
538    S: Fn() -> Option<Bytes>,
539{
540    let mut last_err = None;
541    for (i, attempt_timeout) in SEARCH_ATTEMPT_TIMEOUTS.iter().enumerate() {
542        let retryable = match tokio::time::timeout(*attempt_timeout, fetch()).await {
543            Ok(Ok(body)) => return Ok(body),
544            Ok(Err(e)) => {
545                let retryable = is_retryable_error(&e);
546                tracing::debug!(attempt = i + 1, error = %e, retryable, "solrsearch attempt failed");
547                last_err = Some(e);
548                retryable
549            }
550            Err(_) => {
551                tracing::debug!(attempt = i + 1, timeout = ?attempt_timeout, "solrsearch attempt timed out");
552                last_err = Some(DepsError::CacheError(format!(
553                    "search request timed out after {attempt_timeout:?}"
554                )));
555                true
556            }
557        };
558
559        if let Some(body) = stale() {
560            tracing::warn!("solrsearch: serving stale cached result after a failed live attempt");
561            return Ok(body);
562        }
563
564        if !retryable || i + 1 == SEARCH_ATTEMPT_TIMEOUTS.len() {
565            break;
566        }
567        tokio::time::sleep(SEARCH_RETRY_DELAY).await;
568    }
569    Err(last_err
570        .expect("SEARCH_ATTEMPT_TIMEOUTS is non-empty, so at least one attempt always runs"))
571}
572
573/// Returns ordered list of maven-metadata.xml URLs to try for the given package.
574///
575/// Non-Google packages get two URLs: Maven Central (primary) and Gradle Plugin Portal (fallback).
576/// Google-hosted packages get only the Google Maven URL — they are not mirrored elsewhere.
577///
578/// Returns `Ok(vec![])` (treated by [`MavenCentralRegistry::get_metadata`] as "no versions
579/// found") only for a malformed `groupId:artifactId` pair with no `:` separator.
580///
581/// Returns `Err(DepsError::PackageNotFound)` — mirroring `deps-dart`'s `reject_dot_segment`
582/// (#349) — when either coordinate segment fails [`is_safe_maven_coordinate_segment`]: a
583/// `groupId`/`artifactId` containing `../` (or other path-breakout characters) must never
584/// reach the `.`→`/` replace and URL construction below, since `group_path`/`artifact_id`
585/// are interpolated into the request URL unescaped. Propagating this as an error (rather
586/// than folding it into the empty-URL-list case) keeps it distinguishable from a genuine
587/// 404, so hover correctly renders nothing for a rejected coordinate instead of a broken
588/// "package not found" section (#366).
589fn metadata_urls(name: &str) -> Result<Vec<String>> {
590    let Some((group_id, artifact_id)) = name.split_once(':') else {
591        return Ok(vec![]);
592    };
593    if !is_safe_maven_coordinate_segment(group_id) {
594        warn_rejected_value(
595            "is_safe_maven_coordinate_segment",
596            "maven metadata URL groupId",
597            group_id,
598        );
599        return Err(DepsError::PackageNotFound {
600            package: name.to_string(),
601            registry: REGISTRY,
602        });
603    }
604    if !is_safe_maven_coordinate_segment(artifact_id) {
605        warn_rejected_value(
606            "is_safe_maven_coordinate_segment",
607            "maven metadata URL artifactId",
608            artifact_id,
609        );
610        return Err(DepsError::PackageNotFound {
611            package: name.to_string(),
612            registry: REGISTRY,
613        });
614    }
615    let group_path = group_id.replace('.', "/");
616    let primary_base = repo_base_for_group(group_id);
617    let primary = format!("{primary_base}/{group_path}/{artifact_id}/maven-metadata.xml");
618
619    Ok(if is_google_group(group_id) {
620        vec![primary]
621    } else {
622        vec![
623            primary,
624            format!("{GRADLE_PLUGIN_PORTAL_BASE}/{group_path}/{artifact_id}/maven-metadata.xml"),
625        ]
626    })
627}
628
629/// Parses maven-metadata.xml to extract version list and the authoritative release version.
630///
631/// Returns `(versions, release)` where `release` is the `<release>` element from
632/// `<versioning>`, if present. Use `release` as the authoritative latest stable version
633/// instead of sorting all versions.
634///
635/// # Errors
636///
637/// Returns `DepsError::CacheError` if the XML is malformed. A truncated `versions` list from
638/// silently stopping at the parse error, rather than surfacing it, would itself be a source
639/// of the same "real version missing from `available`" false-positive class this PR's
640/// diagnostic guards against elsewhere.
641fn parse_metadata_xml(data: &[u8]) -> Result<(Vec<MavenVersion>, Option<String>)> {
642    let mut reader = Reader::from_reader(data);
643    let mut versions = Vec::new();
644    let mut release: Option<String> = None;
645    let mut in_versions = false;
646    let mut in_version = false;
647    let mut in_release = false;
648    let mut buf = Vec::new();
649
650    loop {
651        match reader.read_event_into(&mut buf) {
652            Ok(Event::Start(e)) => match e.name().as_ref() {
653                "versions" => in_versions = true,
654                "version" if in_versions => in_version = true,
655                "release" if !in_versions => in_release = true,
656                _ => {}
657            },
658            Ok(Event::End(e)) => match e.name().as_ref() {
659                "versions" => in_versions = false,
660                "version" => in_version = false,
661                "release" => in_release = false,
662                _ => {}
663            },
664            Ok(Event::Text(e)) => {
665                let text = quick_xml::escape::unescape(&e).unwrap_or_default();
666                let s = text.trim().to_string();
667                if s.is_empty() {
668                    buf.clear();
669                    continue;
670                }
671                if in_version {
672                    versions.push(MavenVersion {
673                        version: s.into(),
674                        published_at: None,
675                    });
676                } else if in_release {
677                    release = Some(s);
678                }
679            }
680            Ok(Event::Eof) => break,
681            Err(e) => {
682                return Err(DepsError::CacheError(format!(
683                    "malformed maven-metadata.xml: {e}"
684                )));
685            }
686            _ => {}
687        }
688        buf.clear();
689    }
690
691    versions.sort_by(|a, b| compare_versions(b.version.as_str(), a.version.as_str()));
692    Ok((versions, release))
693}
694
695/// Parses a Maven Central directory listing (`repo1.maven.org/maven2/{g}/{a}/`) into a
696/// version → publish-time map.
697///
698/// Line-oriented, not a full HTML parser: each line is checked independently for both an
699/// anchor `href` (never the display text, which Maven Central sometimes pads or wraps in
700/// a `title=` attribute) and a `YYYY-MM-DD HH:MM` timestamp anywhere on the line; a line
701/// missing either yields nothing. This is what makes the Gradle Plugin Portal's dateless
702/// `<pre><a href="X/">X/</a></pre>` listing format — and any other listing that carries no
703/// date column — parse to an empty map instead of a guess.
704///
705/// Bounded by the same 32 MiB response cap [`HttpCache`] applies to every fetch (not a
706/// meaningfully tight bound on its own); real listings are far smaller (up to ~245 KB /
707/// ~2000 anchors observed for a large artifact).
708fn parse_publish_times(html: &[u8]) -> HashMap<String, PublishTime> {
709    let mut map = HashMap::new();
710    let text = String::from_utf8_lossy(html);
711    let Some(pre) = extract_pre_block(&text) else {
712        return map;
713    };
714
715    for line in pre.lines() {
716        let Some(href) = extract_href(line) else {
717            continue;
718        };
719        // Only directory entries (trailing `/`) are version directories — a sibling file
720        // entry (`maven-metadata.xml`, `.md5`, `.sha1`, ...) also carries an href and a
721        // date, but is never a version, so keeping it out of the map avoids polluting it
722        // with keys that will just never be looked up.
723        let Some(version) = href.strip_suffix('/') else {
724            continue;
725        };
726        if version.is_empty() || version == ".." {
727            continue;
728        }
729        let Some(date_str) = find_date_time(line) else {
730            continue;
731        };
732        let rfc3339 = format!("{}T{}:00Z", &date_str[..10], &date_str[11..16]);
733        if let Some(published) = PublishTime::parse_rfc3339(&rfc3339) {
734            map.insert(version.to_string(), published);
735        }
736    }
737
738    map
739}
740
741/// Slices out the body of the first `<pre>...</pre>` block, case-sensitively (Maven
742/// Central and the Gradle Plugin Portal both emit lowercase tags). Returns `None` when no
743/// `<pre>` block is present, so a page shaped nothing like a directory listing yields an
744/// empty map rather than scanning arbitrary HTML for anchor-shaped text.
745fn extract_pre_block(html: &str) -> Option<&str> {
746    let open = html.find("<pre")?;
747    let content_start = html[open..].find('>')? + open + 1;
748    let close = html[content_start..].find("</pre")?;
749    Some(&html[content_start..content_start + close])
750}
751
752/// Extracts an anchor's `href` attribute value from a listing line, ignoring display text.
753fn extract_href(line: &str) -> Option<&str> {
754    let idx = line.find("href=\"")?;
755    let rest = &line[idx + 6..];
756    let end = rest.find('"')?;
757    Some(&rest[..end])
758}
759
760/// Finds the first `YYYY-MM-DD HH:MM` substring anywhere in `line`, independent of column
761/// alignment or padding. All matched bytes are ASCII, so the returned slice's byte offsets
762/// are always valid `str` char boundaries.
763fn find_date_time(line: &str) -> Option<&str> {
764    let bytes = line.as_bytes();
765    let window = 16; // "YYYY-MM-DD HH:MM"
766    if bytes.len() < window {
767        return None;
768    }
769    for start in 0..=(bytes.len() - window) {
770        let candidate = &bytes[start..start + window];
771        if is_date_time_shape(candidate) {
772            return Some(&line[start..start + window]);
773        }
774    }
775    None
776}
777
778fn is_date_time_shape(b: &[u8]) -> bool {
779    let digit = u8::is_ascii_digit;
780    digit(&b[0])
781        && digit(&b[1])
782        && digit(&b[2])
783        && digit(&b[3])
784        && b[4] == b'-'
785        && digit(&b[5])
786        && digit(&b[6])
787        && b[7] == b'-'
788        && digit(&b[8])
789        && digit(&b[9])
790        && b[10] == b' '
791        && digit(&b[11])
792        && digit(&b[12])
793        && b[13] == b':'
794        && digit(&b[14])
795        && digit(&b[15])
796}
797
798#[derive(Deserialize)]
799struct SolrSearchResponse {
800    response: SolrSearchBody,
801}
802
803#[derive(Deserialize)]
804struct SolrSearchBody {
805    #[serde(default)]
806    docs: Vec<SearchDoc>,
807}
808
809#[derive(Deserialize)]
810struct SearchDoc {
811    g: String,
812    a: String,
813    #[serde(rename = "latestVersion")]
814    latest_version: Option<String>,
815}
816
817fn parse_search_response(data: &[u8], limit: usize) -> Result<Vec<ArtifactInfo>> {
818    let response: SolrSearchResponse = deps_core::parse_json_checked(data)?;
819
820    let results = response
821        .response
822        .docs
823        .into_iter()
824        .take(limit)
825        .map(|d| {
826            let name = format!("{}:{}", d.g, d.a);
827            ArtifactInfo {
828                group_id: d.g,
829                artifact_id: d.a,
830                name: name.into(),
831                description: None,
832                latest_version: d.latest_version.unwrap_or_default().into(),
833                repository: None,
834            }
835        })
836        .collect();
837
838    Ok(results)
839}
840
841impl deps_core::Registry for MavenCentralRegistry {
842    fn get_versions<'a>(
843        &'a self,
844        name: &'a deps_core::PackageName,
845    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Version>>>> {
846        Box::pin(async move {
847            let versions = self.get_versions_typed(name.as_str()).await?;
848            Ok(versions
849                .into_iter()
850                .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
851                .collect())
852        })
853    }
854
855    fn get_versions_with<'a>(
856        &'a self,
857        name: &'a deps_core::PackageName,
858        freshness: deps_core::FreshnessSettings,
859    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Version>>>> {
860        Box::pin(async move {
861            let versions = self
862                .get_versions_typed_with(name.as_str(), freshness.enabled)
863                .await?;
864            Ok(versions
865                .into_iter()
866                .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
867                .collect())
868        })
869    }
870
871    fn get_latest_matching<'a>(
872        &'a self,
873        name: &'a deps_core::PackageName,
874        req: &'a deps_core::VersionReq,
875    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Option<Box<dyn deps_core::Version>>>> {
876        Box::pin(async move {
877            let version = self
878                .get_latest_matching_typed(name.as_str(), req.as_str())
879                .await?;
880            Ok(version.map(|v| Box::new(v) as Box<dyn deps_core::Version>))
881        })
882    }
883
884    fn search<'a>(
885        &'a self,
886        query: &'a str,
887        limit: usize,
888    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Metadata>>>> {
889        Box::pin(async move {
890            let results = self.search_typed(query, limit).await?;
891            Ok(results
892                .into_iter()
893                .map(|m| Box::new(m) as Box<dyn deps_core::Metadata>)
894                .collect())
895        })
896    }
897
898    fn select_latest_matching(
899        &self,
900        versions: &[Box<dyn deps_core::Version>],
901        req: &deps_core::VersionReq,
902    ) -> Option<usize> {
903        // `versions` is `get_versions`'s output, which already moved the maven-metadata.xml
904        // `<release>` entry to the front (`move_release_to_front`). Unlike npm's curated
905        // `dist-tags.latest`, Maven Central's `<release>`/`<latest>` tags carry no
906        // prerelease semantics — they simply track the most recently *deployed* artifact,
907        // which can itself be a prerelease. So index 0 is trusted only when it isn't one;
908        // otherwise scan for the newest non-prerelease entry. When every version is a
909        // prerelease (#340), this does NOT fall back to raw index 0 either — see the
910        // version-comparison scan below (M1) — since `move_release_to_front` may have
911        // hoisted a `<release>`-tagged prerelease that isn't actually the newest deployed
912        // one. This keeps `select_latest_matching` (no I/O, no side channel) agreeing with
913        // hover's `is_stable()`-based pick whenever a stable version exists.
914        let req_str = req.as_str();
915        if req_str.is_empty() || req_str == "*" {
916            if versions.is_empty() {
917                return None;
918            }
919            if let Some(idx) = versions.iter().position(|v| !v.is_prerelease()) {
920                return Some(idx);
921            }
922            // FR-002: every version is a prerelease. Don't just trust index 0 here —
923            // `move_release_to_front` may have hoisted a `<release>`-tagged prerelease
924            // that isn't actually the newest deployed one (M1); scan by actual version
925            // comparison instead, same comparator `get_versions` already sorted by.
926            return versions
927                .iter()
928                .enumerate()
929                .max_by(|(_, a), (_, b)| {
930                    crate::version::compare_versions(
931                        a.version_string().as_str(),
932                        b.version_string().as_str(),
933                    )
934                })
935                .map(|(idx, _)| idx);
936        }
937        versions.iter().position(|v| v.version_string() == req_str)
938    }
939
940    // Maven Central has no retraction concept (`types.rs:98`) — `removal_status`
941    // uses the trait's default `Available`. Also covers Gradle, whose `registry()`
942    // returns its own instance of this same `MavenCentralRegistry` type (#233).
943    fn reports_yanked(&self) -> bool {
944        false
945    }
946
947    fn as_any(&self) -> &dyn Any {
948        self
949    }
950}
951
952#[cfg(test)]
953mod tests {
954    use super::*;
955
956    use std::assert_matches;
957
958    #[test]
959    fn test_repo_base_for_group_central() {
960        assert_eq!(repo_base_for_group("org.apache.commons"), MAVEN_REPO_BASE);
961        assert_eq!(repo_base_for_group("com.example"), MAVEN_REPO_BASE);
962        // com.google.protobuf is on Maven Central, not Google Maven
963        assert_eq!(repo_base_for_group("com.google.protobuf"), MAVEN_REPO_BASE);
964    }
965
966    #[test]
967    fn test_repo_base_for_group_google() {
968        assert_eq!(repo_base_for_group("androidx.core"), GOOGLE_MAVEN_BASE);
969        assert_eq!(
970            repo_base_for_group("com.google.firebase.crashlytics"),
971            GOOGLE_MAVEN_BASE
972        );
973        assert_eq!(
974            repo_base_for_group("com.google.android.gms"),
975            GOOGLE_MAVEN_BASE
976        );
977        assert_eq!(
978            repo_base_for_group("com.google.gms.google-services"),
979            GOOGLE_MAVEN_BASE
980        );
981        assert_eq!(repo_base_for_group("com.android.tools"), GOOGLE_MAVEN_BASE);
982    }
983
984    #[test]
985    fn test_package_url_central() {
986        assert_eq!(
987            package_url("org.apache.commons:commons-lang3"),
988            "https://central.sonatype.com/artifact/org.apache.commons/commons-lang3"
989        );
990    }
991
992    #[test]
993    fn test_package_url_google() {
994        assert_eq!(
995            package_url("androidx.core:core-ktx"),
996            "https://maven.google.com/web/index.html#androidx.core:core-ktx"
997        );
998        assert_eq!(
999            package_url("com.google.firebase.crashlytics:firebase-crashlytics"),
1000            "https://maven.google.com/web/index.html#com.google.firebase.crashlytics:firebase-crashlytics"
1001        );
1002    }
1003
1004    #[test]
1005    fn test_package_url_no_colon() {
1006        let url = package_url("bad");
1007        assert!(url.contains("search.maven") || url.contains("sonatype.com"));
1008    }
1009
1010    #[test]
1011    fn test_package_url_encodes_malicious_group_and_artifact() {
1012        let url = package_url("evil)[:pkg](x");
1013        assert!(!url.contains('('));
1014        assert!(!url.contains(')'));
1015        assert!(!url.contains('['));
1016        assert!(!url.contains(']'));
1017    }
1018
1019    #[test]
1020    fn test_package_url_google_encodes_malicious_group_and_artifact() {
1021        let url = package_url("androidx.evil)[:pkg](x");
1022        assert!(url.contains("maven.google.com"));
1023        assert!(!url.contains('('));
1024        assert!(!url.contains(')'));
1025        assert!(!url.contains('['));
1026        assert!(!url.contains(']'));
1027    }
1028
1029    #[test]
1030    fn test_package_url_encodes_newline_autolink_and_percent() {
1031        let url = package_url("evil\n<%:pkg>");
1032        assert!(!url.contains('\n'));
1033        assert!(!url.contains('<'));
1034        assert!(!url.contains('>'));
1035        assert!(url.contains("%25"));
1036    }
1037
1038    #[test]
1039    fn test_metadata_urls_central_has_two_urls() {
1040        let urls = metadata_urls("org.apache.commons:commons-lang3").unwrap();
1041        assert_eq!(urls.len(), 2);
1042        assert_eq!(
1043            urls[0],
1044            "https://repo1.maven.org/maven2/org/apache/commons/commons-lang3/maven-metadata.xml"
1045        );
1046        assert_eq!(
1047            urls[1],
1048            "https://plugins.gradle.org/m2/org/apache/commons/commons-lang3/maven-metadata.xml"
1049        );
1050    }
1051
1052    #[test]
1053    fn test_metadata_urls_google_has_one_url() {
1054        let urls = metadata_urls("androidx.core:core-ktx").unwrap();
1055        assert_eq!(urls.len(), 1);
1056        assert_eq!(
1057            urls[0],
1058            "https://dl.google.com/dl/android/maven2/androidx/core/core-ktx/maven-metadata.xml"
1059        );
1060
1061        let urls = metadata_urls("com.google.firebase.crashlytics:firebase-crashlytics").unwrap();
1062        assert_eq!(urls.len(), 1);
1063        assert_eq!(
1064            urls[0],
1065            "https://dl.google.com/dl/android/maven2/com/google/firebase/crashlytics/firebase-crashlytics/maven-metadata.xml"
1066        );
1067    }
1068
1069    #[test]
1070    fn test_metadata_urls_no_colon() {
1071        assert!(metadata_urls("bad").unwrap().is_empty());
1072    }
1073
1074    /// #366: a rejected coordinate must surface specifically as `PackageNotFound`, not
1075    /// merely *some* error — `DepsError::is_not_found()` (checked by
1076    /// `deps-lsp/src/document/lifecycle.rs`) is what keeps the diagnostic classified as
1077    /// "Unknown package" rather than "Registry lookup failed", and is also what makes
1078    /// `generate_hover`'s `.ok()?` chain return `None` the same way it already does for a
1079    /// genuine 404.
1080    fn assert_rejected_as_not_found(result: Result<Vec<String>>) {
1081        let err = result.expect_err("rejected coordinate must be Err");
1082        assert!(
1083            matches!(err, DepsError::PackageNotFound { .. }),
1084            "expected PackageNotFound, got {err:?}"
1085        );
1086        assert!(err.is_not_found());
1087    }
1088
1089    /// #349: `com.example:../../../admin` (confirmed live) must not escape the `maven2`
1090    /// prefix via the artifact_id segment. The fix rejects the coordinate outright
1091    /// (`is_safe_maven_coordinate_segment` gate) rather than attempting to
1092    /// escape/percent-encode it, so the correct regression assertion is that *no* metadata
1093    /// URL is built at all — there is structurally nothing left for a
1094    /// `url::Url::parse` check to validate once the request itself is suppressed.
1095    ///
1096    /// #366: the rejection is a distinct error (`PackageNotFound`), not the same empty-list
1097    /// case as a malformed `groupId:artifactId` pair — this is what lets hover distinguish
1098    /// "not found" from a security-gated coordinate and return `None` for both.
1099    #[test]
1100    fn test_metadata_urls_rejects_path_traversal_artifact_id() {
1101        assert_rejected_as_not_found(metadata_urls("com.example:../../../admin"));
1102    }
1103
1104    /// #349: same guard, but the traversal sits in the groupId segment instead.
1105    #[test]
1106    fn test_metadata_urls_rejects_path_traversal_group_id() {
1107        assert_rejected_as_not_found(metadata_urls("../../../admin:artifact"));
1108    }
1109
1110    /// M1 (impl-critic): a literal `..` artifactId is made only of otherwise-allowed
1111    /// characters (`is_safe_maven_coordinate_segment`'s charset permits `.`), so it used to
1112    /// slip past the gate even though it is not a real Maven coordinate segment —
1113    /// `metadata_urls("com.example:..")` collapsed to `/maven2/com/maven-metadata.xml`,
1114    /// dropping the `example`/artifactId path components via dot-segment normalization.
1115    /// Confined to the `maven2` prefix (not a breakout, per the critique), but must still
1116    /// be rejected outright like any other dot-segment gate in this PR.
1117    #[test]
1118    fn test_metadata_urls_rejects_literal_dot_dot_artifact_id() {
1119        assert_rejected_as_not_found(metadata_urls("com.example:.."));
1120        assert_rejected_as_not_found(metadata_urls("a:.."));
1121    }
1122
1123    /// M1: same guard for a literal `.` artifactId/groupId.
1124    #[test]
1125    fn test_metadata_urls_rejects_literal_dot_segment() {
1126        assert_rejected_as_not_found(metadata_urls("com.example:."));
1127        assert_rejected_as_not_found(metadata_urls(".:artifact"));
1128    }
1129
1130    /// #365 regression sweep: exercises the real production, self-gating `metadata_urls`
1131    /// sink against the shared adversarial input set (varying artifactId, then groupId),
1132    /// guarding against a 6th recurrence of #349's defect class.
1133    #[test]
1134    fn test_metadata_urls_dot_segment_sweep() {
1135        deps_core::test_util::assert_dot_segment_gated_or_contained(
1136            |seg| {
1137                metadata_urls(&format!("com.example:{seg}"))
1138                    .ok()
1139                    .and_then(|urls| urls.into_iter().next())
1140            },
1141            "repo1.maven.org",
1142            "/maven2/",
1143        );
1144        deps_core::test_util::assert_dot_segment_gated_or_contained(
1145            |seg| {
1146                metadata_urls(&format!("{seg}:artifact"))
1147                    .ok()
1148                    .and_then(|urls| urls.into_iter().next())
1149            },
1150            "repo1.maven.org",
1151            "/maven2/",
1152        );
1153    }
1154
1155    /// #349: a legitimate coordinate must still resolve to a URL confined to the
1156    /// `maven2` (or Gradle Plugin Portal) prefix — verified structurally via
1157    /// `url::Url::parse`, not a raw-string `contains` check, so a future regression that
1158    /// reintroduces unescaped interpolation without breaking the existing exact-string
1159    /// tests would still be caught here.
1160    #[test]
1161    fn test_metadata_urls_parsed_url_stays_within_maven2_prefix() {
1162        let urls = metadata_urls("org.apache.commons:commons-lang3").unwrap();
1163        let parsed = url::Url::parse(&urls[0]).unwrap();
1164        let segments: Vec<&str> = parsed.path_segments().unwrap().collect();
1165        assert_eq!(
1166            segments,
1167            vec![
1168                "maven2",
1169                "org",
1170                "apache",
1171                "commons",
1172                "commons-lang3",
1173                "maven-metadata.xml"
1174            ]
1175        );
1176    }
1177
1178    #[test]
1179    fn test_parse_metadata_xml() {
1180        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1181<metadata>
1182  <groupId>org.apache.commons</groupId>
1183  <artifactId>commons-lang3</artifactId>
1184  <versioning>
1185    <latest>3.14.0</latest>
1186    <release>3.14.0</release>
1187    <versions>
1188      <version>3.12.0</version>
1189      <version>3.13.0</version>
1190      <version>3.14.0</version>
1191    </versions>
1192  </versioning>
1193</metadata>"#;
1194
1195        let (versions, release) = parse_metadata_xml(xml.as_bytes()).unwrap();
1196        assert_eq!(versions.len(), 3);
1197        assert_eq!(versions[0].version, "3.14.0");
1198        assert_eq!(versions[1].version, "3.13.0");
1199        assert_eq!(versions[2].version, "3.12.0");
1200        assert_eq!(release.as_deref(), Some("3.14.0"));
1201    }
1202
1203    #[test]
1204    fn test_parse_metadata_xml_empty() {
1205        let xml = r#"<?xml version="1.0"?><metadata><versioning><versions></versions></versioning></metadata>"#;
1206        let (versions, release) = parse_metadata_xml(xml.as_bytes()).unwrap();
1207        assert!(versions.is_empty());
1208        assert!(release.is_none());
1209    }
1210
1211    #[test]
1212    fn test_parse_metadata_xml_legacy_versions_release_wins() {
1213        // Guava scenario: legacy bare-qualifier r03-r09 releases must sort below
1214        // properly-formed numeric releases, and <release> is authoritative for latest stable.
1215        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1216<metadata>
1217  <groupId>com.google.guava</groupId>
1218  <artifactId>guava</artifactId>
1219  <versioning>
1220    <latest>33.5.0-jre</latest>
1221    <release>33.5.0-jre</release>
1222    <versions>
1223      <version>r03</version>
1224      <version>r05</version>
1225      <version>r09</version>
1226      <version>14.0</version>
1227      <version>33.4.0-jre</version>
1228      <version>33.5.0-jre</version>
1229    </versions>
1230  </versioning>
1231</metadata>"#;
1232
1233        let (versions, release) = parse_metadata_xml(xml.as_bytes()).unwrap();
1234        assert_eq!(versions.len(), 6);
1235        assert_eq!(release.as_deref(), Some("33.5.0-jre"));
1236
1237        let ordered: Vec<&str> = versions.iter().map(|v| v.version.as_str()).collect();
1238        assert_eq!(
1239            ordered,
1240            vec!["33.5.0-jre", "33.4.0-jre", "14.0", "r09", "r05", "r03"],
1241            "numeric releases must sort above legacy bare qualifiers"
1242        );
1243    }
1244
1245    #[test]
1246    fn test_parse_metadata_xml_mixed_segment_count_sort_does_not_panic() {
1247        // C1 regression guard: an artifact publishing both a 2- and 3-segment
1248        // spelling of the same release plus a same-base above-release
1249        // qualifier build used to make compare_versions a non-total order
1250        // (#182's absent-as-zero rule collided with qualifier ranking at the
1251        // flat segment index), which panics `Vec::sort_by`'s total-order
1252        // detector. compare_versions must stay total-order; range/interval
1253        // normalization lives in compare_versions_for_range instead.
1254        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1255<metadata>
1256  <groupId>com.example</groupId>
1257  <artifactId>widget</artifactId>
1258  <versioning>
1259    <versions>
1260      <version>1.0</version>
1261      <version>1.0.0</version>
1262      <version>1.0-jre</version>
1263    </versions>
1264  </versioning>
1265</metadata>"#;
1266
1267        let (versions, _release) = parse_metadata_xml(xml.as_bytes()).unwrap();
1268        let ordered: Vec<&str> = versions.iter().map(|v| v.version.as_str()).collect();
1269        assert_eq!(ordered, vec!["1.0.0", "1.0-jre", "1.0"]);
1270    }
1271
1272    /// Minor item: malformed XML must surface as an error, not silently return a truncated
1273    /// `versions` list — a truncation could itself drop a real, installable version out of
1274    /// `available`, the exact false-positive class this PR's diagnostic guards against.
1275    #[test]
1276    fn test_parse_metadata_xml_malformed_returns_error_instead_of_silent_truncation() {
1277        let xml = b"<metadata><versioning><versions><version>1.0.0</version></versions></wrong></metadata>";
1278        let result = parse_metadata_xml(xml);
1279        assert!(result.is_err());
1280    }
1281
1282    #[test]
1283    fn test_parse_search_response() {
1284        let json = r#"{
1285            "response": {
1286                "numFound": 2,
1287                "docs": [
1288                    {"g": "org.apache.commons", "a": "commons-lang3", "latestVersion": "3.14.0"},
1289                    {"g": "org.apache.commons", "a": "commons-math3", "latestVersion": "3.6.1"}
1290                ]
1291            }
1292        }"#;
1293
1294        let results = parse_search_response(json.as_bytes(), 10).unwrap();
1295        assert_eq!(results.len(), 2);
1296        assert_eq!(results[0].name, "org.apache.commons:commons-lang3");
1297        assert_eq!(results[0].latest_version, "3.14.0");
1298    }
1299
1300    #[test]
1301    fn test_parse_search_response_nesting_at_max_depth_accepted() {
1302        let depth = deps_core::MAX_JSON_NESTING_DEPTH;
1303        let json = format!(
1304            r#"{{"response": {{"docs": []}}, "extra": {}1{}}}"#,
1305            "[".repeat(depth - 1),
1306            "]".repeat(depth - 1)
1307        );
1308        assert!(parse_search_response(json.as_bytes(), 10).is_ok());
1309    }
1310
1311    #[test]
1312    fn test_parse_search_response_nesting_over_max_depth_rejected() {
1313        let depth = deps_core::MAX_JSON_NESTING_DEPTH + 1;
1314        let json = format!(
1315            r#"{{"response": {{"docs": []}}, "extra": {}1{}}}"#,
1316            "[".repeat(depth),
1317            "]".repeat(depth)
1318        );
1319        assert!(parse_search_response(json.as_bytes(), 10).is_err());
1320    }
1321
1322    /// Manual live probe against the real endpoint (#274) — NOT deterministic
1323    /// regression coverage: `solrsearch`'s live health varies run to run, so this can
1324    /// pass or fail independently of whether `search_with_retry`'s logic is correct.
1325    /// Deterministic coverage for the retry/timeout/backoff/stale-fallback mechanics
1326    /// lives in the `search_with_retry` tests below, which use fakes under
1327    /// `tokio::time::pause` and don't depend on network health. Run this one manually
1328    /// via `cargo test -p deps-maven -- --ignored` to sanity-check against the real
1329    /// endpoint.
1330    #[tokio::test]
1331    #[ignore]
1332    async fn test_search_typed_real_guava() {
1333        let cache = Arc::new(HttpCache::new());
1334        let registry = MavenCentralRegistry::new(cache);
1335        let results = registry.search_typed("guava", 20).await.unwrap();
1336
1337        assert!(!results.is_empty());
1338        assert!(
1339            results
1340                .iter()
1341                .any(|r| r.group_id == "com.google.guava" && r.artifact_id == "guava")
1342        );
1343    }
1344
1345    /// S1 guard rail: a total-failure `search_typed` call (no stale cache to fall back
1346    /// on) must take longer than `deps-lsp`'s completion deadline, so that deadline's
1347    /// own timeout fires first and takes its existing skip-fallback path, rather than
1348    /// `search_typed` finishing fast with an empty/error result the caller cannot
1349    /// distinguish from "genuinely no results" (see [`SEARCH_ATTEMPT_TIMEOUTS`]'s doc).
1350    #[test]
1351    fn test_search_attempt_budget_exceeds_completion_search_timeout() {
1352        let attempts_total: Duration = SEARCH_ATTEMPT_TIMEOUTS.iter().sum();
1353        let delays_total = SEARCH_RETRY_DELAY * (SEARCH_ATTEMPT_TIMEOUTS.len() as u32 - 1);
1354        assert!(
1355            attempts_total + delays_total > deps_core::completion::COMPLETION_SEARCH_TIMEOUT,
1356            "search_typed's worst-case retry budget must outlast the completion \
1357             handler's own timeout (#274/S1)"
1358        );
1359    }
1360
1361    /// R4 (code review of #274/S1's guard): the constant-arithmetic assertion in
1362    /// `test_search_attempt_budget_exceeds_completion_search_timeout` holds even under
1363    /// a fast-failing retryable error, where the invariant it's meant to guard doesn't
1364    /// actually apply (see [`SEARCH_ATTEMPT_TIMEOUTS`]'s doc). This test exercises the
1365    /// actual hang-mode behavior instead: a total-failure `search_with_retry` call (no
1366    /// stale cache) must not resolve before `COMPLETION_SEARCH_TIMEOUT` elapses.
1367    #[tokio::test(start_paused = true)]
1368    async fn test_search_with_retry_total_failure_outlasts_completion_search_timeout() {
1369        let start = tokio::time::Instant::now();
1370        let result = search_with_retry(
1371            || async {
1372                tokio::time::sleep(Duration::from_secs(60)).await;
1373                Ok(Bytes::new())
1374            },
1375            || None,
1376        )
1377        .await;
1378
1379        assert!(result.is_err());
1380        assert!(
1381            start.elapsed() >= deps_core::completion::COMPLETION_SEARCH_TIMEOUT,
1382            "a total-failure call must not resolve before the completion handler's own \
1383             timeout does (#274/S1), elapsed={:?}",
1384            start.elapsed()
1385        );
1386    }
1387
1388    /// #282 gap 2: the request URL (which doubles as `HttpCache`'s cache key) must not
1389    /// vary with the caller's requested `limit`, so a `limit=50` fallback search can
1390    /// reuse a `limit=20` primary search's cached/stale result for the same query.
1391    #[test]
1392    fn test_search_url_is_limit_independent() {
1393        assert_eq!(
1394            search_url("commons-lang3"),
1395            "https://search.maven.org/solrsearch/select?q=commons-lang3&rows=50&wt=json"
1396        );
1397    }
1398
1399    #[test]
1400    fn test_is_recently_failed_true_within_ttl() {
1401        let failures = DashMap::new();
1402        failures.insert("guava".to_string(), tokio::time::Instant::now());
1403        assert!(is_recently_failed(&failures, "guava"));
1404    }
1405
1406    #[test]
1407    fn test_is_recently_failed_false_for_unknown_query() {
1408        let failures = DashMap::new();
1409        assert!(!is_recently_failed(&failures, "guava"));
1410    }
1411
1412    /// #282 gap 1: an expired entry must not keep suppressing live attempts forever —
1413    /// once `RECENT_FAILURE_TTL` has passed, a legitimate retry is allowed again.
1414    #[tokio::test(start_paused = true)]
1415    async fn test_is_recently_failed_false_after_ttl_expires() {
1416        let failures = DashMap::new();
1417        failures.insert("guava".to_string(), tokio::time::Instant::now());
1418
1419        tokio::time::advance(RECENT_FAILURE_TTL + Duration::from_millis(1)).await;
1420
1421        assert!(!is_recently_failed(&failures, "guava"));
1422    }
1423
1424    /// #282 gap 1: `record_search_failure` must not let `failures` grow unbounded —
1425    /// already-expired entries for other queries are pruned on every new failure.
1426    #[tokio::test(start_paused = true)]
1427    async fn test_record_search_failure_prunes_expired_entries() {
1428        let failures = DashMap::new();
1429        record_search_failure(&failures, "old-query");
1430        assert_eq!(failures.len(), 1);
1431
1432        tokio::time::advance(RECENT_FAILURE_TTL + Duration::from_millis(1)).await;
1433        record_search_failure(&failures, "new-query");
1434
1435        assert_eq!(failures.len(), 1);
1436        assert!(failures.contains_key("new-query"));
1437        assert!(!failures.contains_key("old-query"));
1438    }
1439
1440    #[test]
1441    fn test_record_search_success_clears_failure() {
1442        let failures = DashMap::new();
1443        failures.insert("guava".to_string(), tokio::time::Instant::now());
1444
1445        record_search_success(&failures, "guava");
1446
1447        assert!(!failures.contains_key("guava"));
1448    }
1449
1450    #[test]
1451    fn test_record_search_success_on_unknown_query_is_a_no_op() {
1452        let failures = DashMap::new();
1453        record_search_success(&failures, "guava");
1454        assert!(failures.is_empty());
1455    }
1456
1457    /// #282 gap 1: a query that just failed live short-circuits the next `search_typed`
1458    /// call for the same query with no network attempt — the returned error names the
1459    /// suppression explicitly, distinguishing it from a real HTTP/timeout failure.
1460    #[tokio::test]
1461    async fn test_search_typed_short_circuits_on_recent_failure() {
1462        let cache = Arc::new(HttpCache::new());
1463        let registry = MavenCentralRegistry::new(cache);
1464        registry
1465            .recent_search_failures
1466            .insert("guava".to_string(), tokio::time::Instant::now());
1467
1468        let err = registry.search_typed("guava", 20).await.unwrap_err();
1469
1470        assert!(err.to_string().contains("skipping duplicate live attempt"));
1471    }
1472
1473    /// Issue #483 M2: an offline block must not poison `recent_search_failures` — unlike a
1474    /// genuine registry failure, it says nothing about whether `query` itself is broken, and
1475    /// `RECENT_FAILURE_TTL` would otherwise leave search silently short-circuited for a
1476    /// while after `network.offline` flips back to `false`.
1477    #[tokio::test]
1478    async fn test_search_typed_offline_does_not_poison_recent_failures() {
1479        let cache = Arc::new(HttpCache::new());
1480        cache.set_offline(true);
1481        let registry = MavenCentralRegistry::new(cache);
1482
1483        let err = registry.search_typed("guava", 20).await.unwrap_err();
1484
1485        assert!(err.is_offline(), "expected Offline, got {err:?}");
1486        assert!(
1487            registry.recent_search_failures.is_empty(),
1488            "an offline block must not be recorded as a search failure"
1489        );
1490    }
1491
1492    /// #282 S1: `parse_search_response` is the sole trim mechanism now that the request
1493    /// URL always asks for `SEARCH_CACHE_ROWS` regardless of the caller's `limit` — a
1494    /// caller asking for fewer results than the response contains must still get back
1495    /// only what it asked for.
1496    #[test]
1497    fn test_parse_search_response_trims_to_limit() {
1498        let json = r#"{
1499            "response": {
1500                "numFound": 2,
1501                "docs": [
1502                    {"g": "org.apache.commons", "a": "commons-lang3", "latestVersion": "3.14.0"},
1503                    {"g": "org.apache.commons", "a": "commons-math3", "latestVersion": "3.6.1"}
1504                ]
1505            }
1506        }"#;
1507
1508        let results = parse_search_response(json.as_bytes(), 1).unwrap();
1509        assert_eq!(results.len(), 1);
1510        assert_eq!(results[0].name, "org.apache.commons:commons-lang3");
1511    }
1512
1513    #[tokio::test(start_paused = true)]
1514    async fn test_search_with_retry_first_attempt_fails_second_succeeds() {
1515        use std::sync::atomic::{AtomicUsize, Ordering};
1516
1517        let calls = AtomicUsize::new(0);
1518        let result = search_with_retry(
1519            || {
1520                let n = calls.fetch_add(1, Ordering::SeqCst);
1521                async move {
1522                    if n == 0 {
1523                        Err(DepsError::CacheError("boom".into()))
1524                    } else {
1525                        Ok(Bytes::from_static(b"ok"))
1526                    }
1527                }
1528            },
1529            || None,
1530        )
1531        .await;
1532
1533        assert_eq!(result.unwrap(), Bytes::from_static(b"ok"));
1534        assert_eq!(calls.load(Ordering::SeqCst), 2);
1535    }
1536
1537    #[tokio::test(start_paused = true)]
1538    async fn test_search_with_retry_all_attempts_timeout_no_cache_returns_error() {
1539        use std::sync::atomic::{AtomicUsize, Ordering};
1540
1541        let calls = AtomicUsize::new(0);
1542        let result = search_with_retry(
1543            || {
1544                calls.fetch_add(1, Ordering::SeqCst);
1545                async {
1546                    tokio::time::sleep(Duration::from_secs(60)).await;
1547                    Ok(Bytes::new())
1548                }
1549            },
1550            || None,
1551        )
1552        .await;
1553
1554        assert!(result.is_err());
1555        assert_eq!(calls.load(Ordering::SeqCst), 2);
1556    }
1557
1558    /// #274/S2: a hung live attempt must not prevent a known-good stale cached result
1559    /// from being served, and it must be served after the *first* failed attempt, not
1560    /// only once every attempt is exhausted (see `search_with_retry`'s doc for why).
1561    #[tokio::test(start_paused = true)]
1562    async fn test_search_with_retry_serves_stale_cache_after_first_timeout() {
1563        use std::sync::atomic::{AtomicUsize, Ordering};
1564
1565        let calls = AtomicUsize::new(0);
1566        let result = search_with_retry(
1567            || {
1568                calls.fetch_add(1, Ordering::SeqCst);
1569                async {
1570                    tokio::time::sleep(Duration::from_secs(60)).await;
1571                    Ok(Bytes::new())
1572                }
1573            },
1574            || Some(Bytes::from_static(b"stale")),
1575        )
1576        .await;
1577
1578        assert_eq!(result.unwrap(), Bytes::from_static(b"stale"));
1579        assert_eq!(
1580            calls.load(Ordering::SeqCst),
1581            1,
1582            "stale cache should short-circuit after the first failed attempt, not wait \
1583             for both"
1584        );
1585    }
1586
1587    /// #274/M1: a 4xx (e.g. malformed query, or 429 rate-limiting) must not be retried
1588    /// immediately — retrying 429 specifically would add to the load suspected of
1589    /// triggering the rate limit in the first place.
1590    #[tokio::test]
1591    async fn test_search_with_retry_4xx_status_is_not_retried() {
1592        use std::sync::atomic::{AtomicUsize, Ordering};
1593
1594        let calls = AtomicUsize::new(0);
1595        let result = search_with_retry(
1596            || {
1597                calls.fetch_add(1, Ordering::SeqCst);
1598                async {
1599                    Err(DepsError::HttpStatus {
1600                        url: MAVEN_SEARCH_BASE.to_string(),
1601                        status: 400,
1602                    })
1603                }
1604            },
1605            || None,
1606        )
1607        .await;
1608
1609        assert!(result.is_err());
1610        assert_eq!(calls.load(Ordering::SeqCst), 1);
1611    }
1612
1613    #[tokio::test(start_paused = true)]
1614    async fn test_search_with_retry_5xx_status_is_retried() {
1615        use std::sync::atomic::{AtomicUsize, Ordering};
1616
1617        let calls = AtomicUsize::new(0);
1618        let result = search_with_retry(
1619            || {
1620                let n = calls.fetch_add(1, Ordering::SeqCst);
1621                async move {
1622                    if n == 0 {
1623                        Err(DepsError::HttpStatus {
1624                            url: MAVEN_SEARCH_BASE.to_string(),
1625                            status: 503,
1626                        })
1627                    } else {
1628                        Ok(Bytes::from_static(b"ok"))
1629                    }
1630                }
1631            },
1632            || None,
1633        )
1634        .await;
1635
1636        assert_eq!(result.unwrap(), Bytes::from_static(b"ok"));
1637        assert_eq!(calls.load(Ordering::SeqCst), 2);
1638    }
1639
1640    #[test]
1641    fn test_registry_creation() {
1642        let cache = Arc::new(HttpCache::new());
1643        let _registry = MavenCentralRegistry::new(cache);
1644    }
1645
1646    #[test]
1647    fn test_registry_as_any() {
1648        use deps_core::Registry;
1649        let cache = Arc::new(HttpCache::new());
1650        let registry = MavenCentralRegistry::new(cache);
1651        assert!(registry.as_any().is::<MavenCentralRegistry>());
1652    }
1653
1654    #[test]
1655    fn test_select_latest_matching_not_default_none() {
1656        use deps_core::{Registry, VersionReq};
1657
1658        // `select_latest_matching`'s contract is "index 0 of a `get_versions`-shaped list
1659        // is latest" — `get_versions_typed` is what puts the right entry at index 0 (via
1660        // `move_release_to_front`), not `select_latest_matching` itself, so this fixture
1661        // reflects an already-correctly-ordered list rather than an unordered one.
1662        let cache = Arc::new(HttpCache::new());
1663        let registry = MavenCentralRegistry::new(cache);
1664        let versions: Vec<Box<dyn deps_core::Version>> = vec![
1665            Box::new(MavenVersion {
1666                version: "1.0.0".into(),
1667                published_at: None,
1668            }),
1669            Box::new(MavenVersion {
1670                version: "2.0.0-SNAPSHOT".into(),
1671                published_at: None,
1672            }),
1673        ];
1674        let req = VersionReq::new("*");
1675        assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
1676    }
1677
1678    #[test]
1679    fn test_move_release_to_front_reorders() {
1680        let mut versions = vec![
1681            MavenVersion {
1682                version: "3.4.0".into(),
1683                published_at: None,
1684            },
1685            MavenVersion {
1686                version: "4.0.0-M1".into(),
1687                published_at: None,
1688            },
1689        ];
1690        // <release> designates the milestone even though it isn't the "stable-looking"
1691        // entry — the exact `spring-core` scenario this fix targets.
1692        move_release_to_front(&mut versions, Some("4.0.0-M1"));
1693        assert_eq!(versions[0].version, "4.0.0-M1");
1694        assert_eq!(versions[1].version, "3.4.0");
1695    }
1696
1697    #[test]
1698    fn test_move_release_to_front_already_first_is_a_no_op() {
1699        let mut versions = vec![
1700            MavenVersion {
1701                version: "1.0.0".into(),
1702                published_at: None,
1703            },
1704            MavenVersion {
1705                version: "0.9.0".into(),
1706                published_at: None,
1707            },
1708        ];
1709        move_release_to_front(&mut versions, Some("1.0.0"));
1710        assert_eq!(versions[0].version, "1.0.0");
1711        assert_eq!(versions[1].version, "0.9.0");
1712    }
1713
1714    #[test]
1715    fn test_move_release_to_front_release_absent_from_list_is_a_no_op() {
1716        let mut versions = vec![
1717            MavenVersion {
1718                version: "1.0.0".into(),
1719                published_at: None,
1720            },
1721            MavenVersion {
1722                version: "0.9.0".into(),
1723                published_at: None,
1724            },
1725        ];
1726        move_release_to_front(&mut versions, Some("2.0.0"));
1727        assert_eq!(versions[0].version, "1.0.0");
1728        assert_eq!(versions[1].version, "0.9.0");
1729    }
1730
1731    #[test]
1732    fn test_move_release_to_front_no_release_is_a_no_op() {
1733        let mut versions = vec![MavenVersion {
1734            version: "1.0.0".into(),
1735            published_at: None,
1736        }];
1737        move_release_to_front(&mut versions, None);
1738        assert_eq!(versions[0].version, "1.0.0");
1739    }
1740
1741    /// S7 regression: an artifact without a `<release>` element used to leave index 0 at
1742    /// whatever the raw qualifier sort put first, which can be a prerelease.
1743    #[test]
1744    fn test_move_release_to_front_no_release_falls_back_to_first_non_prerelease() {
1745        let mut versions = vec![
1746            MavenVersion {
1747                version: "1.5.0-alpha01".into(),
1748                published_at: None,
1749            },
1750            MavenVersion {
1751                version: "1.4.0".into(),
1752                published_at: None,
1753            },
1754        ];
1755        move_release_to_front(&mut versions, None);
1756        assert_eq!(versions[0].version, "1.4.0");
1757        assert_eq!(versions[1].version, "1.5.0-alpha01");
1758    }
1759
1760    #[test]
1761    fn test_move_release_to_front_no_release_and_all_prerelease_leaves_sorted_top() {
1762        let mut versions = vec![
1763            MavenVersion {
1764                version: "2.0.0-alpha".into(),
1765                published_at: None,
1766            },
1767            MavenVersion {
1768                version: "1.0.0-beta".into(),
1769                published_at: None,
1770            },
1771        ];
1772        move_release_to_front(&mut versions, None);
1773        assert_eq!(versions[0].version, "2.0.0-alpha");
1774    }
1775
1776    #[test]
1777    fn test_pick_wildcard_latest_prefers_release() {
1778        let versions = vec![
1779            MavenVersion {
1780                version: "1.4.0".into(),
1781                published_at: None,
1782            },
1783            MavenVersion {
1784                version: "1.5.0-M1".into(),
1785                published_at: None,
1786            },
1787        ];
1788        let picked = pick_wildcard_latest(&versions, Some("1.5.0-M1")).unwrap();
1789        assert_eq!(picked.version, "1.5.0-M1");
1790    }
1791
1792    #[test]
1793    fn test_pick_wildcard_latest_release_absent_from_list_synthesizes() {
1794        // The one documented case where pick_wildcard_latest and
1795        // move_release_to_front/select_latest_matching structurally cannot agree: <release>
1796        // is still trusted here since this function can return an owned value, but
1797        // move_release_to_front can only return an index into the existing slice.
1798        let versions = vec![MavenVersion {
1799            version: "1.0.0".into(),
1800            published_at: None,
1801        }];
1802        let picked = pick_wildcard_latest(&versions, Some("9.9.9")).unwrap();
1803        assert_eq!(picked.version, "9.9.9");
1804    }
1805
1806    /// #340 residual edge case (found during validation, not the original spec): when
1807    /// `release` names a prerelease absent from `versions` — the exact shape
1808    /// `Registry::get_latest_matching`'s only production caller reaches this function
1809    /// through, since `versions` is always empty there — `pick_wildcard_latest` must not
1810    /// synthesize a placeholder for it. Doing so would reproduce #340 through this one
1811    /// narrow corner even after the `select_latest_matching` fix.
1812    #[test]
1813    fn test_pick_wildcard_latest_release_prerelease_absent_from_list_returns_none() {
1814        let versions: Vec<MavenVersion> = vec![];
1815        let picked = pick_wildcard_latest(&versions, Some("8.0.0.Beta1"));
1816        assert!(picked.is_none());
1817    }
1818
1819    /// Companion to the above: a *stable* `release` absent from `versions` is unaffected
1820    /// (unlikely to be a real issue in practice — this is the pre-existing, deliberately
1821    /// documented synthesis behavior for a non-prerelease `<release>` tag).
1822    #[test]
1823    fn test_pick_wildcard_latest_release_stable_absent_from_empty_list_still_synthesizes() {
1824        let versions: Vec<MavenVersion> = vec![];
1825        let picked = pick_wildcard_latest(&versions, Some("1.2.3")).unwrap();
1826        assert_eq!(picked.version, "1.2.3");
1827    }
1828
1829    #[test]
1830    fn test_pick_wildcard_latest_no_release_prefers_non_prerelease() {
1831        let versions = vec![
1832            MavenVersion {
1833                version: "2.0.0-alpha".into(),
1834                published_at: None,
1835            },
1836            MavenVersion {
1837                version: "1.0.0".into(),
1838                published_at: None,
1839            },
1840        ];
1841        let picked = pick_wildcard_latest(&versions, None).unwrap();
1842        assert_eq!(picked.version, "1.0.0");
1843    }
1844
1845    /// S8: `select_latest_matching` (via `move_release_to_front`) and
1846    /// `get_latest_matching_typed`'s own wildcard branch (via `pick_wildcard_latest`) must
1847    /// agree on the same `(versions, release)` fixture when `<release>` names a stable
1848    /// version or is absent (S3/S7). They deliberately no longer agree when `<release>`
1849    /// itself names a prerelease *present in `versions`* (#340): `select_latest_matching`
1850    /// now skips past it to the newest stable version, matching hover's
1851    /// `is_stable()`-based pick, while `pick_wildcard_latest` still trusts a
1852    /// `<release>` found in `versions` verbatim — see
1853    /// `test_select_latest_matching_skips_prerelease_release_tag`. When `<release>` names a
1854    /// prerelease *absent* from `versions` instead, `pick_wildcard_latest` no longer trusts
1855    /// it either (see `test_pick_wildcard_latest_release_prerelease_absent_from_list_returns_none`).
1856    fn assert_select_latest_matching_agrees_with_pick_wildcard_latest(
1857        versions: Vec<MavenVersion>,
1858        release: Option<&str>,
1859    ) {
1860        use deps_core::{Registry, VersionReq};
1861
1862        let wildcard_pick = pick_wildcard_latest(&versions, release);
1863
1864        let mut reordered = versions;
1865        move_release_to_front(&mut reordered, release);
1866        let boxed: Vec<Box<dyn deps_core::Version>> = reordered
1867            .into_iter()
1868            .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
1869            .collect();
1870
1871        let cache = Arc::new(HttpCache::new());
1872        let registry = MavenCentralRegistry::new(cache);
1873        let idx = registry
1874            .select_latest_matching(&boxed, &VersionReq::new("*"))
1875            .expect("non-empty list must select an index");
1876
1877        assert_eq!(
1878            boxed[idx].version_string().as_str(),
1879            wildcard_pick
1880                .expect("fixture always has a pick")
1881                .version
1882                .as_str()
1883        );
1884    }
1885
1886    #[test]
1887    fn test_select_latest_matching_agrees_with_pick_wildcard_latest_release_present_stable() {
1888        assert_select_latest_matching_agrees_with_pick_wildcard_latest(
1889            vec![
1890                MavenVersion {
1891                    version: "1.5.0-M1".into(),
1892                    published_at: None,
1893                },
1894                MavenVersion {
1895                    version: "1.4.0".into(),
1896                    published_at: None,
1897                },
1898            ],
1899            Some("1.4.0"),
1900        );
1901    }
1902
1903    /// #340: `<release>` names a prerelease (`8.0.0.Beta1`-shaped scenario), but a stable
1904    /// release also exists in the list. `select_latest_matching`'s wildcard fast path must
1905    /// skip past the front-loaded prerelease and return the newest stable version instead
1906    /// of blindly trusting index 0, matching hover's `is_stable()`-based pick (FR-001).
1907    #[test]
1908    fn test_select_latest_matching_skips_prerelease_release_tag() {
1909        use deps_core::{Registry, VersionReq};
1910
1911        let mut versions = vec![
1912            MavenVersion {
1913                version: "1.4.0".into(),
1914                published_at: None,
1915            },
1916            MavenVersion {
1917                version: "1.5.0-M1".into(),
1918                published_at: None,
1919            },
1920        ];
1921        // `<release>` names the prerelease, so `move_release_to_front` puts it at index 0 —
1922        // reproducing the real `maven-metadata.xml` shape this bug was found in.
1923        move_release_to_front(&mut versions, Some("1.5.0-M1"));
1924        assert_eq!(versions[0].version, "1.5.0-M1");
1925
1926        let boxed: Vec<Box<dyn deps_core::Version>> = versions
1927            .into_iter()
1928            .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
1929            .collect();
1930
1931        let cache = Arc::new(HttpCache::new());
1932        let registry = MavenCentralRegistry::new(cache);
1933        let idx = registry
1934            .select_latest_matching(&boxed, &VersionReq::new("*"))
1935            .expect("non-empty list must select an index");
1936
1937        assert_eq!(boxed[idx].version_string(), "1.4.0");
1938    }
1939
1940    /// FR-002: when every version in the list is a prerelease, the wildcard fast path
1941    /// falls back to the newest version regardless of prerelease status.
1942    #[test]
1943    fn test_select_latest_matching_wildcard_all_prerelease_falls_back_to_newest() {
1944        use deps_core::{Registry, VersionReq};
1945
1946        let versions: Vec<Box<dyn deps_core::Version>> = vec![
1947            Box::new(MavenVersion {
1948                version: "2.0.0-alpha".into(),
1949                published_at: None,
1950            }),
1951            Box::new(MavenVersion {
1952                version: "1.0.0-beta".into(),
1953                published_at: None,
1954            }),
1955        ];
1956
1957        let cache = Arc::new(HttpCache::new());
1958        let registry = MavenCentralRegistry::new(cache);
1959        let idx = registry
1960            .select_latest_matching(&versions, &VersionReq::new("*"))
1961            .expect("non-empty list must select an index");
1962
1963        assert_eq!(versions[idx].version_string(), "2.0.0-alpha");
1964    }
1965
1966    /// M1: when every version is a prerelease AND `<release>` hoisted an *older*
1967    /// prerelease to index 0 (`move_release_to_front` trusts `<release>` unconditionally,
1968    /// independent of whether it's actually the newest deployed artifact), the FR-002
1969    /// fallback must scan by actual version comparison rather than blindly trusting
1970    /// index 0 — otherwise a stale/inconsistent `<release>` tag reproduces #340 even in
1971    /// this all-prerelease branch.
1972    #[test]
1973    fn test_select_latest_matching_wildcard_all_prerelease_ignores_stale_release_hoist() {
1974        use deps_core::{Registry, VersionReq};
1975
1976        let mut versions = vec![
1977            MavenVersion {
1978                version: "2.0.0-beta".into(),
1979                published_at: None,
1980            },
1981            MavenVersion {
1982                version: "1.0.0-alpha".into(),
1983                published_at: None,
1984            },
1985        ];
1986        // `<release>` names the OLDER prerelease — `move_release_to_front` hoists it to
1987        // index 0 regardless, reproducing the real shape a stale/inconsistent
1988        // `maven-metadata.xml` could produce.
1989        move_release_to_front(&mut versions, Some("1.0.0-alpha"));
1990        assert_eq!(versions[0].version, "1.0.0-alpha");
1991
1992        let boxed: Vec<Box<dyn deps_core::Version>> = versions
1993            .into_iter()
1994            .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
1995            .collect();
1996
1997        let cache = Arc::new(HttpCache::new());
1998        let registry = MavenCentralRegistry::new(cache);
1999        let idx = registry
2000            .select_latest_matching(&boxed, &VersionReq::new("*"))
2001            .expect("non-empty list must select an index");
2002
2003        assert_eq!(
2004            boxed[idx].version_string(),
2005            "2.0.0-beta",
2006            "must scan for the actual newest prerelease, not trust the release-tag hoist"
2007        );
2008    }
2009
2010    #[test]
2011    fn test_select_latest_matching_agrees_with_pick_wildcard_latest_release_absent_non_prerelease_exists()
2012     {
2013        assert_select_latest_matching_agrees_with_pick_wildcard_latest(
2014            vec![
2015                MavenVersion {
2016                    version: "1.5.0-alpha01".into(),
2017                    published_at: None,
2018                },
2019                MavenVersion {
2020                    version: "1.4.0".into(),
2021                    published_at: None,
2022                },
2023            ],
2024            None,
2025        );
2026    }
2027
2028    #[test]
2029    fn test_select_latest_matching_agrees_with_pick_wildcard_latest_release_absent_only_prereleases()
2030     {
2031        assert_select_latest_matching_agrees_with_pick_wildcard_latest(
2032            vec![
2033                MavenVersion {
2034                    version: "2.0.0-alpha".into(),
2035                    published_at: None,
2036                },
2037                MavenVersion {
2038                    version: "1.0.0-beta".into(),
2039                    published_at: None,
2040                },
2041            ],
2042            None,
2043        );
2044    }
2045
2046    // --- parse_publish_times: fixtures captured live from repo1.maven.org and
2047    // plugins.gradle.org on 2026-08-24 (see handoff for the exact `curl` commands) ---
2048
2049    /// A trimmed excerpt of the real `repo1.maven.org/maven2/org/apache/commons/commons-lang3/`
2050    /// listing: the `../` parent anchor, several version directories (padded display text +
2051    /// `title=` attribute, exactly as Maven Central emits), and a couple of sibling file
2052    /// entries (`maven-metadata.xml.md5` etc.) that carry dates too but are not versions.
2053    const REPO1_FIXTURE: &str = r#"<pre id="contents">
2054<a href="../">../</a>
2055<a href="3.12.0/" title="3.12.0/">3.12.0/</a>                                           2021-02-26 20:40         -
2056<a href="3.13.0/" title="3.13.0/">3.13.0/</a>                                           2023-07-23 19:44         -
2057<a href="3.14.0/" title="3.14.0/">3.14.0/</a>                                           2023-11-18 15:03         -
2058<a href="maven-metadata.xml" title="maven-metadata.xml">maven-metadata.xml</a>                                2025-11-16 12:55       817
2059<a href="maven-metadata.xml.md5" title="maven-metadata.xml.md5">maven-metadata.xml.md5</a>                            2025-11-16 12:55        32
2060</pre>"#;
2061
2062    #[test]
2063    fn test_parse_publish_times_repo1_fixture() {
2064        let map = parse_publish_times(REPO1_FIXTURE.as_bytes());
2065
2066        assert_eq!(
2067            map.get("3.14.0").copied(),
2068            PublishTime::parse_rfc3339("2023-11-18T15:03:00Z")
2069        );
2070        assert_eq!(
2071            map.get("3.12.0").copied(),
2072            PublishTime::parse_rfc3339("2021-02-26T20:40:00Z")
2073        );
2074        // The `../` parent anchor never becomes a "version".
2075        assert!(!map.contains_key(".."));
2076        assert!(!map.contains_key(""));
2077        // Sibling file entries (no trailing `/` in their href) are not versions either,
2078        // even though they carry a date too (M2).
2079        assert!(!map.contains_key("maven-metadata.xml"));
2080        assert!(!map.contains_key("maven-metadata.xml.md5"));
2081    }
2082
2083    /// Real `plugins.gradle.org/m2/.../spring-boot-gradle-plugin/` shape: one `<pre>` per
2084    /// anchor, no date column at all. `extract_pre_block` only ever sees the first `<pre>`,
2085    /// but the outcome is the same either way — no line here carries a date, so nothing
2086    /// is ever inserted.
2087    const GRADLE_PLUGIN_PORTAL_FIXTURE: &str = r#"<pre><a href="1.4.2.RELEASE/">1.4.2.RELEASE/</a></pre>
2088<pre><a href="1.5.0.RELEASE/">1.5.0.RELEASE/</a></pre>"#;
2089
2090    #[test]
2091    fn test_parse_publish_times_gradle_plugin_portal_dateless_is_empty() {
2092        let map = parse_publish_times(GRADLE_PLUGIN_PORTAL_FIXTURE.as_bytes());
2093        assert!(map.is_empty());
2094    }
2095
2096    #[test]
2097    fn test_parse_publish_times_malformed_date_entry_absent_rest_parsed() {
2098        let html = r#"<pre id="contents">
2099<a href="1.0.0/" title="1.0.0/">1.0.0/</a>                                            2011-13-45 99:99         -
2100<a href="1.0.1/" title="1.0.1/">1.0.1/</a>                                            2011-09-28 16:04         -
2101</pre>"#;
2102        let map = parse_publish_times(html.as_bytes());
2103        assert!(!map.contains_key("1.0.0"));
2104        assert_eq!(
2105            map.get("1.0.1").copied(),
2106            PublishTime::parse_rfc3339("2011-09-28T16:04:00Z")
2107        );
2108    }
2109
2110    #[test]
2111    fn test_parse_publish_times_no_pre_block_is_empty() {
2112        let html = r"<html><body>not a listing at all</body></html>";
2113        let map = parse_publish_times(html.as_bytes());
2114        assert!(map.is_empty());
2115    }
2116
2117    #[test]
2118    fn test_parse_publish_times_empty_body_is_empty() {
2119        let map = parse_publish_times(b"");
2120        assert!(map.is_empty());
2121    }
2122
2123    // --- should_fetch_listing (S2: gate on the winning base, not "some base exists") ---
2124
2125    #[test]
2126    fn test_should_fetch_listing_maven_central_base() {
2127        assert!(should_fetch_listing(
2128            "https://repo1.maven.org/maven2/org/apache/commons/commons-lang3/"
2129        ));
2130    }
2131
2132    #[test]
2133    fn test_should_fetch_listing_google_maven_base_is_false() {
2134        assert!(!should_fetch_listing(
2135            "https://dl.google.com/dl/android/maven2/androidx/core/core/"
2136        ));
2137    }
2138
2139    #[test]
2140    fn test_should_fetch_listing_gradle_plugin_portal_base_is_false() {
2141        assert!(!should_fetch_listing(
2142            "https://plugins.gradle.org/m2/org/example/plugin/"
2143        ));
2144    }
2145
2146    // --- attach_publish_times: version/date pairing edge cases ---
2147
2148    #[test]
2149    fn test_attach_publish_times_matches_by_version_string() {
2150        let mut versions = vec![
2151            MavenVersion {
2152                version: "1.0.0".into(),
2153                published_at: None,
2154            },
2155            MavenVersion {
2156                version: "2.0.0".into(),
2157                published_at: None,
2158            },
2159        ];
2160        let mut times = HashMap::new();
2161        times.insert(
2162            "1.0.0".to_string(),
2163            PublishTime::parse_rfc3339("2020-01-01T00:00:00Z").unwrap(),
2164        );
2165        attach_publish_times(&mut versions, &times);
2166
2167        assert_eq!(
2168            versions[0].published_at,
2169            PublishTime::parse_rfc3339("2020-01-01T00:00:00Z")
2170        );
2171        assert_eq!(versions[1].published_at, None);
2172        // Order is untouched.
2173        assert_eq!(versions[0].version, "1.0.0");
2174        assert_eq!(versions[1].version, "2.0.0");
2175    }
2176
2177    #[test]
2178    fn test_attach_publish_times_extra_map_entry_does_not_panic_or_cross_assign() {
2179        let mut versions = vec![MavenVersion {
2180            version: "1.0.0".into(),
2181            published_at: None,
2182        }];
2183        let mut times = HashMap::new();
2184        // A version present in the listing but absent from maven-metadata.xml — must not
2185        // be assigned to an unrelated entry, and must not panic.
2186        times.insert(
2187            "9.9.9-not-in-metadata".to_string(),
2188            PublishTime::parse_rfc3339("2020-01-01T00:00:00Z").unwrap(),
2189        );
2190        attach_publish_times(&mut versions, &times);
2191        assert_eq!(versions[0].published_at, None);
2192    }
2193
2194    #[test]
2195    fn test_attach_publish_times_empty_map_leaves_all_none() {
2196        let mut versions = vec![
2197            MavenVersion {
2198                version: "1.0.0".into(),
2199                published_at: None,
2200            },
2201            MavenVersion {
2202                version: "2.0.0".into(),
2203                published_at: None,
2204            },
2205        ];
2206        attach_publish_times(&mut versions, &HashMap::new());
2207        assert!(versions.iter().all(|v| v.published_at.is_none()));
2208    }
2209
2210    // --- fetch_publish_times: HTTP degradation (mockito) ---
2211
2212    #[tokio::test]
2213    async fn test_fetch_publish_times_success_parses_and_attaches() {
2214        let mut server = mockito::Server::new_async().await;
2215        let mock = server
2216            .mock("GET", "/org/example/widget/")
2217            .with_status(200)
2218            .with_body(REPO1_FIXTURE)
2219            .expect(1)
2220            .create_async()
2221            .await;
2222
2223        let registry = MavenCentralRegistry::new(Arc::new(HttpCache::new()));
2224        let url = format!("{}/org/example/widget/", server.url());
2225        let times = registry.fetch_publish_times(&url).await;
2226
2227        assert_eq!(
2228            times.get("3.14.0").copied(),
2229            PublishTime::parse_rfc3339("2023-11-18T15:03:00Z")
2230        );
2231        mock.assert_async().await;
2232    }
2233
2234    #[tokio::test]
2235    async fn test_fetch_publish_times_404_degrades_to_empty_map() {
2236        let mut server = mockito::Server::new_async().await;
2237        server
2238            .mock("GET", "/org/example/widget/")
2239            .with_status(404)
2240            .create_async()
2241            .await;
2242
2243        let registry = MavenCentralRegistry::new(Arc::new(HttpCache::new()));
2244        let url = format!("{}/org/example/widget/", server.url());
2245        let times = registry.fetch_publish_times(&url).await;
2246
2247        assert!(times.is_empty());
2248    }
2249
2250    #[tokio::test]
2251    async fn test_fetch_publish_times_500_degrades_to_empty_map() {
2252        let mut server = mockito::Server::new_async().await;
2253        server
2254            .mock("GET", "/org/example/widget/")
2255            .with_status(500)
2256            .create_async()
2257            .await;
2258
2259        let registry = MavenCentralRegistry::new(Arc::new(HttpCache::new()));
2260        let url = format!("{}/org/example/widget/", server.url());
2261        let times = registry.fetch_publish_times(&url).await;
2262
2263        assert!(times.is_empty());
2264    }
2265
2266    // --- get_versions_typed_with: end-to-end gating and degradation on the metadata path ---
2267
2268    #[tokio::test]
2269    async fn test_get_versions_typed_with_invalid_name_short_circuits_before_any_request() {
2270        // No colon in the name => `metadata_urls` returns empty and `get_metadata` never
2271        // issues a request at all, so this also exercises `get_versions_typed`'s delegation
2272        // to `get_versions_typed_with(name, false)` (M1) without needing a network mock.
2273        let registry = MavenCentralRegistry::new(Arc::new(HttpCache::new()));
2274        assert!(
2275            registry
2276                .get_versions_typed("bad-name")
2277                .await
2278                .unwrap()
2279                .is_empty()
2280        );
2281        assert!(
2282            registry
2283                .get_versions_typed_with("bad-name", true)
2284                .await
2285                .unwrap()
2286                .is_empty()
2287        );
2288    }
2289
2290    /// #366: unlike the malformed-pair case above, a coordinate rejected by
2291    /// `is_safe_maven_coordinate_segment` must surface as `Err`, not `Ok(vec![])` — this is
2292    /// what lets hover (`registry.get_versions_with(...).ok()?`) return `None` for a
2293    /// rejected coordinate instead of a broken "package not found" hover section.
2294    #[tokio::test]
2295    async fn test_get_versions_typed_dot_segment_artifact_id_returns_err() {
2296        let registry = MavenCentralRegistry::new(Arc::new(HttpCache::new()));
2297        let err = registry
2298            .get_versions_typed("com.example:..")
2299            .await
2300            .expect_err("dot-segment artifactId must be rejected");
2301        assert_matches!(err, DepsError::PackageNotFound { .. });
2302        assert!(err.is_not_found());
2303    }
2304
2305    // --- NFR-006 live verification (real network, run explicitly with `--ignored`) ---
2306
2307    #[tokio::test]
2308    #[ignore]
2309    async fn test_live_maven_central_attaches_publish_times() {
2310        let registry = MavenCentralRegistry::new(Arc::new(HttpCache::new()));
2311        let versions = registry
2312            .get_versions_typed_with("org.apache.commons:commons-lang3", true)
2313            .await
2314            .unwrap();
2315
2316        assert!(!versions.is_empty());
2317        // Maven Central: the listing exists, so at least the most recent releases carry a
2318        // publish date (some very old/legacy entries may not, but recent ones always do).
2319        assert!(versions.iter().take(5).any(|v| v.published_at.is_some()));
2320    }
2321
2322    #[tokio::test]
2323    #[ignore]
2324    async fn test_live_google_maven_never_attaches_publish_times() {
2325        let registry = MavenCentralRegistry::new(Arc::new(HttpCache::new()));
2326        let versions = registry
2327            .get_versions_typed_with("androidx.core:core", true)
2328            .await
2329            .unwrap();
2330
2331        assert!(!versions.is_empty());
2332        // Google Maven's listing 404s by design (§1.3) — the version list itself must be
2333        // unaffected, exactly as before this feature.
2334        assert!(versions.iter().all(|v| v.published_at.is_none()));
2335    }
2336
2337    #[tokio::test]
2338    #[ignore]
2339    async fn test_live_gradle_plugin_portal_never_attaches_publish_times() {
2340        let registry = MavenCentralRegistry::new(Arc::new(HttpCache::new()));
2341        // The Gradle plugin marker artifact for `com.gradle.develocity` (404s on Maven
2342        // Central; verified `repo1` 404 / plugin portal 200 on 2026-08-24) — resolves only
2343        // via the Gradle Plugin Portal fallback.
2344        let versions = registry
2345            .get_versions_typed_with(
2346                "com.gradle.develocity:com.gradle.develocity.gradle.plugin",
2347                true,
2348            )
2349            .await
2350            .unwrap();
2351
2352        assert!(!versions.is_empty());
2353        assert!(versions.iter().all(|v| v.published_at.is_none()));
2354    }
2355}