1use 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
24pub const REGISTRY: &str = "GitLab";
26
27pub const MAX_GITLAB_ROUTES: usize = 256;
34
35const 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#[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#[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
77const 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
112fn 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
146fn 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
188fn 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#[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 #[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 #[must_use]
225 pub fn tag_index(&self) -> Arc<DashMap<PackageName, Arc<TagIndex>>> {
226 Arc::clone(&self.tag_index)
227 }
228
229 #[must_use]
233 pub fn routes(&self) -> Arc<DashMap<String, GitlabRoute>> {
234 Arc::clone(&self.routes)
235 }
236
237 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 let versions = vec![
854 version("1.3.0", false),
855 version("1.2.5", false),
856 version("1.2.0", false),
857 ];
858 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}