1use dashmap::DashMap;
4use deps_core::lsp_helpers::{
5 DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
6 RequirementResolution, RequirementStatus, SourcePolicy, match_v_prefix_style,
7 warn_rejected_value,
8};
9use deps_core::parser::DependencySource;
10use deps_core::{ConcreteVersion, Dependency, InvalidPackageName, PackageName, VersionReq};
11use std::sync::Arc;
12
13use crate::host::is_valid_gitlab_coordinate;
14use crate::types::{EndpointKind, GitlabCiDependency, GitlabRoute, PinStyle};
15
16pub struct GitlabCiFormatter {
18 pub(crate) routes: Arc<DashMap<String, GitlabRoute>>,
24 pub(crate) tag_index: Arc<DashMap<PackageName, Arc<crate::registry::TagIndex>>>,
26}
27
28impl GitlabCiFormatter {
29 #[must_use]
31 pub fn new(
32 routes: Arc<DashMap<String, GitlabRoute>>,
33 tag_index: Arc<DashMap<PackageName, Arc<crate::registry::TagIndex>>>,
34 ) -> Self {
35 Self { routes, tag_index }
36 }
37
38 #[must_use]
42 pub(crate) fn resolved_tag_for_sha(&self, name: &PackageName, sha: &str) -> Option<String> {
43 self.tag_index
44 .get(name)
45 .and_then(|index| index.sha_to_tag.get(sha).cloned())
46 }
47}
48
49impl PackageNaming for GitlabCiFormatter {
50 fn normalize_package_name(&self, name: &PackageName) -> String {
51 name.as_str().to_lowercase()
52 }
53
54 fn validate_package_name(&self, name: &str) -> Result<(), InvalidPackageName> {
58 if is_valid_gitlab_coordinate(name) {
59 Ok(())
60 } else {
61 Err(InvalidPackageName::new(
62 "name must be a GitLab project/component coordinate",
63 ))
64 }
65 }
66}
67
68impl PackageRendering for GitlabCiFormatter {
69 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
70 version.as_str().to_string()
71 }
72
73 fn format_version_replacing_for(
78 &self,
79 dep: &dyn Dependency,
80 version: &ConcreteVersion,
81 current: &str,
82 ) -> String {
83 let Some(gl_dep) = dep.as_any().downcast_ref::<GitlabCiDependency>() else {
84 return self.format_version_for_text_edit(version);
85 };
86 match &gl_dep.pin {
87 Some(PinStyle::Partial | PinStyle::Latest) => current.to_string(),
88 _ => match_v_prefix_style(current, version.as_str()),
89 }
90 }
91
92 fn package_url(&self, name: &PackageName) -> String {
93 if is_valid_gitlab_coordinate(name.as_str()) {
94 format!("https://{name}")
95 } else {
96 warn_rejected_value(
97 "is_valid_gitlab_coordinate",
98 "gitlab-ci package display formatting",
99 name.as_str(),
100 );
101 String::new()
102 }
103 }
104
105 fn suppress_package_url(&self, source: &DependencySource) -> bool {
111 match source {
112 DependencySource::AlternateRegistry { index, .. } => {
113 match self.routes.get(index).map(|r| r.endpoint) {
114 Some(EndpointKind::Tags) => false,
115 Some(EndpointKind::Releases) | None => true,
119 }
120 }
121 _ => true,
123 }
124 }
125}
126
127impl RequirementResolution for GitlabCiFormatter {
128 fn requirement_is_unresolved(&self, requirement: &VersionReq) -> bool {
137 matches!(
138 crate::component::classify_component_pin_style(requirement.as_str()),
139 PinStyle::Sha | PinStyle::Branch
140 )
141 }
142
143 fn is_requirement_up_to_date(
155 &self,
156 requirement: &VersionReq,
157 latest: &ConcreteVersion,
158 ) -> bool {
159 let pin = crate::component::classify_component_pin_style(requirement.as_str());
160 !matches!(
161 status_for_pin(&pin, requirement.as_str(), latest.as_str()),
162 RequirementStatus::Outdated
163 )
164 }
165
166 fn requirement_status_for(
179 &self,
180 dep: &dyn Dependency,
181 requirement: &VersionReq,
182 latest: &ConcreteVersion,
183 ) -> RequirementStatus {
184 let Some(pin) = dep
185 .as_any()
186 .downcast_ref::<GitlabCiDependency>()
187 .and_then(|gl_dep| gl_dep.pin.as_ref())
188 else {
189 return self.requirement_status(requirement, latest);
190 };
191 status_for_pin(pin, requirement.as_str(), latest.as_str())
192 }
193}
194
195fn status_for_pin(pin: &PinStyle, requirement: &str, latest: &str) -> RequirementStatus {
201 match pin {
202 PinStyle::Sha | PinStyle::Branch => RequirementStatus::Unresolved,
203 PinStyle::Latest => RequirementStatus::UpToDate,
204 PinStyle::Tag => {
205 if deps_core::github::normalize_tag(requirement)
206 == deps_core::github::normalize_tag(latest)
207 {
208 RequirementStatus::UpToDate
209 } else {
210 RequirementStatus::Outdated
211 }
212 }
213 PinStyle::Partial => {
214 if partial_leading_components_match(requirement, latest) {
215 RequirementStatus::UpToDate
216 } else {
217 RequirementStatus::Outdated
218 }
219 }
220 }
221}
222
223fn partial_leading_components_match(req: &str, latest: &str) -> bool {
229 crate::component::gitlab_version_req(req).is_some_and(|range| {
230 semver::Version::parse(deps_core::github::normalize_tag(latest))
231 .is_ok_and(|version| range.matches(&version))
232 })
233}
234
235impl DiagnosticMessages for GitlabCiFormatter {}
236
237impl DiagnosticPolicy for GitlabCiFormatter {}
238
239impl SourcePolicy for GitlabCiFormatter {
240 fn can_resolve_source(&self, source: &DependencySource) -> bool {
247 matches!(source, DependencySource::AlternateRegistry { .. })
248 }
249}
250
251impl OsvNaming for GitlabCiFormatter {
252 fn osv_version(&self, version: &str) -> String {
256 deps_core::github::normalize_tag(version).to_string()
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use tower_lsp_server::ls_types::{Position, Range};
264
265 fn formatter() -> GitlabCiFormatter {
266 GitlabCiFormatter::new(Arc::new(DashMap::new()), Arc::new(DashMap::new()))
267 }
268
269 fn range() -> Range {
270 Range::new(Position::new(0, 0), Position::new(0, 1))
271 }
272
273 fn dep(pin: Option<PinStyle>, name: &str, source: DependencySource) -> GitlabCiDependency {
274 GitlabCiDependency {
275 name: name.into(),
276 name_range: range(),
277 version_req: Some("1.0.0".into()),
278 version_range: Some(range()),
279 version_literal: None,
280 source,
281 is_plain_scalar: true,
282 kind: crate::types::IncludeKind::Project,
283 host: crate::types::HostRef::Unresolved("$CI_SERVER_FQDN".into()),
284 pin,
285 project_path: "org/proj".to_string(),
286 }
287 }
288
289 #[test]
290 fn test_validate_package_name_accepts_bare_and_host_qualified() {
291 let fmt = formatter();
292 assert!(fmt.validate_package_name("org/proj").is_ok());
293 assert!(
294 fmt.validate_package_name("gitlab.com/org/proj/comp")
295 .is_ok()
296 );
297 assert!(fmt.validate_package_name("no-slash").is_err());
298 }
299
300 #[test]
301 fn test_package_url() {
302 let fmt = formatter();
303 assert_eq!(
304 fmt.package_url(&PackageName::new("gitlab.com/org/proj")),
305 "https://gitlab.com/org/proj"
306 );
307 assert_eq!(fmt.package_url(&PackageName::new("no-slash")), "");
308 }
309
310 #[test]
311 fn test_suppress_package_url_custom_registry_always_suppressed() {
312 let fmt = formatter();
313 assert!(fmt.suppress_package_url(&DependencySource::CustomRegistry {
314 url: "$CI_SERVER_FQDN".into()
315 }));
316 }
317
318 #[test]
319 fn test_suppress_package_url_tags_route_not_suppressed() {
320 let fmt = formatter();
321 fmt.routes.insert(
322 "gitlab:abc".to_string(),
323 GitlabRoute {
324 origin: "https://gitlab.com".into(),
325 endpoint: EndpointKind::Tags,
326 },
327 );
328 assert!(
329 !fmt.suppress_package_url(&DependencySource::AlternateRegistry {
330 index: "gitlab:abc".into(),
331 mirrors_crates_io: false,
332 })
333 );
334 }
335
336 #[test]
337 fn test_suppress_package_url_releases_route_suppressed() {
338 let fmt = formatter();
339 fmt.routes.insert(
340 "gitlab:abc".to_string(),
341 GitlabRoute {
342 origin: "https://gitlab.com".into(),
343 endpoint: EndpointKind::Releases,
344 },
345 );
346 assert!(
347 fmt.suppress_package_url(&DependencySource::AlternateRegistry {
348 index: "gitlab:abc".into(),
349 mirrors_crates_io: false,
350 })
351 );
352 }
353
354 #[test]
355 fn test_suppress_package_url_unregistered_index_fails_closed() {
356 let fmt = formatter();
357 assert!(
358 fmt.suppress_package_url(&DependencySource::AlternateRegistry {
359 index: "gitlab:missing".into(),
360 mirrors_crates_io: false,
361 })
362 );
363 }
364
365 #[test]
366 fn test_can_resolve_source() {
367 let fmt = formatter();
368 assert!(
369 fmt.can_resolve_source(&DependencySource::AlternateRegistry {
370 index: "x".into(),
371 mirrors_crates_io: false,
372 })
373 );
374 assert!(!fmt.can_resolve_source(&DependencySource::CustomRegistry { url: "x".into() }));
375 assert!(!fmt.can_resolve_source(&DependencySource::Registry));
376 }
377
378 #[test]
379 fn test_requirement_is_unresolved_sha_and_branch() {
380 let fmt = formatter();
381 assert!(fmt.requirement_is_unresolved(&VersionReq::new("a".repeat(40))));
382 assert!(fmt.requirement_is_unresolved(&VersionReq::new("some-branch")));
383 }
384
385 #[test]
386 fn test_requirement_is_unresolved_tag_latest_partial_are_resolved() {
387 let fmt = formatter();
388 assert!(!fmt.requirement_is_unresolved(&VersionReq::new("1.0.0")));
389 assert!(!fmt.requirement_is_unresolved(&VersionReq::new("~latest")));
390 assert!(!fmt.requirement_is_unresolved(&VersionReq::new("1.2")));
391 }
392
393 #[test]
394 fn test_is_requirement_up_to_date_latest_always_true() {
395 let fmt = formatter();
396 assert!(fmt.is_requirement_up_to_date(
397 &VersionReq::new("~latest"),
398 &ConcreteVersion::new("9.9.9")
399 ));
400 }
401
402 #[test]
403 fn test_is_requirement_up_to_date_partial_leading_components() {
404 let fmt = formatter();
405 assert!(
406 fmt.is_requirement_up_to_date(&VersionReq::new("1.2"), &ConcreteVersion::new("1.2.5"))
407 );
408 assert!(
409 !fmt.is_requirement_up_to_date(&VersionReq::new("1.2"), &ConcreteVersion::new("1.3.0"))
410 );
411 assert!(
412 fmt.is_requirement_up_to_date(&VersionReq::new("1"), &ConcreteVersion::new("1.9.0"))
413 );
414 }
415
416 #[test]
417 fn test_is_requirement_up_to_date_tag_exact_match() {
418 let fmt = formatter();
419 assert!(
420 fmt.is_requirement_up_to_date(
421 &VersionReq::new("v1.0.0"),
422 &ConcreteVersion::new("1.0.0")
423 )
424 );
425 assert!(
426 !fmt.is_requirement_up_to_date(
427 &VersionReq::new("1.0.0"),
428 &ConcreteVersion::new("1.1.0")
429 )
430 );
431 }
432
433 #[test]
434 fn test_is_requirement_up_to_date_sha_never_false_positive() {
435 let fmt = formatter();
436 assert!(fmt.is_requirement_up_to_date(
437 &VersionReq::new("a".repeat(40)),
438 &ConcreteVersion::new("1.0.0")
439 ));
440 }
441
442 #[test]
448 fn test_requirement_status_for_consults_dep_pin_not_text_reclassification() {
449 let fmt = formatter();
450 let d = dep(
451 Some(PinStyle::Branch),
452 "org/proj",
453 DependencySource::Registry,
454 );
455 let requirement = VersionReq::new("1.2");
456 assert_eq!(
460 fmt.requirement_status(&requirement, &ConcreteVersion::new("1.2.9")),
461 RequirementStatus::UpToDate,
462 "sanity: bare text reclassification treats this as an up-to-date Partial pin"
463 );
464 assert_eq!(
467 fmt.requirement_status_for(&d, &requirement, &ConcreteVersion::new("1.2.9")),
468 RequirementStatus::Unresolved
469 );
470 }
471
472 #[test]
473 fn test_requirement_status_for_partial_pin_matches_boolean_method() {
474 let fmt = formatter();
475 let d = dep(
476 Some(PinStyle::Partial),
477 "org/proj",
478 DependencySource::Registry,
479 );
480 let requirement = VersionReq::new("1.2");
481 assert_eq!(
482 fmt.requirement_status_for(&d, &requirement, &ConcreteVersion::new("1.2.9")),
483 RequirementStatus::UpToDate
484 );
485 assert_eq!(
486 fmt.requirement_status_for(&d, &requirement, &ConcreteVersion::new("1.3.0")),
487 RequirementStatus::Outdated
488 );
489 }
490
491 #[test]
492 fn test_requirement_status_for_non_gitlab_dependency_falls_back_to_text() {
493 struct OtherDep;
496 impl Dependency for OtherDep {
497 fn name(&self) -> &PackageName {
498 unimplemented!()
499 }
500 fn name_range(&self) -> tower_lsp_server::ls_types::Range {
501 Range::default()
502 }
503 fn version_requirement(&self) -> Option<&VersionReq> {
504 None
505 }
506 fn version_range(&self) -> Option<tower_lsp_server::ls_types::Range> {
507 None
508 }
509 fn source(&self) -> DependencySource {
510 DependencySource::Registry
511 }
512 fn as_any(&self) -> &dyn std::any::Any {
513 self
514 }
515 }
516 let fmt = formatter();
517 let requirement = VersionReq::new("^1.0");
518 assert_eq!(
519 fmt.requirement_status_for(&OtherDep, &requirement, &ConcreteVersion::new("1.5.0")),
520 fmt.requirement_status(&requirement, &ConcreteVersion::new("1.5.0"))
521 );
522 }
523
524 #[test]
525 fn test_format_version_replacing_for_partial_returns_current_unchanged() {
526 let fmt = formatter();
527 let d = dep(
528 Some(PinStyle::Partial),
529 "org/proj",
530 DependencySource::Registry,
531 );
532 assert_eq!(
533 fmt.format_version_replacing_for(&d, &ConcreteVersion::new("1.3.0"), "1.2"),
534 "1.2"
535 );
536 }
537
538 #[test]
539 fn test_format_version_replacing_for_latest_returns_current_unchanged() {
540 let fmt = formatter();
541 let d = dep(
542 Some(PinStyle::Latest),
543 "org/proj",
544 DependencySource::Registry,
545 );
546 assert_eq!(
547 fmt.format_version_replacing_for(&d, &ConcreteVersion::new("2.0.0"), "~latest"),
548 "~latest"
549 );
550 }
551
552 #[test]
553 fn test_format_version_replacing_for_tag_preserves_v_style() {
554 let fmt = formatter();
555 let d = dep(Some(PinStyle::Tag), "org/proj", DependencySource::Registry);
556 assert_eq!(
557 fmt.format_version_replacing_for(&d, &ConcreteVersion::new("2.0.0"), "v1.0.0"),
558 "v2.0.0"
559 );
560 }
561
562 #[test]
563 fn test_osv_version_strips_v_prefix() {
564 let fmt = formatter();
565 assert_eq!(fmt.osv_version("v1.2.3"), "1.2.3");
566 }
567}