Skip to main content

deps_gitlab_ci/
registry.rs

1//! `GitlabCiRegistry` — the `deps_core::Registry` implementation for GitLab CI.
2//!
3//! Routes every dependency against its `(host, endpoint)` route (spec §7a), a process-wide
4//! route table capped at [`MAX_GITLAB_ROUTES`] (spec §4.6), and a per-host rate-limit gate
5//! (spec §9.3).
6
7use dashmap::DashMap;
8use deps_core::error::{DepsError, Result};
9use deps_core::github::normalize_tag;
10use deps_core::{PackageName, PublishTime};
11use std::any::Any;
12use std::collections::HashSet;
13use std::sync::Arc;
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use crate::client::{
18    GitlabApiClient, GitlabRelease, GitlabTag, MAX_GITLAB_PAGES, gitlab_rate_limit_error,
19    parse_releases_page, parse_tags_page,
20};
21use crate::host::GitlabHost;
22use crate::types::{EndpointKind, GitlabCiVersion, GitlabRoute, PinStyle};
23
24/// Display name for the registry backing GitLab CI version lookups.
25pub const REGISTRY: &str = "GitLab";
26
27/// Upper bound on [`GitlabCiRegistry::routes`]' entry count.
28///
29/// Mirrors `deps_go::registry::MAX_ALTERNATE_REGISTRIES` exactly, including its core
30/// semantics: at capacity a *new* route is simply never registered. Since a route is
31/// `(origin, endpoint)`, this bounds distinct origins at 256 too — the same ceiling
32/// `deps-nuget` already imposes on `HttpCache::trusted_clients` growth.
33pub const MAX_GITLAB_ROUTES: usize = 256;
34
35/// How long [`RateLimitGate`] keeps a host's fetches short-circuiting locally after a
36/// rate-limited/untokened-auth-failure response, before allowing another live request.
37const RATE_LIMIT_COOLDOWN_SECS: u64 = 300;
38
39fn now_epoch_secs() -> u64 {
40    SystemTime::now()
41        .duration_since(UNIX_EPOCH)
42        .map_or(0, |d| d.as_secs())
43}
44
45/// Per-host (not process-wide, spec §9.3) gate: one self-hosted instance rate-limiting must
46/// not disable lookups against `gitlab.com` or any other host.
47#[derive(Debug, Default)]
48struct RateLimitGate {
49    reset_at: AtomicU64,
50}
51
52impl RateLimitGate {
53    fn is_tripped(&self) -> bool {
54        let reset_at = self.reset_at.load(Ordering::Relaxed);
55        reset_at != 0 && now_epoch_secs() < reset_at
56    }
57
58    fn trip(&self) {
59        self.reset_at.store(
60            now_epoch_secs() + RATE_LIMIT_COOLDOWN_SECS,
61            Ordering::Relaxed,
62        );
63    }
64}
65
66/// Per-`PackageName` tag/release-name -> commit-SHA cross-reference.
67///
68/// Mirrors `deps_github_actions::registry::TagIndex` in shape — lets a SHA pin render a
69/// readable `**Resolved**` hover line. Populated from whichever endpoint the route used: tag
70/// names for `project:`, release names for `component:`.
71#[derive(Debug, Default)]
72pub struct TagIndex {
73    pub tag_to_sha: std::collections::HashMap<String, String>,
74    pub sha_to_tag: std::collections::HashMap<String, String>,
75}
76
77/// Maximum number of [`GitlabCiRegistry::tag_index`] entries.
78const MAX_TAG_INDEX_ENTRIES: usize = 256;
79
80fn evict_if_full<V>(map: &DashMap<PackageName, V>, max_entries: usize) {
81    if map.len() < max_entries {
82        return;
83    }
84    let victim = map.iter().next().map(|e| e.key().clone());
85    if let Some(key) = victim {
86        map.remove(&key);
87    }
88}
89
90fn populate_tag_index(
91    index: &DashMap<PackageName, Arc<TagIndex>>,
92    name: &PackageName,
93    versions: &[GitlabCiVersion],
94) {
95    let mut built = TagIndex::default();
96    for v in versions {
97        built
98            .sha_to_tag
99            .entry(v.sha.clone())
100            .or_insert_with(|| v.version.as_str().to_string());
101        built
102            .tag_to_sha
103            .entry(v.version.as_str().to_string())
104            .or_insert_with(|| v.sha.clone());
105    }
106    if !index.contains_key(name) {
107        evict_if_full(index, MAX_TAG_INDEX_ENTRIES);
108    }
109    index.insert(name.clone(), Arc::new(built));
110}
111
112/// Converts a fetched tags page into a newest-first, semver-filtered version list.
113///
114/// Drops any tag whose `commit.id` is not a full 40-hex SHA (security S-3 precedent from
115/// `deps-github-actions`): a `GitlabCiVersion`'s `sha` is later spliced verbatim into hover
116/// text.
117fn tags_to_versions(tags: Vec<GitlabTag>) -> Vec<GitlabCiVersion> {
118    let mut seen = HashSet::new();
119    let mut with_parsed: Vec<(GitlabCiVersion, semver::Version)> = tags
120        .into_iter()
121        .filter_map(|tag| {
122            if !deps_core::lsp_helpers::is_full_sha(&tag.commit.id) {
123                return None;
124            }
125            let normalized = normalize_tag(&tag.name).to_string();
126            let parsed = semver::Version::parse(&normalized).ok()?;
127            if !seen.insert(normalized) {
128                return None;
129            }
130            let prerelease = !parsed.pre.is_empty();
131            Some((
132                GitlabCiVersion {
133                    version: tag.name.into(),
134                    sha: tag.commit.id,
135                    prerelease,
136                    published_at: None,
137                },
138                parsed,
139            ))
140        })
141        .collect();
142    with_parsed.sort_unstable_by(|a, b| b.1.cmp(&a.1));
143    with_parsed.into_iter().map(|(v, _)| v).collect()
144}
145
146/// Converts a fetched releases page into a version list — deliberately **not**
147/// semver-filtered, unlike [`tags_to_versions`]: [`crate::component::resolve_component_pin`]'s
148/// exact-match/branch step (FR-007) must be able to match a release whose name doesn't look
149/// like a version at all (e.g. a release literally named `stable`), so every release with a
150/// valid commit SHA is kept. Semver-parseable entries sort newest-first; non-parseable ones
151/// keep their release-date-descending fetch order and sort after (stable sort).
152fn releases_to_versions(releases: Vec<GitlabRelease>) -> Vec<GitlabCiVersion> {
153    let mut versions: Vec<GitlabCiVersion> = releases
154        .into_iter()
155        .filter_map(|r| {
156            if !deps_core::lsp_helpers::is_full_sha(&r.commit.id) {
157                return None;
158            }
159            let published_at = r
160                .released_at
161                .as_deref()
162                .and_then(PublishTime::parse_rfc3339);
163            let prerelease =
164                semver::Version::parse(normalize_tag(&r.tag_name)).is_ok_and(|v| !v.pre.is_empty());
165            Some(GitlabCiVersion {
166                version: r.tag_name.into(),
167                sha: r.commit.id,
168                prerelease,
169                published_at,
170            })
171        })
172        .collect();
173
174    versions.sort_by(|a, b| {
175        match (
176            semver::Version::parse(normalize_tag(a.version.as_str())),
177            semver::Version::parse(normalize_tag(b.version.as_str())),
178        ) {
179            (Ok(pa), Ok(pb)) => pb.cmp(&pa),
180            (Ok(_), Err(_)) => std::cmp::Ordering::Less,
181            (Err(_), Ok(_)) => std::cmp::Ordering::Greater,
182            (Err(_), Err(_)) => std::cmp::Ordering::Equal,
183        }
184    });
185    versions
186}
187
188/// Derives `project_path` (and, for a [`EndpointKind::Releases`] route, strips the trailing
189/// component-name segment) from a host-qualified `name` (spec §3.1) and the route's bare
190/// host.
191fn project_path_from_name(name: &str, host_bare: &str, endpoint: EndpointKind) -> Option<String> {
192    let remainder = name.strip_prefix(host_bare)?.strip_prefix('/')?;
193    match endpoint {
194        EndpointKind::Tags => Some(remainder.to_string()),
195        EndpointKind::Releases => remainder
196            .rsplit_once('/')
197            .map(|(path, _component)| path.to_string()),
198    }
199}
200
201/// `deps_core::Registry` implementation for GitLab CI, routing every fetch across the
202/// registered `(host, endpoint)` table (spec §7a).
203#[derive(Clone)]
204pub struct GitlabCiRegistry {
205    client: Arc<GitlabApiClient>,
206    routes: Arc<DashMap<String, GitlabRoute>>,
207    rate_limits: Arc<DashMap<String, Arc<RateLimitGate>>>,
208    tag_index: Arc<DashMap<PackageName, Arc<TagIndex>>>,
209}
210
211impl GitlabCiRegistry {
212    /// Creates a new registry sharing `client`.
213    #[must_use]
214    pub fn new(client: Arc<GitlabApiClient>) -> Self {
215        Self {
216            client,
217            routes: Arc::new(DashMap::new()),
218            rate_limits: Arc::new(DashMap::new()),
219            tag_index: Arc::new(DashMap::new()),
220        }
221    }
222
223    /// Shares this registry's [`TagIndex`] map with [`crate::formatter::GitlabCiFormatter`].
224    #[must_use]
225    pub fn tag_index(&self) -> Arc<DashMap<PackageName, Arc<TagIndex>>> {
226        Arc::clone(&self.tag_index)
227    }
228
229    /// Shares this registry's route table with [`crate::formatter::GitlabCiFormatter`],
230    /// which needs it to distinguish a `project:` (Tags) route from a `component:`
231    /// (Releases) route for the hover-heading-link carve-out (spec §8.2).
232    #[must_use]
233    pub fn routes(&self) -> Arc<DashMap<String, GitlabRoute>> {
234        Arc::clone(&self.routes)
235    }
236
237    /// Registers every `(route_key, route)` pair in `routes` (idempotent per key), capacity-
238    /// capped at [`MAX_GITLAB_ROUTES`], and returns the set of `route_key`s that were
239    /// refused because the cap was already reached (spec §3.2/§4.6's downgrade pass —
240    /// `GitlabCiEcosystem::parse_manifest` rewrites every dependency carrying a refused key
241    /// to `CustomRegistry` + `HostRef::Unresolved` before returning the parse result).
242    pub fn register_routes(&self, routes: &[(String, GitlabRoute)]) -> HashSet<String> {
243        let mut refused = HashSet::new();
244        let mut warned_once = false;
245        for (key, route) in routes {
246            if self.routes.contains_key(key) {
247                continue;
248            }
249            if self.routes.len() >= MAX_GITLAB_ROUTES {
250                if !warned_once {
251                    tracing::warn!(
252                        cap = MAX_GITLAB_ROUTES,
253                        "GitLab CI route cap reached; not registering further routes"
254                    );
255                    warned_once = true;
256                }
257                refused.insert(key.clone());
258                continue;
259            }
260            self.routes.insert(key.clone(), route.clone());
261        }
262        refused
263    }
264
265    fn rate_limit_gate(&self, origin: &str) -> Arc<RateLimitGate> {
266        if let Some(existing) = self.rate_limits.get(origin) {
267            return Arc::clone(&existing);
268        }
269        Arc::clone(
270            self.rate_limits
271                .entry(origin.to_string())
272                .or_insert_with(|| Arc::new(RateLimitGate::default()))
273                .value(),
274        )
275    }
276
277    /// Maps a raw fetch error to its `DepsError` classification (spec §9.3 — GitLab's status
278    /// semantics differ from GitHub's, so this is not a verbatim copy of
279    /// `GithubActionsRegistry::map_tags_error`'s arms) and, for a `429` or an untokened
280    /// `401`/`403`, trips `origin`'s rate-limit gate so every other dependency on the same
281    /// host short-circuits locally instead of also firing (and losing) its own doomed
282    /// request.
283    ///
284    /// H3 (#466 review): a bare `400` reaching this point means
285    /// [`crate::client::GitlabApiClient::fetch_tags_page`]'s own `order_by=version` fallback
286    /// (which now covers every page, not just the first) still didn't resolve it — some
287    /// other client-side request defect (an unroutable/malformed project path). Classified as
288    /// [`DepsError::PackageNotFound`], the same graceful, actionable outcome a `404` already
289    /// gets, rather than an opaque transport error that aborts the whole version list.
290    fn map_error(&self, origin: &str, name: &str, e: DepsError) -> DepsError {
291        match &e {
292            DepsError::HttpStatus { status: 429, .. } => {
293                self.rate_limit_gate(origin).trip();
294                gitlab_rate_limit_error()
295            }
296            // A *tokened* 401/403 is scoped to this one project (private/insufficient-scope),
297            // not treated as a workspace-wide outage — mirrors
298            // `deps_github_actions::registry::GithubActionsRegistry::map_tags_error`'s
299            // identical `has_token()` split.
300            DepsError::HttpStatus {
301                status: 401 | 403, ..
302            } if !self.client.has_token() => {
303                self.rate_limit_gate(origin).trip();
304                gitlab_rate_limit_error()
305            }
306            DepsError::HttpStatus {
307                status: 404 | 400, ..
308            } => DepsError::PackageNotFound {
309                package: name.to_string(),
310                registry: REGISTRY,
311            },
312            _ => e,
313        }
314    }
315
316    /// Fetches and converts the version list for `name` via `route`, gated by the route's
317    /// origin-scoped rate-limit gate.
318    async fn fetch_route(
319        &self,
320        name: &PackageName,
321        route: &GitlabRoute,
322    ) -> Result<Vec<GitlabCiVersion>> {
323        let gate = self.rate_limit_gate(&route.origin);
324        if gate.is_tripped() {
325            return Err(gitlab_rate_limit_error());
326        }
327
328        let host = GitlabHost::trusted(&route.origin);
329        let Some(project_path) = project_path_from_name(name.as_str(), host.host(), route.endpoint)
330        else {
331            return Err(DepsError::PackageNotFound {
332                package: name.to_string(),
333                registry: REGISTRY,
334            });
335        };
336
337        let versions = match route.endpoint {
338            EndpointKind::Tags => {
339                let client = &self.client;
340                let path = project_path.as_str();
341                let tags = deps_core::pagination::paginate_pages(
342                    "GitLab",
343                    "GitLab CI",
344                    "tags",
345                    name.as_str(),
346                    MAX_GITLAB_PAGES,
347                    |page| client.fetch_tags_page(&host, path, page),
348                    |data| parse_tags_page(data),
349                )
350                .await
351                .map_err(|e| self.map_error(&route.origin, name.as_str(), e))?;
352                tags_to_versions(tags)
353            }
354            EndpointKind::Releases => {
355                let client = &self.client;
356                let path = project_path.as_str();
357                let releases = deps_core::pagination::paginate_pages(
358                    "GitLab",
359                    "GitLab CI",
360                    "releases",
361                    name.as_str(),
362                    MAX_GITLAB_PAGES,
363                    |page| client.fetch_releases_page(&host, path, page),
364                    |data| parse_releases_page(data),
365                )
366                .await
367                .map_err(|e| self.map_error(&route.origin, name.as_str(), e))?;
368                releases_to_versions(releases)
369            }
370        };
371
372        populate_tag_index(&self.tag_index, name, &versions);
373        Ok(versions)
374    }
375
376    /// Resolves a `component:` include's already-classified pin against `route`'s
377    /// published releases via spec FR-007's priority ladder
378    /// ([`crate::component::resolve_component_pin`]) — H1 (#466 review): this is the real
379    /// production call path that ladder was missing; previously it was exercised only by its
380    /// own unit tests.
381    ///
382    /// Fetches (and caches, via the same `Self::fetch_route` path `get_versions_from`
383    /// uses) `route`'s full release list, never the raw tag list (FR-004/FR-007) — an
384    /// unreleased tag is not a usable component version. `route.endpoint` is expected to be
385    /// [`EndpointKind::Releases`]; called with a [`EndpointKind::Tags`] route this still
386    /// returns a result (the shared ladder degrades to its `Sha`/`Tag`/`Branch` arms, which
387    /// are valid for a tag list too), but callers only need it for `component:` includes —
388    /// a `project:` include's SHA pin already resolves via [`Self::tag_index`] with no extra
389    /// fetch.
390    ///
391    /// # Errors
392    ///
393    /// Propagates the underlying fetch error unchanged (rate limit, not-found, etc).
394    pub async fn resolve_component_pin(
395        &self,
396        name: &PackageName,
397        route: &GitlabRoute,
398        pin: &PinStyle,
399        raw: &str,
400    ) -> Result<Option<GitlabCiVersion>> {
401        let releases = self.fetch_route(name, route).await?;
402        Ok(crate::component::resolve_component_pin(pin, raw, &releases))
403    }
404}
405
406impl deps_core::Registry for GitlabCiRegistry {
407    /// Unconditional [`DepsError::PackageNotFound`], never a host guess (spec §7a) — this
408    /// crate's dependencies are only ever routable via `get_versions_from`'s
409    /// `AlternateRegistry` dispatch; a `&PackageName` alone carries no host.
410    fn get_versions<'a>(
411        &'a self,
412        name: &'a PackageName,
413    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Version>>>> {
414        Box::pin(async move {
415            Err(DepsError::PackageNotFound {
416                package: name.to_string(),
417                registry: "gitlab-ci (no route; source required)",
418            })
419        })
420    }
421
422    fn get_versions_from<'a>(
423        &'a self,
424        name: &'a PackageName,
425        source: &'a deps_core::parser::DependencySource,
426        _freshness: deps_core::FreshnessSettings,
427    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Version>>>> {
428        Box::pin(async move {
429            let deps_core::parser::DependencySource::AlternateRegistry { index, .. } = source
430            else {
431                return Err(DepsError::PackageNotFound {
432                    package: name.to_string(),
433                    registry: "gitlab-ci (no route; source required)",
434                });
435            };
436            let Some(route) = self.routes.get(index).map(|r| r.clone()) else {
437                return Err(DepsError::PackageNotFound {
438                    package: name.to_string(),
439                    registry: "gitlab-ci (unregistered route)",
440                });
441            };
442            let versions = self.fetch_route(name, &route).await?;
443            Ok(versions
444                .into_iter()
445                .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
446                .collect())
447        })
448    }
449
450    /// Unconditional [`DepsError::PackageNotFound`] — this trait method's counterpart to
451    /// `get_versions`; equally unroutable without a source.
452    fn get_latest_matching<'a>(
453        &'a self,
454        name: &'a PackageName,
455        _req: &'a deps_core::VersionReq,
456    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Option<Box<dyn deps_core::Version>>>> {
457        Box::pin(async move {
458            Err(DepsError::PackageNotFound {
459                package: name.to_string(),
460                registry: "gitlab-ci (no route; source required)",
461            })
462        })
463    }
464
465    fn get_latest_matching_from<'a>(
466        &'a self,
467        name: &'a PackageName,
468        source: &'a deps_core::parser::DependencySource,
469        req: &'a deps_core::VersionReq,
470        _minimum_stability: Option<&'a str>,
471    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Option<Box<dyn deps_core::Version>>>> {
472        Box::pin(async move {
473            let versions: Vec<Box<dyn deps_core::Version>> = self
474                .get_versions_from(name, source, deps_core::FreshnessSettings::default())
475                .await?;
476            let idx = self.select_latest_matching(&versions, req);
477            Ok(idx.and_then(|i| versions.into_iter().nth(i)))
478        })
479    }
480
481    /// No cheap GitLab search endpoint under the NFR-002 rate-limit budget.
482    fn search<'a>(
483        &'a self,
484        _query: &'a str,
485        _limit: usize,
486    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Metadata>>>> {
487        Box::pin(async move { Ok(vec![]) })
488    }
489
490    /// M-b (#466 review): a bare partial-shaped requirement (`"1.2"`) is parsed with
491    /// GitLab's own tilde semantics via [`crate::component::gitlab_version_req`], not the
492    /// `semver` crate's implicit-caret default — and a literal `~latest` (which
493    /// `semver::VersionReq::parse` cannot parse at all, since `"latest"` is not a version)
494    /// is special-cased to pick the highest non-prerelease version, mirroring
495    /// [`crate::component::resolve_component_pin`]'s `Latest` arm.
496    fn select_latest_matching(
497        &self,
498        versions: &[Box<dyn deps_core::Version>],
499        req: &deps_core::VersionReq,
500    ) -> Option<usize> {
501        if deps_core::is_existence_wildcard(req) {
502            return deps_core::select_latest_for_existence(versions, |v| v.as_ref());
503        }
504        if req.as_str() == crate::component::LATEST {
505            return versions
506                .iter()
507                .enumerate()
508                .filter(|(_, v)| !v.is_prerelease())
509                .filter_map(|(i, v)| {
510                    semver::Version::parse(normalize_tag(v.version_string().as_str()))
511                        .ok()
512                        .map(|ver| (i, ver))
513                })
514                .max_by(|(_, a), (_, b)| a.cmp(b))
515                .map(|(i, _)| i);
516        }
517        let parsed_req = crate::component::gitlab_version_req(req.as_str())?;
518        versions.iter().position(|v| {
519            semver::Version::parse(normalize_tag(v.version_string().as_str()))
520                .is_ok_and(|ver| parsed_req.matches(&ver))
521        })
522    }
523
524    /// GitLab exposes no yank/deprecation signal for either endpoint.
525    fn reports_yanked(&self) -> bool {
526        false
527    }
528
529    fn as_any(&self) -> &dyn Any {
530        self
531    }
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537    use crate::host::GitlabInstanceHost;
538    use deps_core::Registry;
539    use deps_core::net_policy::RegistryAccessPolicy;
540    use deps_core::parser::DependencySource;
541    use std::sync::RwLock;
542
543    /// A client backed by an in-memory cache and no configured instance host — sufficient
544    /// for every test in this module that never actually issues a live request (route-table
545    /// and error-mapping tests), and reused as the base for tests that do via `mockito`.
546    fn test_client() -> Arc<GitlabApiClient> {
547        let policy = Arc::new(RegistryAccessPolicy::default());
548        let instance_host = Arc::new(GitlabInstanceHost::new(Arc::new(RwLock::new(None)), policy));
549        Arc::new(GitlabApiClient::new(
550            Arc::new(deps_core::HttpCache::new()),
551            instance_host,
552        ))
553    }
554
555    fn route(origin: &str, endpoint: EndpointKind) -> GitlabRoute {
556        GitlabRoute {
557            origin: origin.to_string(),
558            endpoint,
559        }
560    }
561
562    // --- get_versions / get_latest_matching: unconditional PackageNotFound (no route) ---
563
564    #[tokio::test]
565    async fn test_get_versions_unconditional_package_not_found() {
566        let registry = GitlabCiRegistry::new(test_client());
567        let name = PackageName::new("gitlab.com/org/proj");
568        match registry.get_versions(&name).await {
569            Err(e) => assert!(matches!(e, DepsError::PackageNotFound { .. })),
570            Ok(_) => panic!("expected PackageNotFound"),
571        }
572    }
573
574    #[tokio::test]
575    async fn test_get_latest_matching_unconditional_package_not_found() {
576        let registry = GitlabCiRegistry::new(test_client());
577        let name = PackageName::new("gitlab.com/org/proj");
578        let req = deps_core::VersionReq::new("*");
579        match registry.get_latest_matching(&name, &req).await {
580            Err(e) => assert!(matches!(e, DepsError::PackageNotFound { .. })),
581            Ok(_) => panic!("expected PackageNotFound"),
582        }
583    }
584
585    /// An unrouted `AlternateRegistry` index must be `PackageNotFound` even when the route
586    /// table already has *other* entries registered — proving the lookup is keyed on the
587    /// exact index, not e.g. falling through to whatever route happens to be registered.
588    #[tokio::test]
589    async fn test_get_versions_from_unrouted_index_is_package_not_found_even_with_other_routes() {
590        let registry = GitlabCiRegistry::new(test_client());
591        let refused = registry.register_routes(&[(
592            "gitlab:known".to_string(),
593            route("https://gitlab.com", EndpointKind::Tags),
594        )]);
595        assert!(refused.is_empty());
596
597        let name = PackageName::new("gitlab.com/org/other");
598        let source = DependencySource::AlternateRegistry {
599            index: "gitlab:unknown".to_string(),
600            mirrors_crates_io: false,
601        };
602        match registry
603            .get_versions_from(&name, &source, deps_core::FreshnessSettings::default())
604            .await
605        {
606            Err(e) => assert!(matches!(e, DepsError::PackageNotFound { .. })),
607            Ok(_) => panic!("expected PackageNotFound"),
608        }
609
610        let req = deps_core::VersionReq::new("*");
611        match registry
612            .get_latest_matching_from(&name, &source, &req, None)
613            .await
614        {
615            Err(e) => assert!(matches!(e, DepsError::PackageNotFound { .. })),
616            Ok(_) => panic!("expected PackageNotFound"),
617        }
618    }
619
620    // --- register_routes: MAX_GITLAB_ROUTES cap refusal ---
621
622    #[test]
623    fn test_register_routes_refuses_route_257() {
624        let registry = GitlabCiRegistry::new(test_client());
625        let routes: Vec<(String, GitlabRoute)> = (0..=MAX_GITLAB_ROUTES)
626            .map(|i| {
627                (
628                    format!("gitlab:route{i}"),
629                    route(&format!("https://host{i}.example.com"), EndpointKind::Tags),
630                )
631            })
632            .collect();
633        assert_eq!(routes.len(), MAX_GITLAB_ROUTES + 1);
634
635        let refused = registry.register_routes(&routes);
636
637        assert_eq!(refused.len(), 1);
638        assert!(refused.contains(&format!("gitlab:route{MAX_GITLAB_ROUTES}")));
639        assert_eq!(registry.routes.len(), MAX_GITLAB_ROUTES);
640    }
641
642    #[test]
643    fn test_register_routes_under_cap_refuses_nothing() {
644        let registry = GitlabCiRegistry::new(test_client());
645        let routes: Vec<(String, GitlabRoute)> = (0..MAX_GITLAB_ROUTES)
646            .map(|i| {
647                (
648                    format!("gitlab:route{i}"),
649                    route(&format!("https://host{i}.example.com"), EndpointKind::Tags),
650                )
651            })
652            .collect();
653        let refused = registry.register_routes(&routes);
654        assert!(refused.is_empty());
655        assert_eq!(registry.routes.len(), MAX_GITLAB_ROUTES);
656    }
657
658    // --- endpoint dispatch: a Releases route calls /releases, never /repository/tags ---
659
660    #[tokio::test]
661    async fn test_releases_route_dispatch_calls_releases_never_tags() {
662        let mut server = mockito::Server::new_async().await;
663        let sha = "a".repeat(40);
664        let releases_mock = server
665            .mock("GET", "/api/v4/projects/org%2Fproj/releases")
666            .match_query(mockito::Matcher::Any)
667            .with_status(200)
668            .with_body(format!(
669                r#"[{{"tag_name":"1.0.0","commit":{{"id":"{sha}"}}}}]"#
670            ))
671            .create_async()
672            .await;
673        let tags_mock = server
674            .mock("GET", "/api/v4/projects/org%2Fproj/repository/tags")
675            .match_query(mockito::Matcher::Any)
676            .with_status(200)
677            .with_body("[]")
678            .expect(0)
679            .create_async()
680            .await;
681
682        let registry = GitlabCiRegistry::new(test_client());
683        let host_bare = server.url();
684        let gitlab_route = route(&host_bare, EndpointKind::Releases);
685        let name = PackageName::new(format!("{host_bare}/org/proj/comp"));
686
687        let versions = registry.fetch_route(&name, &gitlab_route).await.unwrap();
688
689        assert_eq!(versions.len(), 1);
690        assert_eq!(versions[0].version.as_str(), "1.0.0");
691        releases_mock.assert_async().await;
692        tags_mock.assert_async().await;
693    }
694
695    #[tokio::test]
696    async fn test_tags_route_dispatch_calls_tags_never_releases() {
697        let mut server = mockito::Server::new_async().await;
698        let sha = "a".repeat(40);
699        let tags_mock = server
700            .mock("GET", "/api/v4/projects/org%2Fproj/repository/tags")
701            .match_query(mockito::Matcher::Any)
702            .with_status(200)
703            .with_body(format!(r#"[{{"name":"1.0.0","commit":{{"id":"{sha}"}}}}]"#))
704            .create_async()
705            .await;
706        let releases_mock = server
707            .mock("GET", "/api/v4/projects/org%2Fproj/releases")
708            .match_query(mockito::Matcher::Any)
709            .with_status(200)
710            .with_body("[]")
711            .expect(0)
712            .create_async()
713            .await;
714
715        let registry = GitlabCiRegistry::new(test_client());
716        let host_bare = server.url();
717        let gitlab_route = route(&host_bare, EndpointKind::Tags);
718        let name = PackageName::new(format!("{host_bare}/org/proj"));
719
720        let versions = registry.fetch_route(&name, &gitlab_route).await.unwrap();
721
722        assert_eq!(versions.len(), 1);
723        tags_mock.assert_async().await;
724        releases_mock.assert_async().await;
725    }
726
727    // --- resolve_component_pin: H1 (#466 review) — FR-007 wired to a real caller ---
728
729    #[tokio::test]
730    async fn test_resolve_component_pin_partial_picks_highest_matching_release() {
731        let mut server = mockito::Server::new_async().await;
732        let sha_a = "a".repeat(40);
733        let sha_b = "b".repeat(40);
734        let sha_c = "c".repeat(40);
735        let _releases_mock = server
736            .mock("GET", "/api/v4/projects/org%2Fproj/releases")
737            .match_query(mockito::Matcher::Any)
738            .with_status(200)
739            .with_body(format!(
740                r#"[{{"tag_name":"1.2.0","commit":{{"id":"{sha_a}"}}}},
741                    {{"tag_name":"1.2.5","commit":{{"id":"{sha_b}"}}}},
742                    {{"tag_name":"1.3.0","commit":{{"id":"{sha_c}"}}}}]"#
743            ))
744            .create_async()
745            .await;
746
747        let registry = GitlabCiRegistry::new(test_client());
748        let host_bare = server.url();
749        let gitlab_route = route(&host_bare, EndpointKind::Releases);
750        let name = PackageName::new(format!("{host_bare}/org/proj/comp"));
751
752        let resolved = registry
753            .resolve_component_pin(&name, &gitlab_route, &PinStyle::Partial, "1.2")
754            .await
755            .unwrap()
756            .expect("a matching release exists");
757
758        assert_eq!(resolved.version.as_str(), "1.2.5");
759        assert_eq!(resolved.sha, sha_b);
760    }
761
762    #[tokio::test]
763    async fn test_resolve_component_pin_no_match_returns_none() {
764        let mut server = mockito::Server::new_async().await;
765        let sha = "a".repeat(40);
766        let _releases_mock = server
767            .mock("GET", "/api/v4/projects/org%2Fproj/releases")
768            .match_query(mockito::Matcher::Any)
769            .with_status(200)
770            .with_body(format!(
771                r#"[{{"tag_name":"2.0.0","commit":{{"id":"{sha}"}}}}]"#
772            ))
773            .create_async()
774            .await;
775
776        let registry = GitlabCiRegistry::new(test_client());
777        let host_bare = server.url();
778        let gitlab_route = route(&host_bare, EndpointKind::Releases);
779        let name = PackageName::new(format!("{host_bare}/org/proj/comp"));
780
781        let resolved = registry
782            .resolve_component_pin(&name, &gitlab_route, &PinStyle::Partial, "1.2")
783            .await
784            .unwrap();
785        assert!(resolved.is_none());
786    }
787
788    // --- map_error ---
789
790    #[test]
791    fn test_map_error_404_is_package_not_found() {
792        let registry = GitlabCiRegistry::new(test_client());
793        let err = registry.map_error(
794            "https://gitlab.com",
795            "org/proj",
796            DepsError::HttpStatus {
797                url: "https://gitlab.com/x".into(),
798                status: 404,
799            },
800        );
801        assert!(matches!(err, DepsError::PackageNotFound { .. }));
802    }
803
804    /// H3 (#466 review): a residual `400` (the client-level `order_by=version` fallback
805    /// already exhausted) degrades to `PackageNotFound`, not a raw, unclassified transport
806    /// error.
807    #[test]
808    fn test_map_error_400_is_package_not_found() {
809        let registry = GitlabCiRegistry::new(test_client());
810        let err = registry.map_error(
811            "https://gitlab.com",
812            "org/proj",
813            DepsError::HttpStatus {
814                url: "https://gitlab.com/x".into(),
815                status: 400,
816            },
817        );
818        assert!(matches!(err, DepsError::PackageNotFound { .. }));
819    }
820
821    #[test]
822    fn test_map_error_429_trips_rate_limit_gate() {
823        let registry = GitlabCiRegistry::new(test_client());
824        let err = registry.map_error(
825            "https://gitlab.com",
826            "org/proj",
827            DepsError::HttpStatus {
828                url: "https://gitlab.com/x".into(),
829                status: 429,
830            },
831        );
832        assert!(matches!(err, DepsError::RateLimited { .. }));
833        assert!(registry.rate_limit_gate("https://gitlab.com").is_tripped());
834    }
835
836    // --- select_latest_matching: M-b partial-pin and ~latest semantics ---
837
838    fn version(v: &str, prerelease: bool) -> Box<dyn deps_core::Version> {
839        Box::new(GitlabCiVersion {
840            version: v.into(),
841            sha: "a".repeat(40),
842            prerelease,
843            published_at: None,
844        })
845    }
846
847    #[test]
848    fn test_select_latest_matching_partial_pin_uses_tilde_not_caret() {
849        let registry = GitlabCiRegistry::new(test_client());
850        // Newest-first, mirroring `releases_to_versions`/`tags_to_versions`'s actual
851        // output order — `select_latest_matching` is a first-match `position()` over an
852        // already-sorted list, not a max-search.
853        let versions = vec![
854            version("1.3.0", false),
855            version("1.2.5", false),
856            version("1.2.0", false),
857        ];
858        // GitLab tilde semantics: "1.2" matches only 1.2.*, never 1.3.0 the way an implicit
859        // caret (`^1.2`) would.
860        let idx = registry
861            .select_latest_matching(&versions, &deps_core::VersionReq::new("1.2"))
862            .unwrap();
863        assert_eq!(versions[idx].version_string().as_str(), "1.2.5");
864    }
865
866    #[test]
867    fn test_select_latest_matching_tilde_latest_picks_highest_non_prerelease() {
868        let registry = GitlabCiRegistry::new(test_client());
869        let versions = vec![
870            version("1.0.0", false),
871            version("2.0.0", false),
872            version("3.0.0-beta.1", true),
873        ];
874        let idx = registry
875            .select_latest_matching(&versions, &deps_core::VersionReq::new("~latest"))
876            .unwrap();
877        assert_eq!(versions[idx].version_string().as_str(), "2.0.0");
878    }
879}