Skip to main content

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}