deps_gitlab_ci/types.rs
1//! GitLab CI dependency and version types.
2
3use deps_core::parser::DependencySource;
4use tower_lsp_server::ls_types::{Range, Uri};
5
6use crate::host::GitlabHost;
7
8/// Which `include:` form a dependency came from.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum IncludeKind {
11 /// `include: - project: org/proj` + `ref:`.
12 Project,
13 /// `include: - component: host/org/proj/name@ref`.
14 Component,
15}
16
17/// Which GitLab REST endpoint a [`crate::registry::GitlabCiRegistry`] route resolves
18/// against.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum EndpointKind {
21 /// `GET /projects/:id/repository/tags` — backs [`IncludeKind::Project`].
22 Tags,
23 /// `GET /projects/:id/releases` — backs [`IncludeKind::Component`] (spec FR-004/FR-007:
24 /// a component version *is* a project Release; a tag with no release is not one).
25 Releases,
26}
27
28impl EndpointKind {
29 /// A stable string discriminator, used as one part of the route's hashed routing key.
30 #[must_use]
31 pub const fn as_str(self) -> &'static str {
32 match self {
33 Self::Tags => "tags",
34 Self::Releases => "releases",
35 }
36 }
37}
38
39/// A dependency's resolved (or not-yet-resolvable) host.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum HostRef {
42 /// A validated, policy-gated host — from a `component:` prefix, or from
43 /// `registries.gitlab_instance_host` (spec FR-011a).
44 Literal(GitlabHost),
45 /// `$CI_SERVER_FQDN` (or another unresolved CI-time variable), or a `project:` include
46 /// with the instance-host setting unset — carries the raw, unresolved expression for
47 /// display only (spec FR-012).
48 Unresolved(String),
49 /// A host that validated successfully but whose route/admission was refused purely by a
50 /// capacity limit (spec §4.6's per-document host cap, or the registry's process-wide
51 /// `MAX_GITLAB_ROUTES` cap) — carries the host's normalized origin. Deliberately distinct
52 /// from [`Self::Unresolved`] (#466 review M-a): the host genuinely *is* determinable, so
53 /// the diagnostic this produces must never suggest `registries.gitlab_instance_host` as
54 /// the fix — a capacity refusal needs fewer distinct hosts/includes, not that setting.
55 CapacityRefused(String),
56}
57
58/// The `(host, endpoint)` pair a dependency resolves against, registered at parse time
59/// under an opaque routing key carried in `DependencySource::AlternateRegistry.index`.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct GitlabRoute {
62 /// Normalized, ASCII-serialized origin (`https://{host}`).
63 pub origin: String,
64 /// Which endpoint this route resolves against.
65 pub endpoint: EndpointKind,
66}
67
68/// How a pin (a `project:` ref, or a `component:` version) is classified.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum PinStyle {
71 /// A 40-character commit SHA.
72 Sha,
73 /// An exact published tag (`project:`) or release name (`component:`).
74 Tag,
75 /// Honest-unknown: not a SHA, not an exact tag/release, not `~latest`, not
76 /// partial-semver-shaped. Also covers a git branch ref.
77 Branch,
78 /// Literal `~latest` (`component:` only) — highest published non-prerelease semver
79 /// release.
80 Latest,
81 /// A partial semantic version, e.g. `1.2` or `1` (`component:` only).
82 Partial,
83}
84
85/// Parsed `include:` dependency from a `.gitlab-ci.yml`-syntax file, with position
86/// tracking.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct GitlabCiDependency {
89 /// Host-qualified when the host is known: `{host}/{project_path}` for [`IncludeKind::Project`],
90 /// `{host}/{project_path}/{component_name}` for [`IncludeKind::Component`]. The bare
91 /// path alone (no host prefix) when [`Self::host`] is [`HostRef::Unresolved`] (spec
92 /// §3.1 — this also means every name-keyed structure is automatically per-instance).
93 pub name: deps_core::PackageName,
94 /// LSP range of the `project:`/`component:` value text.
95 pub name_range: Range,
96 /// Normalized version requirement: the ref/pin text, or `None` for a `project:`
97 /// include with no `ref:` at all (GitLab defaults that to the project's default
98 /// branch, which this crate cannot resolve to a concrete version).
99 pub version_req: Option<deps_core::VersionReq>,
100 /// LSP range of the ref/pin text.
101 pub version_range: Option<Range>,
102 /// The raw literal text, when it differs from `version_req` (unused today — no
103 /// GitLab CI pin form carries a comment-derived requirement the way GitHub Actions'
104 /// SHA-with-comment form does; kept for [`deps_core::ecosystem::Dependency`] parity).
105 pub version_literal: Option<String>,
106 /// Dependency source: [`DependencySource::AlternateRegistry`] when [`Self::host`] is
107 /// known and its route was registered; [`DependencySource::CustomRegistry`] otherwise
108 /// (unresolved host, or a route the process-wide cap refused) — see spec §3.2.
109 pub source: DependencySource,
110 /// Whether the whole include-entry value was written as a plain (unquoted) YAML
111 /// scalar, mirroring `deps-github-actions`'s identical field.
112 pub is_plain_scalar: bool,
113 /// Which `include:` form this dependency came from.
114 pub kind: IncludeKind,
115 /// This dependency's resolved (or not-yet-resolvable) host.
116 pub host: HostRef,
117 /// How the ref/pin is classified; `None` only for a hostless-ref `project:` include
118 /// (no `ref:` key at all).
119 pub pin: Option<PinStyle>,
120 /// The bare `org/sub/proj[/component]` path, without a host prefix — kept for URL
121 /// construction and the registry's own fetch-path use.
122 pub project_path: String,
123}
124
125impl deps_core::ecosystem::Dependency for GitlabCiDependency {
126 fn name(&self) -> &deps_core::PackageName {
127 &self.name
128 }
129
130 fn name_range(&self) -> Range {
131 self.name_range
132 }
133
134 fn version_requirement(&self) -> Option<&deps_core::VersionReq> {
135 self.version_req.as_ref()
136 }
137
138 fn version_range(&self) -> Option<Range> {
139 self.version_range
140 }
141
142 fn source(&self) -> DependencySource {
143 self.source.clone()
144 }
145
146 fn version_literal(&self) -> Option<&str> {
147 self.version_literal.as_deref()
148 }
149
150 fn as_any(&self) -> &dyn std::any::Any {
151 self
152 }
153}
154
155/// Version information for a GitLab CI dependency: a repository tag (`project:`) or a
156/// project release (`component:`).
157#[derive(Debug, Clone)]
158pub struct GitlabCiVersion {
159 /// The tag/release name as published, `v` prefix (or lack of one) kept as-is.
160 pub version: deps_core::ConcreteVersion,
161 /// The commit SHA this tag/release points at.
162 pub sha: String,
163 /// Whether the semver `pre` component is non-empty.
164 pub prerelease: bool,
165 /// `Some(released_at)` for the releases endpoint (free — same response); `None` for
166 /// tags, since a tag's only date is its *commit* date, not a publish date, and using
167 /// it would misreport freshness.
168 pub published_at: Option<deps_core::PublishTime>,
169}
170
171// GitLab exposes no yank/deprecation signal for either endpoint, so `status` is
172// unconditionally `Available` (mirrors `deps-github-actions`'s `GithubActionsVersion`).
173deps_core::impl_version!(GitlabCiVersion {
174 version: version,
175 status: |_v: &GitlabCiVersion| deps_core::RemovalStatus::Available,
176 published_at: published_at,
177 prerelease: |v: &GitlabCiVersion| v.prerelease,
178});
179
180/// Result of parsing a `.gitlab-ci.yml`-syntax file.
181#[derive(Debug)]
182pub struct GitlabCiParseResult {
183 /// Every `include:` dependency found, including ones with an unresolved host (their
184 /// consumers filter on `source()`/hover-visible `HostRef` as usual).
185 pub dependencies: Vec<GitlabCiDependency>,
186 /// Distinct `(route_key, route)` pairs this parse produced, to be registered into the
187 /// shared [`crate::registry::GitlabCiRegistry`] by `GitlabCiEcosystem::parse_manifest`
188 /// before this result is returned (spec §3.2/§4.6's downgrade pass).
189 pub routes: Vec<(String, GitlabRoute)>,
190 /// URI of the parsed file.
191 pub uri: Uri,
192}
193
194impl deps_core::ParseResult for GitlabCiParseResult {
195 fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
196 self.dependencies
197 .iter()
198 .map(|d| d as &dyn deps_core::Dependency)
199 .collect()
200 }
201
202 fn workspace_root(&self) -> Option<&std::path::Path> {
203 None
204 }
205
206 fn uri(&self) -> &Uri {
207 &self.uri
208 }
209
210 fn as_any(&self) -> &dyn std::any::Any {
211 self
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use deps_core::registry::Version;
219 use deps_core::{Dependency, ParseResult};
220 use tower_lsp_server::ls_types::Position;
221
222 fn range() -> Range {
223 Range::new(Position::new(0, 0), Position::new(0, 10))
224 }
225
226 fn dep(host: HostRef, source: DependencySource) -> GitlabCiDependency {
227 GitlabCiDependency {
228 name: "gitlab.com/org/proj".into(),
229 name_range: range(),
230 version_req: Some("v1.0.0".into()),
231 version_range: Some(range()),
232 version_literal: None,
233 source,
234 is_plain_scalar: true,
235 kind: IncludeKind::Project,
236 host,
237 pin: Some(PinStyle::Tag),
238 project_path: "org/proj".to_string(),
239 }
240 }
241
242 #[test]
243 fn test_gitlab_ci_dependency_trait_impl() {
244 let policy = deps_core::net_policy::RegistryAccessPolicy::default();
245 let host = GitlabHost::parse("gitlab.com", &policy).unwrap();
246 let d = dep(
247 HostRef::Literal(host),
248 DependencySource::AlternateRegistry {
249 index: "gitlab:deadbeef".into(),
250 mirrors_crates_io: false,
251 },
252 );
253 assert_eq!(d.name(), "gitlab.com/org/proj");
254 assert_eq!(
255 d.version_requirement().map(deps_core::VersionReq::as_str),
256 Some("v1.0.0")
257 );
258 assert!(matches!(
259 d.source(),
260 DependencySource::AlternateRegistry { .. }
261 ));
262 }
263
264 #[test]
265 fn test_gitlab_ci_version_prerelease() {
266 let stable = GitlabCiVersion {
267 version: "v1.0.0".into(),
268 sha: "a".repeat(40),
269 prerelease: false,
270 published_at: None,
271 };
272 let pre = GitlabCiVersion {
273 version: "v1.0.0-beta.1".into(),
274 sha: "b".repeat(40),
275 prerelease: true,
276 published_at: None,
277 };
278 assert!(!stable.is_prerelease());
279 assert!(pre.is_prerelease());
280 assert!(!stable.removal_status().blocks_resolution());
281 }
282
283 #[test]
284 fn test_parse_result_dependencies_and_uri() {
285 let uri = deps_core::test_util::test_uri("/repo/.gitlab-ci.yml");
286 let result = GitlabCiParseResult {
287 dependencies: vec![dep(
288 HostRef::Unresolved("$CI_SERVER_FQDN".to_string()),
289 DependencySource::CustomRegistry {
290 url: "$CI_SERVER_FQDN".to_string(),
291 },
292 )],
293 routes: vec![],
294 uri,
295 };
296 assert_eq!(result.dependencies().len(), 1);
297 assert!(result.uri().path().as_str().ends_with(".gitlab-ci.yml"));
298 }
299
300 #[test]
301 fn test_endpoint_kind_as_str() {
302 assert_eq!(EndpointKind::Tags.as_str(), "tags");
303 assert_eq!(EndpointKind::Releases.as_str(), "releases");
304 }
305}