Skip to main content

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}