deps_gitlab_ci/component.rs
1//! `component:` include pin classification and resolution.
2//!
3//! Two distinct steps, deliberately kept separate:
4//!
5//! - [`classify_component_pin_style`] — a pure, shape-only classification of the raw pin
6//! text, computed at parse time with no registry access (mirrors
7//! `deps-github-actions`'s `is_tag_shaped` — same documented trade-off: a textual release
8//! name that doesn't look like a version is classified [`crate::types::PinStyle::Branch`]
9//! even when it later turns out to exactly match a published release).
10//! - [`resolve_component_pin`] — the FR-007 priority-ladder resolution against the
11//! project's **published releases** (never the raw tag list — an unreleased tag is not a
12//! usable component version), run once release data is actually fetched.
13
14use crate::types::{GitlabCiVersion, PinStyle};
15use deps_core::github::normalize_tag;
16use deps_core::lsp_helpers::{is_full_sha, is_tag_shaped};
17
18/// Literal `~latest` pin text (spec FR-007).
19pub(crate) const LATEST: &str = "~latest";
20
21/// Whether `raw` (after an optional `v`/`V` strip) is 1 or 2 dot-separated all-ASCII-digit
22/// segments — GitLab's partial-semver component-pin shape (`1`, `1.2`, `v1.2`).
23fn is_partial_semver_shaped(raw: &str) -> bool {
24 let normalized = normalize_tag(raw);
25 let parts: Vec<&str> = normalized.split('.').collect();
26 (1..=2).contains(&parts.len())
27 && parts
28 .iter()
29 .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
30}
31
32/// Classifies a `component:` pin's raw text by shape alone.
33///
34/// # Examples
35///
36/// ```
37/// use deps_gitlab_ci::component::classify_component_pin_style;
38/// use deps_gitlab_ci::PinStyle;
39///
40/// assert_eq!(classify_component_pin_style(&"a".repeat(40)), PinStyle::Sha);
41/// assert_eq!(classify_component_pin_style("~latest"), PinStyle::Latest);
42/// assert_eq!(classify_component_pin_style("1.2"), PinStyle::Partial);
43/// assert_eq!(classify_component_pin_style("1.0.0"), PinStyle::Tag);
44/// assert_eq!(classify_component_pin_style("some-branch"), PinStyle::Branch);
45/// ```
46#[must_use]
47pub fn classify_component_pin_style(raw: &str) -> PinStyle {
48 if is_full_sha(raw) {
49 PinStyle::Sha
50 } else if raw == LATEST {
51 PinStyle::Latest
52 } else if is_partial_semver_shaped(raw) {
53 PinStyle::Partial
54 } else if is_tag_shaped(raw) {
55 PinStyle::Tag
56 } else {
57 PinStyle::Branch
58 }
59}
60
61/// Builds the `semver::VersionReq` a partial-version pin (`1`, `1.2`) desugars to:
62/// `~{normalized}` — `~1.2` matches `>=1.2.0, <1.3.0` (highest published `1.2.*`), `~1`
63/// matches `>=1.0.0, <2.0.0` (highest published `1.*.*`), exactly GitLab's documented
64/// semantics for this pin form.
65fn partial_version_req(raw: &str) -> Option<semver::VersionReq> {
66 semver::VersionReq::parse(&format!("~{}", normalize_tag(raw))).ok()
67}
68
69/// Parses `raw` as a `semver::VersionReq`, applying GitLab's own partial-pin semantics.
70///
71/// Spec plan §7, rather than the `semver` crate's implicit-caret default for a bare
72/// version: a partial-semver-shaped `raw` (`1`, `1.2`) desugars via `partial_version_req`
73/// (`~{raw}`), everything else parses through `semver::VersionReq::parse` unchanged.
74///
75/// Shared by [`resolve_component_pin`]'s `Partial` arm (via `partial_version_req`
76/// directly, since it already knows the pin is partial-shaped) and
77/// `GitlabCiRegistry::select_latest_matching`'s generic requirement
78/// parsing, so the two paths cannot silently diverge on what a bare `"1.2"` means (#466
79/// review M-b).
80///
81/// # Examples
82///
83/// ```
84/// use deps_gitlab_ci::component::gitlab_version_req;
85///
86/// // GitLab's tilde semantics for a bare partial pin, not the semver crate's caret default.
87/// let req = gitlab_version_req("1.2").unwrap();
88/// assert!(req.matches(&semver::Version::parse("1.2.9").unwrap()));
89/// assert!(!req.matches(&semver::Version::parse("1.3.0").unwrap()));
90///
91/// // A non-partial-shaped requirement (an explicit operator, or already a full version)
92/// // parses through `semver::VersionReq` unchanged.
93/// let range = gitlab_version_req(">=1.2.0, <1.3.0").unwrap();
94/// assert!(range.matches(&semver::Version::parse("1.2.9").unwrap()));
95/// assert!(!range.matches(&semver::Version::parse("1.3.0").unwrap()));
96/// ```
97#[must_use]
98pub fn gitlab_version_req(raw: &str) -> Option<semver::VersionReq> {
99 if is_partial_semver_shaped(raw) {
100 partial_version_req(raw)
101 } else {
102 semver::VersionReq::parse(normalize_tag(raw)).ok()
103 }
104}
105
106/// Resolves `raw` (a `component:` pin, already classified as `pin`) against `releases`.
107///
108/// `releases` must be the project's published CI/CD Catalog releases (spec FR-007's
109/// priority order: SHA > exact release > branch > `~latest` > partial semver). A tag that
110/// exists in the repository but was never published as a release is never a
111/// candidate here — `releases` must already be the `/releases` response, never
112/// `/repository/tags` (FR-004).
113///
114/// Returns `None` when nothing in `releases` matches — the honest "unresolvable" outcome for
115/// a [`PinStyle::Branch`] pin, or for any pin naming a release/commit that doesn't exist.
116///
117/// # Examples
118///
119/// ```
120/// use deps_gitlab_ci::PinStyle;
121/// use deps_gitlab_ci::component::resolve_component_pin;
122/// use deps_gitlab_ci::GitlabCiVersion;
123///
124/// let releases = vec![GitlabCiVersion {
125/// version: "1.2.0".into(),
126/// sha: "a".repeat(40),
127/// prerelease: false,
128/// published_at: None,
129/// }];
130/// let resolved = resolve_component_pin(&PinStyle::Tag, "1.2.0", &releases).unwrap();
131/// assert_eq!(resolved.version.as_str(), "1.2.0");
132/// ```
133#[must_use]
134pub fn resolve_component_pin(
135 pin: &PinStyle,
136 raw: &str,
137 releases: &[GitlabCiVersion],
138) -> Option<GitlabCiVersion> {
139 match pin {
140 PinStyle::Sha => releases.iter().find(|r| r.sha == raw).cloned(),
141 // An exact release match always wins regardless of the parse-time shape guess —
142 // FR-007's priority order puts it ahead of the "branch" honest-unknown, and the
143 // registry is the only place this can actually be verified.
144 PinStyle::Tag | PinStyle::Branch => {
145 releases.iter().find(|r| r.version.as_str() == raw).cloned()
146 }
147 PinStyle::Latest => releases
148 .iter()
149 .filter(|r| !r.prerelease)
150 .filter_map(|r| {
151 semver::Version::parse(normalize_tag(r.version.as_str()))
152 .ok()
153 .map(|v| (v, r))
154 })
155 .max_by(|(a, _), (b, _)| a.cmp(b))
156 .map(|(_, r)| r.clone()),
157 PinStyle::Partial => {
158 let req = partial_version_req(raw)?;
159 releases
160 .iter()
161 .filter_map(|r| {
162 semver::Version::parse(normalize_tag(r.version.as_str()))
163 .ok()
164 .filter(|v| req.matches(v))
165 .map(|v| (v, r))
166 })
167 .max_by(|(a, _), (b, _)| a.cmp(b))
168 .map(|(_, r)| r.clone())
169 }
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 fn release(version: &str, sha: &str) -> GitlabCiVersion {
178 GitlabCiVersion {
179 version: version.into(),
180 sha: sha.to_string(),
181 prerelease: version.contains('-'),
182 published_at: None,
183 }
184 }
185
186 // --- classify_component_pin_style ---
187
188 #[test]
189 fn test_classify_sha() {
190 assert_eq!(classify_component_pin_style(&"a".repeat(40)), PinStyle::Sha);
191 }
192
193 #[test]
194 fn test_classify_latest() {
195 assert_eq!(classify_component_pin_style("~latest"), PinStyle::Latest);
196 }
197
198 #[test]
199 fn test_classify_partial_one_component() {
200 assert_eq!(classify_component_pin_style("1"), PinStyle::Partial);
201 }
202
203 #[test]
204 fn test_classify_partial_two_components() {
205 assert_eq!(classify_component_pin_style("1.2"), PinStyle::Partial);
206 assert_eq!(classify_component_pin_style("v1.2"), PinStyle::Partial);
207 }
208
209 #[test]
210 fn test_classify_full_version_is_tag() {
211 assert_eq!(classify_component_pin_style("1.2.3"), PinStyle::Tag);
212 assert_eq!(classify_component_pin_style("v1.2.3"), PinStyle::Tag);
213 }
214
215 #[test]
216 fn test_classify_branch_shaped_ref() {
217 assert_eq!(classify_component_pin_style("main"), PinStyle::Branch);
218 assert_eq!(
219 classify_component_pin_style("some-branch"),
220 PinStyle::Branch
221 );
222 }
223
224 // --- resolve_component_pin: FR-007 priority ladder ---
225
226 #[test]
227 fn test_resolve_sha() {
228 let sha = "a".repeat(40);
229 let releases = vec![release("1.0.0", &sha)];
230 let resolved = resolve_component_pin(&PinStyle::Sha, &sha, &releases).unwrap();
231 assert_eq!(resolved.version.as_str(), "1.0.0");
232 }
233
234 #[test]
235 fn test_resolve_exact_release() {
236 let releases = vec![
237 release("1.2.0", &"a".repeat(40)),
238 release("1.3.0", &"b".repeat(40)),
239 ];
240 let resolved = resolve_component_pin(&PinStyle::Tag, "1.2.0", &releases).unwrap();
241 assert_eq!(resolved.version.as_str(), "1.2.0");
242 }
243
244 #[test]
245 fn test_resolve_exact_release_v_prefix_is_literal_not_normalized() {
246 let releases = vec![release("v1.2.0", &"a".repeat(40))];
247 // Exact matching is literal, not normalized — "1.2.0" must NOT match "v1.2.0".
248 assert!(resolve_component_pin(&PinStyle::Tag, "1.2.0", &releases).is_none());
249 assert!(resolve_component_pin(&PinStyle::Tag, "v1.2.0", &releases).is_some());
250 }
251
252 #[test]
253 fn test_resolve_latest_picks_highest_non_prerelease() {
254 let releases = vec![
255 release("1.0.0", &"a".repeat(40)),
256 release("2.0.0", &"b".repeat(40)),
257 release("3.0.0-beta.1", &"c".repeat(40)),
258 ];
259 let resolved = resolve_component_pin(&PinStyle::Latest, "~latest", &releases).unwrap();
260 assert_eq!(resolved.version.as_str(), "2.0.0");
261 }
262
263 #[test]
264 fn test_resolve_partial_two_components() {
265 let releases = vec![
266 release("1.2.0", &"a".repeat(40)),
267 release("1.2.5", &"b".repeat(40)),
268 release("1.3.0", &"c".repeat(40)),
269 ];
270 let resolved = resolve_component_pin(&PinStyle::Partial, "1.2", &releases).unwrap();
271 assert_eq!(resolved.version.as_str(), "1.2.5");
272 }
273
274 #[test]
275 fn test_resolve_partial_one_component() {
276 let releases = vec![
277 release("1.2.0", &"a".repeat(40)),
278 release("1.9.0", &"b".repeat(40)),
279 release("2.0.0", &"c".repeat(40)),
280 ];
281 let resolved = resolve_component_pin(&PinStyle::Partial, "1", &releases).unwrap();
282 assert_eq!(resolved.version.as_str(), "1.9.0");
283 }
284
285 #[test]
286 fn test_resolve_partial_normalizes_v_prefixed_release_names() {
287 // Revision-1 regression: matching must run against normalized release names, not
288 // raw ones — a project tagging `v1.2.3` must still match a `1.2` partial pin.
289 let releases = vec![release("v1.2.3", &"a".repeat(40))];
290 let resolved = resolve_component_pin(&PinStyle::Partial, "1.2", &releases).unwrap();
291 assert_eq!(resolved.version.as_str(), "v1.2.3");
292 }
293
294 #[test]
295 fn test_resolve_partial_unmatched_returns_none() {
296 let releases = vec![release("2.0.0", &"a".repeat(40))];
297 assert!(resolve_component_pin(&PinStyle::Partial, "1.2", &releases).is_none());
298 }
299
300 #[test]
301 fn test_resolve_branch_shaped_ref_with_no_matching_release_returns_none() {
302 let releases = vec![release("1.0.0", &"a".repeat(40))];
303 assert!(resolve_component_pin(&PinStyle::Branch, "main", &releases).is_none());
304 }
305
306 /// Spec S2 regression: a tag that exists in the repository but has no release must
307 /// never be selected — this crate's registry layer must only ever pass `resolve_component_pin`
308 /// the `/releases` list, never `/repository/tags`; this test fixes the *contract* (only
309 /// releases are searched) rather than the fetch itself.
310 ///
311 /// The absence case alone can never fail by construction (#466 review impl-critic
312 /// finding, `component.rs:274`): `resolve_component_pin` has no side channel to any
313 /// tag/release outside the `releases` slice it's handed, so simply never passing
314 /// `"2.0.0"` in proves nothing beyond "given one release, `Latest` picks it" — already
315 /// covered by `test_resolve_latest_picks_highest_non_prerelease`. The contrast case
316 /// below makes the assertion capable of failing: it proves the *same* pin genuinely
317 /// picks the higher version once it's actually present, ruling out a regression where
318 /// this function always returned the first/only entry regardless of version.
319 #[test]
320 fn test_resolve_latest_only_considers_passed_release_list() {
321 let sha_a = "a".repeat(40);
322 let sha_b = "b".repeat(40);
323
324 let without_2_0_0 = vec![release("1.9.0", &sha_a)];
325 let resolved = resolve_component_pin(&PinStyle::Latest, "~latest", &without_2_0_0).unwrap();
326 assert_eq!(resolved.version.as_str(), "1.9.0");
327
328 let with_2_0_0 = vec![release("1.9.0", &sha_a), release("2.0.0", &sha_b)];
329 let resolved = resolve_component_pin(&PinStyle::Latest, "~latest", &with_2_0_0).unwrap();
330 assert_eq!(resolved.version.as_str(), "2.0.0");
331 }
332}