deps_core/lsp_helpers/in_use_version.rs
1//! "What version does this dependency occurrence actually have" — shared by
2//! `deps-lsp`'s registry-fetch/OSV-target pipeline and, for #394's S1 fix,
3//! the yanked-diagnostic consistency check in [`super::diagnostics`].
4
5use std::collections::HashMap;
6
7use crate::lsp_helpers::EcosystemFormatter;
8use crate::{ConcreteVersion, Dependency, EcosystemId, PackageName};
9
10/// How a *bare* (no explicit pin marker) version requirement should be treated when
11/// deciding whether it denotes a single concrete version.
12///
13/// Replaces a plain boolean (critique B2 of #208's plan) because neither `true` nor
14/// `false` is correct for GitHub Actions: `AlwaysRange`/`Concrete` alone cannot express
15/// "a bare `v4` is a range, but a bare `v4.2.0` is a pin" — the two forms share no
16/// syntactic marker to distinguish them by, only the number of components present.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18enum BareRequirementPolicy {
19 /// A bare requirement is always a range under this ecosystem's own default
20 /// semantics (Cargo's implicit caret, npm/Composer's implicit caret) — never
21 /// treated as concrete without an explicit `=`/`==` pin marker.
22 AlwaysRange,
23 /// A bare requirement is concrete only when it has the shape of a full
24 /// `major.minor.patch` version ([`is_full_semver_shape`]); a partial form (a bare
25 /// major or major.minor, e.g. GitHub Actions' moving-major `v4` tag) is treated as
26 /// a range instead, since it is one.
27 ConcreteIfFullVersion,
28 /// A bare requirement is already exact (no implicit range operator).
29 Concrete,
30}
31
32/// Ecosystems whose *bare* (no explicit pin marker) version requirement is a
33/// range under that ecosystem's own default semantics — Cargo's implicit
34/// caret, npm/Composer's implicit caret. For these, [`is_concrete_version`]
35/// requires an explicit `=`/`==` (or an exact-bracket wrap) before treating a
36/// requirement as concrete; a bare `"1.2.3"` alone is not enough evidence
37/// (critique C2).
38///
39/// Deno reuses npm's exact grammar for both its `jsr:` and `npm:` specifiers
40/// (`DenoFormatter::compile_requirement` compiles both through the same
41/// `node_semver::Range` npm itself uses), so it gets the same treatment here.
42///
43/// GitHub Actions gets [`BareRequirementPolicy::ConcreteIfFullVersion`]: a bare `v4`
44/// (a moving-major tag) genuinely is a range, so it must not be queried as if it were
45/// the concrete version `4`, but a bare `v4.2.0` is a pin — see
46/// [`BareRequirementPolicy`]'s docs. A bare 40-character SHA also falls to the
47/// `None` side of this gate ([`is_full_semver_shape`] rejects it), which is the
48/// correct "honest unknown" outcome: resolving a SHA to its tag would need registry
49/// access this pure function does not have.
50///
51/// GitLab CI gets the identical policy for the identical reason: a `component:`
52/// include's partial-semver pin (`1`, `1.2`) is a range exactly like GitHub Actions'
53/// moving-major tag ([`is_full_semver_shape`] correctly rejects it, since it requires
54/// all three components), while a full `1.2.3`/`v1.2.3` tag or release-name pin is
55/// concrete. A SHA pin (`project:`'s or `component:`'s) falls to the same honest
56/// "unknown" `None` as GitHub Actions' bare SHA, and `~latest`/a branch-shaped ref
57/// never look like a full version shape either, so both also correctly fall through
58/// to `None`.
59///
60/// Gradle is deliberately excluded: a bare Gradle coordinate version (e.g.
61/// `"2.14.1"`) is an exact match under `GradleFormatter`'s own
62/// `version_satisfies_requirement` unless it uses the `+` dynamic-version
63/// suffix, which [`looks_like_a_single_version`] already rejects via its
64/// reject-char set — Gradle has no implicit-caret default the way
65/// Cargo/npm/Composer do.
66const fn bare_requirement_policy(ecosystem: EcosystemId) -> BareRequirementPolicy {
67 match ecosystem {
68 EcosystemId::Cargo | EcosystemId::Npm | EcosystemId::Composer | EcosystemId::Deno => {
69 BareRequirementPolicy::AlwaysRange
70 }
71 EcosystemId::GithubActions | EcosystemId::GitlabCi => {
72 BareRequirementPolicy::ConcreteIfFullVersion
73 }
74 _ => BareRequirementPolicy::Concrete,
75 }
76}
77
78/// Whether `s` has the shape of a full `major.minor.patch` version.
79///
80/// An optional leading `v`/`V`, three dot-separated all-digit components, and an
81/// optional SemVer-style prerelease/build suffix introduced by `-` or `+` (accepted,
82/// but not itself validated beyond "starts here").
83///
84/// Hand-rolled rather than pulled in via the `regex` crate: this is consulted from
85/// `bare_requirement_policy` in `deps-core`, the workspace's most-depended-on crate,
86/// which has no `regex` dependency today — equivalent to the pattern
87/// `^v?\d+\.\d+\.\d+(?:[-+].*)?$`. Shared verbatim by `deps-github-actions`'s
88/// SHA-comment-tag parsing rule so the two mechanisms can never silently diverge on
89/// what counts as a full version (e.g. `v4.2.0-beta.1` must be treated identically by
90/// both).
91///
92/// # Examples
93///
94/// ```
95/// use deps_core::lsp_helpers::is_full_semver_shape;
96///
97/// assert!(is_full_semver_shape("v4.2.0"));
98/// assert!(is_full_semver_shape("4.2.0-beta.1"));
99/// assert!(!is_full_semver_shape("v4"));
100/// assert!(!is_full_semver_shape("v4.2"));
101/// assert!(!is_full_semver_shape("not-a-version"));
102/// ```
103#[must_use]
104pub fn is_full_semver_shape(s: &str) -> bool {
105 let s = s.strip_prefix(['v', 'V']).unwrap_or(s);
106 let core = match s.find(['-', '+']) {
107 Some(idx) => &s[..idx],
108 None => s,
109 };
110 let mut parts = core.split('.');
111 let (Some(major), Some(minor), Some(patch), None) =
112 (parts.next(), parts.next(), parts.next(), parts.next())
113 else {
114 return false;
115 };
116 [major, minor, patch]
117 .iter()
118 .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
119}
120
121/// Returns `true` if `s` (already stripped of any pin marker) has the shape
122/// of a single concrete version: non-empty, no wildcard/range-operator
123/// character, and starting with a digit (after an optional `v`/`V` prefix,
124/// e.g. Go's `v1.9.1`).
125///
126/// Deliberately conservative — see [`is_concrete_version`]'s doc for why a
127/// false positive here is worse than a false negative.
128fn looks_like_a_single_version(s: &str) -> bool {
129 if s.is_empty() {
130 return false;
131 }
132 if s.contains([
133 '^', '~', '*', '<', '>', ',', '|', '(', ')', '[', ']', ' ', '\t', ':', '+', 'x', 'X',
134 ]) {
135 return false;
136 }
137 let core = s.strip_prefix(['v', 'V']).unwrap_or(s);
138 core.chars().next().is_some_and(|c| c.is_ascii_digit())
139}
140
141/// Returns the concrete version text `requirement` denotes, or `None` if
142/// `requirement` is not the shape of a single concrete version.
143///
144/// Any pin marker (`=`/`==`, or a single-value bracket wrap like NuGet's
145/// `[1.0.0]`) is stripped off. The only shape safe to query OSV with
146/// directly, and, for #233, the only shape safe to compare against a real
147/// registry version string in the yanked-version probe. A wrong answer here
148/// is invisible in testing (OSV silently returns `{}` for a fabricated
149/// version; the yanked probe silently finds no match), so getting this
150/// right matters more than covering every ecosystem's full range grammar.
151///
152/// An explicit pin marker is always accepted, and its marker is stripped
153/// from the returned text — required because PyPI's parser retains the
154/// pep440 comparator in `Dependency::version_requirement()` (an exact pin
155/// parses to `"==4.9.0"`, not `"4.9.0"`; confirmed by
156/// `deps-pypi`'s `test_basic_pinned`), so comparing the *unstripped* text
157/// against a real registry version string (`"4.9.0"`) would never match. A
158/// *bare* requirement (no marker) is returned verbatim, and is accepted only
159/// for ecosystems where a bare version is not itself a range by default
160/// (critique C2) — see `bare_version_is_a_range`.
161///
162/// # Examples
163///
164/// ```
165/// use deps_core::EcosystemId;
166/// use deps_core::lsp_helpers::concrete_pin_version;
167///
168/// // An explicit pin marker is stripped, for any ecosystem.
169/// assert_eq!(
170/// concrete_pin_version("=1.2.3", EcosystemId::Cargo),
171/// Some("1.2.3")
172/// );
173///
174/// // Cargo's bare version is a caret range by default, not a pin.
175/// assert_eq!(concrete_pin_version("1.2.3", EcosystemId::Cargo), None);
176///
177/// // Maven has no implicit range operator, so a bare version is exact.
178/// assert_eq!(
179/// concrete_pin_version("2.14.1", EcosystemId::Maven),
180/// Some("2.14.1")
181/// );
182/// ```
183pub fn concrete_pin_version(requirement: &str, ecosystem: EcosystemId) -> Option<&str> {
184 let trimmed = requirement.trim();
185 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("latest") {
186 return None;
187 }
188
189 let pinned = trimmed
190 .strip_prefix("==")
191 .or_else(|| trimmed.strip_prefix('='));
192 let bracket_pinned = trimmed
193 .strip_circumfix('[', ']')
194 .filter(|inner| !inner.contains(','));
195
196 match pinned.or(bracket_pinned) {
197 Some(body) => looks_like_a_single_version(body).then_some(body),
198 None => match bare_requirement_policy(ecosystem) {
199 BareRequirementPolicy::AlwaysRange => None,
200 BareRequirementPolicy::Concrete => {
201 looks_like_a_single_version(trimmed).then_some(trimmed)
202 }
203 BareRequirementPolicy::ConcreteIfFullVersion => {
204 is_full_semver_shape(trimmed).then_some(trimmed)
205 }
206 },
207 }
208}
209
210/// Returns `true` if `requirement` denotes a single concrete version. See
211/// [`concrete_pin_version`], whose boolean projection this is, for the
212/// acceptance rules. Test-only: production code needs the stripped text
213/// from `concrete_pin_version` itself, not just the boolean.
214#[cfg(test)]
215fn is_concrete_version(requirement: &str, ecosystem: EcosystemId) -> bool {
216 concrete_pin_version(requirement, ecosystem).is_some()
217}
218
219/// The version of `dep` this project treats as actually in use.
220///
221/// The lock-file-resolved version, else the declared requirement when it is
222/// already concrete ([`concrete_pin_version`]). `None` when neither applies.
223///
224/// A dependency whose manifest requirement is itself the resolved version
225/// ([`crate::lsp_helpers::RequirementResolution::manifest_requirement_is_resolved_version`] — a Go
226/// `require`-directive dependency) skips the lockfile step entirely, going
227/// straight to the declared requirement (go.sum is unreliable there — a
228/// checksum ledger that `go get`/`go build` only ever append to, so its
229/// last-occurrence-wins parse can surface a version still recorded in the
230/// file but no longer selected by Go's MVS). Shared by `deps-lsp`'s OSV
231/// target selection, its yanked-version check, and the yanked-diagnostic
232/// consistency check in [`super::diagnostics::generate_diagnostics_from_cache`]
233/// (#394 S1) — all three need "what version does the user actually have"
234/// for the same reason: querying a fabricated version produces a silent
235/// false negative.
236///
237/// # Examples
238///
239/// ```
240/// use deps_core::lsp_helpers::{
241/// DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
242/// RequirementResolution, SourcePolicy, in_use_version,
243/// };
244/// use deps_core::{ConcreteVersion, Dependency, EcosystemId, PackageName, VersionReq};
245/// use std::any::Any;
246/// use std::collections::HashMap;
247/// use tower_lsp_server::ls_types::Range;
248///
249/// struct SimpleDep {
250/// name: PackageName,
251/// version_req: Option<VersionReq>,
252/// }
253///
254/// impl Dependency for SimpleDep {
255/// fn name(&self) -> &PackageName {
256/// &self.name
257/// }
258/// fn name_range(&self) -> Range {
259/// Range::default()
260/// }
261/// fn version_requirement(&self) -> Option<&VersionReq> {
262/// self.version_req.as_ref()
263/// }
264/// fn version_range(&self) -> Option<Range> {
265/// None
266/// }
267/// fn source(&self) -> deps_core::parser::DependencySource {
268/// deps_core::parser::DependencySource::Registry
269/// }
270/// fn as_any(&self) -> &dyn Any {
271/// self
272/// }
273/// }
274///
275/// struct SimpleFormatter;
276/// impl PackageNaming for SimpleFormatter {}
277/// impl PackageRendering for SimpleFormatter {
278/// fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
279/// version.to_string()
280/// }
281/// fn package_url(&self, name: &PackageName) -> String {
282/// name.to_string()
283/// }
284/// }
285/// impl RequirementResolution for SimpleFormatter {}
286/// impl DiagnosticMessages for SimpleFormatter {}
287/// impl DiagnosticPolicy for SimpleFormatter {}
288/// impl SourcePolicy for SimpleFormatter {}
289/// impl OsvNaming for SimpleFormatter {}
290///
291/// let dep = SimpleDep {
292/// name: PackageName::new("time"),
293/// version_req: Some(VersionReq::new("=0.1.43")),
294/// };
295/// let resolved_versions: HashMap<PackageName, ConcreteVersion> = HashMap::new();
296///
297/// // No lock file, but the requirement is already an exact pin — falls
298/// // back to it, stripped of its `=` marker.
299/// assert_eq!(
300/// in_use_version(&dep, "time", &resolved_versions, &SimpleFormatter, EcosystemId::Cargo),
301/// Some("0.1.43".to_string())
302/// );
303/// ```
304pub fn in_use_version(
305 dep: &dyn Dependency,
306 normalized_name: &str,
307 resolved_versions: &HashMap<PackageName, ConcreteVersion>,
308 formatter: &dyn EcosystemFormatter,
309 ecosystem: EcosystemId,
310) -> Option<String> {
311 if formatter.manifest_requirement_is_resolved_version(dep) {
312 dep.version_requirement()
313 .and_then(|req| concrete_pin_version(req.as_str(), ecosystem))
314 .map(str::to_string)
315 } else {
316 resolved_versions
317 .get(normalized_name)
318 .or_else(|| resolved_versions.get(dep.name()))
319 .map(ConcreteVersion::to_string)
320 .or_else(|| {
321 dep.version_requirement()
322 .and_then(|req| concrete_pin_version(req.as_str(), ecosystem))
323 .map(str::to_string)
324 })
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 #[test]
333 fn is_concrete_version_accepts_explicit_pins_in_any_ecosystem() {
334 for eco in [EcosystemId::Cargo, EcosystemId::Npm, EcosystemId::Go] {
335 assert!(is_concrete_version("=1.2.3", eco), "{eco:?}");
336 }
337 // Go's go.mod bare `v1.9.1` style: Go is not in the
338 // range-default set, so the bare form (with its `v` prefix) is
339 // accepted without needing an explicit `=`.
340 assert!(is_concrete_version("v1.9.1", EcosystemId::Go));
341 }
342
343 #[test]
344 fn is_concrete_version_pep440_double_equals_is_a_pin() {
345 // Critique C2: `strip_prefix('=')` alone turns PEP 440 `"==2.28.0"`
346 // into `"=2.28.0"`, whose first char then fails the digit check.
347 assert!(is_concrete_version("==2.28.0", EcosystemId::Pypi));
348 }
349
350 #[test]
351 fn is_concrete_version_bare_digit_accepted_for_non_range_default_ecosystems() {
352 // Maven/Go/Bundler/Dart/Gradle/NuGet: a bare version is already
353 // exact (or, for NuGet's PackageReference floor, resolves to
354 // exactly that version in practice). Gradle in particular has no
355 // implicit-caret default for a plain coordinate version like
356 // `"2.14.1"` — only the `+` dynamic-version suffix is a range,
357 // and that's rejected separately by `looks_like_a_single_version`.
358 for eco in [
359 EcosystemId::Maven,
360 EcosystemId::Go,
361 EcosystemId::Bundler,
362 EcosystemId::Dart,
363 EcosystemId::Gradle,
364 EcosystemId::NuGet,
365 ] {
366 assert!(is_concrete_version("2.14.1", eco), "{eco:?}");
367 }
368 }
369
370 #[test]
371 fn is_concrete_version_bare_digit_rejected_for_range_default_ecosystems() {
372 // Critique C2: Cargo's bare "1.2.3" is a caret range under
373 // Cargo's own default operator, not a pin — same for npm and
374 // Composer's implicit range notations. Deno reuses npm's exact
375 // grammar for both `jsr:` and `npm:` requirements, so it gets the
376 // same treatment (`bare_version_is_a_range`'s doc comment).
377 for eco in [
378 EcosystemId::Cargo,
379 EcosystemId::Npm,
380 EcosystemId::Composer,
381 EcosystemId::Deno,
382 ] {
383 assert!(!is_concrete_version("1.2.3", eco), "{eco:?}");
384 // ...but an explicit pin is still accepted.
385 assert!(is_concrete_version("=1.2.3", eco), "{eco:?}");
386 }
387 }
388
389 #[test]
390 fn is_concrete_version_rejects_partials_and_wildcards() {
391 // Critique C2: npm/Composer "1.x"/"1.2.x" and bare partials like
392 // "1.2" are ranges, and Gradle's "1.+" is a dynamic version —
393 // none of these contained a previously-rejected character.
394 for eco in [EcosystemId::Npm, EcosystemId::Composer] {
395 assert!(!is_concrete_version("1.x", eco), "{eco:?}");
396 assert!(!is_concrete_version("1.2.x", eco), "{eco:?}");
397 assert!(!is_concrete_version("1.2", eco), "{eco:?}");
398 }
399 assert!(!is_concrete_version("1.+", EcosystemId::Gradle));
400 }
401
402 #[test]
403 fn is_concrete_version_rejects_ranges_and_wildcards() {
404 for eco in [EcosystemId::Maven, EcosystemId::Go] {
405 assert!(!is_concrete_version("^1.0", eco));
406 assert!(!is_concrete_version("~1.2", eco));
407 assert!(!is_concrete_version("*", eco));
408 assert!(!is_concrete_version(">=1.0", eco));
409 assert!(!is_concrete_version(">=1.0 <2.0", eco));
410 assert!(!is_concrete_version("1.0.*", eco));
411 assert!(!is_concrete_version("", eco));
412 }
413 }
414
415 #[test]
416 fn is_concrete_version_rejects_non_version_schemes() {
417 let eco = EcosystemId::Go;
418 assert!(!is_concrete_version("latest", eco));
419 assert!(!is_concrete_version("github:user/repo", eco));
420 assert!(!is_concrete_version("file:../x", eco));
421 assert!(!is_concrete_version("main", eco));
422 }
423
424 #[test]
425 fn concrete_pin_version_strips_pep440_double_equals_comparator() {
426 // Regression guard: PyPI's parser retains the pep440 comparator
427 // in `version_requirement().as_str()` (`"==4.9.0"`, not
428 // `"4.9.0"` — confirmed by deps-pypi's `test_basic_pinned`). The
429 // verbatim string was silently unusable against real registry
430 // version strings in the yanked probe; `concrete_pin_version`
431 // must strip it.
432 assert_eq!(
433 concrete_pin_version("==4.9.0", EcosystemId::Pypi),
434 Some("4.9.0")
435 );
436 }
437
438 #[test]
439 fn concrete_pin_version_strips_single_equals_and_bracket_pins() {
440 assert_eq!(
441 concrete_pin_version("=1.2.3", EcosystemId::Cargo),
442 Some("1.2.3")
443 );
444 assert_eq!(
445 concrete_pin_version("[1.0.0]", EcosystemId::NuGet),
446 Some("1.0.0")
447 );
448 }
449
450 #[test]
451 fn concrete_pin_version_bare_version_returned_verbatim() {
452 // No operator to strip: Maven/Go/Bundler/Dart/Gradle/NuGet treat
453 // a bare version as already exact.
454 assert_eq!(
455 concrete_pin_version("2.14.1", EcosystemId::Maven),
456 Some("2.14.1")
457 );
458 }
459
460 #[test]
461 fn concrete_pin_version_rejects_ranges_and_partials() {
462 assert_eq!(concrete_pin_version("^1.0", EcosystemId::Cargo), None);
463 assert_eq!(concrete_pin_version("1.2.3", EcosystemId::Cargo), None);
464 assert_eq!(concrete_pin_version(">=1.0,<2.0", EcosystemId::Pypi), None);
465 }
466
467 // --- is_full_semver_shape ---
468
469 #[test]
470 fn is_full_semver_shape_accepts_full_versions_with_and_without_v_prefix() {
471 assert!(is_full_semver_shape("4.2.0"));
472 assert!(is_full_semver_shape("v4.2.0"));
473 assert!(is_full_semver_shape("V4.2.0"));
474 }
475
476 #[test]
477 fn is_full_semver_shape_accepts_prerelease_and_build_suffixes() {
478 assert!(is_full_semver_shape("v4.2.0-beta.1"));
479 assert!(is_full_semver_shape("4.2.0+build.5"));
480 }
481
482 #[test]
483 fn is_full_semver_shape_rejects_partial_versions() {
484 assert!(!is_full_semver_shape("v4"));
485 assert!(!is_full_semver_shape("v4.2"));
486 }
487
488 #[test]
489 fn is_full_semver_shape_rejects_non_version_and_sha_shapes() {
490 assert!(!is_full_semver_shape("main"));
491 assert!(!is_full_semver_shape(
492 "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
493 ));
494 assert!(!is_full_semver_shape(""));
495 assert!(!is_full_semver_shape("4.2.0.1"));
496 assert!(!is_full_semver_shape("4..0"));
497 }
498
499 // --- concrete_pin_version: BareRequirementPolicy::ConcreteIfFullVersion (GitHub Actions) ---
500
501 #[test]
502 fn concrete_pin_version_github_actions_full_bare_tag_is_concrete() {
503 assert_eq!(
504 concrete_pin_version("v4.2.0", EcosystemId::GithubActions),
505 Some("v4.2.0")
506 );
507 }
508
509 #[test]
510 fn concrete_pin_version_github_actions_moving_major_tag_is_a_range() {
511 // `v4` genuinely is a range (a moving major tag) — must not be queried as if
512 // it were the concrete version `4` (critique B2).
513 assert_eq!(concrete_pin_version("v4", EcosystemId::GithubActions), None);
514 assert_eq!(
515 concrete_pin_version("v4.2", EcosystemId::GithubActions),
516 None
517 );
518 }
519
520 #[test]
521 fn concrete_pin_version_github_actions_bare_sha_is_not_concrete() {
522 // A bare SHA has no dots, so it fails `is_full_semver_shape` and falls to the
523 // honest "unknown" `None` rather than being queried as a fabricated version.
524 assert_eq!(
525 concrete_pin_version(
526 "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
527 EcosystemId::GithubActions
528 ),
529 None
530 );
531 }
532
533 // --- concrete_pin_version: BareRequirementPolicy::ConcreteIfFullVersion (GitLab CI) ---
534
535 #[test]
536 fn concrete_pin_version_gitlab_ci_full_tag_is_concrete() {
537 assert_eq!(
538 concrete_pin_version("1.2.3", EcosystemId::GitlabCi),
539 Some("1.2.3")
540 );
541 assert_eq!(
542 concrete_pin_version("v1.2.3", EcosystemId::GitlabCi),
543 Some("v1.2.3")
544 );
545 }
546
547 #[test]
548 fn concrete_pin_version_gitlab_ci_partial_pin_is_a_range() {
549 // H2 regression (#466 review): a `component:` partial-semver pin (`1`, `1.2`)
550 // is a range under GitLab's own documented `~{raw}` semantics, not a single
551 // version — must not be queried as if it were the concrete version `1.2`.
552 assert_eq!(concrete_pin_version("1.2", EcosystemId::GitlabCi), None);
553 assert_eq!(concrete_pin_version("1", EcosystemId::GitlabCi), None);
554 }
555
556 #[test]
557 fn concrete_pin_version_gitlab_ci_digit_leading_sha_is_not_concrete() {
558 // A 40-hex SHA that happens to start with a digit must still fall to the
559 // honest "unknown" `None` rather than being misread as a version — it has no
560 // dots, so it fails `is_full_semver_shape` regardless of its leading
561 // character.
562 assert_eq!(
563 concrete_pin_version(
564 "1234567890abcdef1234567890abcdef12345678",
565 EcosystemId::GitlabCi
566 ),
567 None
568 );
569 }
570}