1use std::{borrow::Cow, fmt::Write, str::FromStr};
2
3use semver::VersionReq;
4
5use super::{NamedPackageId, PackageParseError};
6
7#[derive(PartialEq, Eq, Clone, Debug, Hash)]
8pub enum Tag {
9 Named(String),
10 VersionReq(semver::VersionReq),
11}
12
13impl Tag {
14 pub fn as_named(&self) -> Option<&String> {
15 if let Self::Named(v) = self {
16 Some(v)
17 } else {
18 None
19 }
20 }
21
22 pub fn as_version_req(&self) -> Option<&semver::VersionReq> {
23 if let Self::VersionReq(v) = self {
24 Some(v)
25 } else {
26 None
27 }
28 }
29}
30
31impl std::fmt::Display for Tag {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 match self {
34 Tag::Named(n) => n.fmt(f),
35 Tag::VersionReq(v) => v.fmt(f),
36 }
37 }
38}
39
40impl std::str::FromStr for Tag {
41 type Err = PackageParseError;
42
43 fn from_str(s: &str) -> Result<Self, Self::Err> {
44 if s == "latest" {
45 Ok(Self::VersionReq(semver::VersionReq::STAR))
46 } else {
47 match semver::VersionReq::from_str(s) {
48 Ok(v) => {
49 if s.contains('+') {
54 return Err(PackageParseError::new(
55 s,
56 "build metadata (`+...`) is not supported in a version requirement: \
57 it is ignored when matching versions and cannot select a specific \
58 build, so pin an exact version or a package hash instead",
59 ));
60 }
61 Ok(Self::VersionReq(v))
62 }
63 Err(_) => Ok(Self::Named(s.to_string())),
64 }
65 }
66 }
67}
68
69#[derive(PartialEq, Eq, Clone, Debug, Hash)]
75pub struct NamedPackageIdent {
76 pub registry: Option<String>,
77 pub namespace: Option<String>,
78 pub name: String,
79 pub tag: Option<Tag>,
80}
81
82impl NamedPackageIdent {
83 pub fn try_from_full_name_and_version(
84 full_name: &str,
85 version: &str,
86 ) -> Result<Self, PackageParseError> {
87 let (namespace, name) = match full_name.split_once('/') {
88 Some((ns, name)) => (Some(ns.to_owned()), name.to_owned()),
89 None => (None, full_name.to_owned()),
90 };
91
92 let version = version
93 .parse::<VersionReq>()
94 .map_err(|e| PackageParseError::new(version, e.to_string()))?;
95
96 Ok(Self {
97 registry: None,
98 namespace,
99 name,
100 tag: Some(Tag::VersionReq(version)),
101 })
102 }
103
104 pub fn tag_str(&self) -> Option<String> {
105 self.tag.as_ref().map(|x| x.to_string())
106 }
107
108 pub fn full_name(&self) -> String {
112 if let Some(ns) = &self.namespace {
113 format!("{}/{}", ns, self.name)
114 } else {
115 self.name.clone()
116 }
117 }
118
119 pub fn version_opt(&self) -> Option<&VersionReq> {
120 match &self.tag {
121 Some(Tag::VersionReq(v)) => Some(v),
122 Some(Tag::Named(_)) | None => None,
123 }
124 }
125
126 pub fn version_or_default(&self) -> VersionReq {
127 match &self.tag {
128 Some(Tag::VersionReq(v)) => v.clone(),
129 Some(Tag::Named(_)) | None => semver::VersionReq::STAR,
130 }
131 }
132
133 pub fn registry_url(&self) -> Result<Option<url::Url>, PackageParseError> {
134 let Some(reg) = &self.registry else {
135 return Ok(None);
136 };
137
138 let reg = if !reg.starts_with("http://") && !reg.starts_with("https://") {
139 format!("https://{reg}")
140 } else {
141 reg.clone()
142 };
143
144 url::Url::parse(®)
145 .map_err(|e| PackageParseError::new(reg, e.to_string()))
146 .map(Some)
147 }
148
149 pub fn build_identifier(&self) -> String {
154 let mut ident = if let Some(ns) = &self.namespace {
155 format!("{}/{}", ns, self.name)
156 } else {
157 self.name.to_string()
158 };
159
160 if let Some(tag) = &self.tag {
161 ident.push('@');
162 write!(&mut ident, "{tag}").unwrap();
164 }
165 ident
166 }
167
168 pub fn build(&self) -> String {
169 let mut out = String::new();
170 if let Some(url) = &self.registry {
171 write!(&mut out, "{url}").unwrap();
173
174 if !out.ends_with('/') {
175 out.push(':');
176 }
177 }
178 if let Some(ns) = &self.namespace {
179 out.push_str(ns);
180 out.push('/');
181 }
182 out.push_str(&self.name);
183 if let Some(tag) = &self.tag {
184 out.push('@');
185 write!(&mut out, "{tag}").unwrap();
187 }
188
189 out
190 }
191
192 pub fn matches_id(&self, id: &NamedPackageId) -> bool {
196 if self.full_name() == id.full_name {
197 if let Some(tag) = &self.tag {
198 match tag {
199 Tag::Named(n) => n == &id.version.to_string(),
200 Tag::VersionReq(v) => v.matches(&id.version),
201 }
202 } else {
203 true
204 }
205 } else {
206 false
207 }
208 }
209}
210
211impl From<NamedPackageId> for NamedPackageIdent {
212 fn from(value: NamedPackageId) -> Self {
213 let (namespace, name) = match value.full_name.split_once('/') {
214 Some((ns, name)) => (Some(ns.to_owned()), name.to_owned()),
215 None => (None, value.full_name),
216 };
217
218 Self {
219 registry: None,
220 namespace,
221 name,
222 tag: Some(Tag::VersionReq(semver::VersionReq {
223 comparators: vec![semver::Comparator {
224 op: semver::Op::Exact,
225 major: value.version.major,
226 minor: Some(value.version.minor),
227 patch: Some(value.version.patch),
228 pre: value.version.pre,
229 }],
230 })),
231 }
232 }
233}
234
235impl std::str::FromStr for NamedPackageIdent {
236 type Err = PackageParseError;
237
238 fn from_str(value: &str) -> Result<Self, Self::Err> {
239 let (rest, tag_opt) = value
240 .trim()
241 .rsplit_once('@')
242 .map(|(x, y)| (x, if y.is_empty() { None } else { Some(y) }))
243 .unwrap_or((value, None));
244
245 let tag = if let Some(v) = tag_opt.filter(|x| !x.is_empty()) {
246 Some(Tag::from_str(v)?)
247 } else {
248 None
249 };
250
251 let (rest, name) = if let Some((r, n)) = rest.rsplit_once('/') {
252 (r, n)
253 } else {
254 ("", rest)
255 };
256
257 let name = name.trim();
258 if name.is_empty() {
259 return Err(PackageParseError::new(value, "package name is required"));
260 }
261
262 let (rest, namespace) = if rest.is_empty() {
263 ("", None)
264 } else {
265 let (rest, ns) = rest.rsplit_once(':').unwrap_or(("", rest));
266
267 let ns = ns.trim();
268
269 if ns.is_empty() {
270 return Err(PackageParseError::new(value, "namespace can not be empty"));
271 }
272 (rest, Some(ns.to_string()))
273 };
274
275 let rest = rest.trim();
276 let registry = if rest.is_empty() {
277 None
278 } else {
279 Some(rest.to_string())
280 };
281
282 Ok(Self {
283 registry,
284 namespace,
285 name: name.to_string(),
286 tag,
287 })
288 }
289}
290
291impl std::fmt::Display for NamedPackageIdent {
292 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
293 write!(f, "{}", self.build())
294 }
295}
296
297impl serde::Serialize for NamedPackageIdent {
298 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
299 where
300 S: serde::ser::Serializer,
301 {
302 self.to_string().serialize(serializer)
303 }
304}
305
306impl<'de> serde::Deserialize<'de> for NamedPackageIdent {
307 fn deserialize<D>(deserializer: D) -> Result<NamedPackageIdent, D::Error>
308 where
309 D: serde::de::Deserializer<'de>,
310 {
311 let s = String::deserialize(deserializer)?;
312 Self::from_str(&s).map_err(serde::de::Error::custom)
313 }
314}
315
316impl schemars::JsonSchema for NamedPackageIdent {
317 fn schema_name() -> Cow<'static, str> {
318 Cow::Borrowed("NamedPackageIdent")
319 }
320
321 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
322 String::json_schema(generator)
323 }
324
325 fn inline_schema() -> bool {
326 false
327 }
328
329 fn schema_id() -> Cow<'static, str> {
330 Self::schema_name()
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use std::str::FromStr;
337
338 use crate::package::PackageParseError;
339
340 use super::*;
341
342 #[test]
343 fn test_parse_webc_ident() {
344 assert_eq!(
347 NamedPackageIdent::from_str("ns/name").unwrap(),
348 NamedPackageIdent {
349 registry: None,
350 namespace: Some("ns".to_string()),
351 name: "name".to_string(),
352 tag: None,
353 }
354 );
355
356 assert_eq!(
357 NamedPackageIdent::from_str("ns/name@").unwrap(),
358 NamedPackageIdent {
359 registry: None,
360 namespace: Some("ns".to_string()),
361 name: "name".to_string(),
362 tag: None,
363 },
364 "empty tag should be parsed as None"
365 );
366
367 assert_eq!(
368 NamedPackageIdent::from_str("ns/name@tag").unwrap(),
369 NamedPackageIdent {
370 registry: None,
371 namespace: Some("ns".to_string()),
372 name: "name".to_string(),
373 tag: Some(Tag::Named("tag".to_string())),
374 }
375 );
376
377 assert_eq!(
378 NamedPackageIdent::from_str("reg.com:ns/name").unwrap(),
379 NamedPackageIdent {
380 registry: Some("reg.com".to_string()),
381 namespace: Some("ns".to_string()),
382 name: "name".to_string(),
383 tag: None,
384 }
385 );
386
387 assert_eq!(
388 NamedPackageIdent::from_str("reg.com:ns/name@tag").unwrap(),
389 NamedPackageIdent {
390 registry: Some("reg.com".to_string()),
391 namespace: Some("ns".to_string()),
392 name: "name".to_string(),
393 tag: Some(Tag::Named("tag".to_string())),
394 }
395 );
396
397 assert_eq!(
398 NamedPackageIdent::from_str("reg.com:ns/name").unwrap(),
399 NamedPackageIdent {
400 registry: Some("reg.com".to_string()),
401 namespace: Some("ns".to_string()),
402 name: "name".to_string(),
403 tag: None,
404 }
405 );
406
407 assert_eq!(
408 NamedPackageIdent::from_str("reg.com:ns/name@tag").unwrap(),
409 NamedPackageIdent {
410 registry: Some("reg.com".to_string()),
411 namespace: Some("ns".to_string()),
412 name: "name".to_string(),
413 tag: Some(Tag::Named("tag".to_string())),
414 }
415 );
416
417 assert_eq!(
418 NamedPackageIdent::from_str("reg.com:ns/name").unwrap(),
419 NamedPackageIdent {
420 registry: Some("reg.com".to_string()),
421 namespace: Some("ns".to_string()),
422 name: "name".to_string(),
423 tag: None,
424 }
425 );
426
427 assert_eq!(
428 NamedPackageIdent::from_str("reg.com:ns/name@tag").unwrap(),
429 NamedPackageIdent {
430 registry: Some("reg.com".to_string()),
431 namespace: Some("ns".to_string()),
432 name: "name".to_string(),
433 tag: Some(Tag::Named("tag".to_string())),
434 }
435 );
436
437 assert_eq!(
440 NamedPackageIdent::from_str("alpha").unwrap(),
441 NamedPackageIdent {
442 registry: None,
443 namespace: None,
444 name: "alpha".to_string(),
445 tag: None,
446 },
447 );
448
449 assert_eq!(
450 NamedPackageIdent::from_str(""),
451 Err(PackageParseError::new("", "package name is required"))
452 );
453 }
454
455 #[test]
456 fn test_reject_build_metadata_in_version_req() {
457 assert!(
459 Tag::from_str("1.2.3+rel.1").is_err(),
460 "build metadata must be rejected in a version requirement"
461 );
462 assert!(
463 NamedPackageIdent::from_str("ns/name@1.2.3+rel.1").is_err(),
464 "a package ident with build metadata in its version must be rejected"
465 );
466
467 assert_eq!(
469 NamedPackageIdent::from_str("ns/name@1.2.3").unwrap().tag,
470 Some(Tag::VersionReq(VersionReq::from_str("1.2.3").unwrap())),
471 );
472
473 assert_eq!(
475 Tag::from_str("my+tag").unwrap(),
476 Tag::Named("my+tag".to_string()),
477 );
478
479 let err = crate::package::PackageSource::from_str("ns/name@1.2.3+rel.1")
482 .expect_err("build metadata must be rejected via PackageSource too");
483 assert!(
484 err.to_string().contains("build metadata"),
485 "user-facing error should explain the build metadata problem, got: {err}"
486 );
487 }
488
489 #[test]
490 fn test_serde_serialize_package_ident_with_repo() {
491 let ident = NamedPackageIdent {
493 registry: Some("wapm.io".to_string()),
494 namespace: Some("ns".to_string()),
495 name: "name".to_string(),
496 tag: None,
497 };
498
499 let raw = serde_json::to_string(&ident).unwrap();
500 assert_eq!(raw, "\"wapm.io:ns/name\"");
501
502 let ident2 = serde_json::from_str::<NamedPackageIdent>(&raw).unwrap();
503 assert_eq!(ident, ident2);
504 }
505
506 #[test]
507 fn test_serde_serialize_webc_str_ident_without_repo() {
508 let ident = NamedPackageIdent {
510 registry: None,
511 namespace: Some("ns".to_string()),
512 name: "name".to_string(),
513 tag: None,
514 };
515
516 let raw = serde_json::to_string(&ident).unwrap();
517 assert_eq!(raw, "\"ns/name\"");
518
519 let ident2 = serde_json::from_str::<NamedPackageIdent>(&raw).unwrap();
520 assert_eq!(ident, ident2);
521 }
522
523 #[test]
524 fn test_named_package_ident_matches_id() {
525 assert!(
526 NamedPackageIdent::from_str("ns/name")
527 .unwrap()
528 .matches_id(&NamedPackageId::try_new("ns/name", "0.1.0").unwrap())
529 );
530
531 assert!(
532 NamedPackageIdent::from_str("ns/name")
533 .unwrap()
534 .matches_id(&NamedPackageId::try_new("ns/name", "1.0.1").unwrap())
535 );
536
537 assert!(
538 NamedPackageIdent::from_str("ns/name@1")
539 .unwrap()
540 .matches_id(&NamedPackageId::try_new("ns/name", "1.0.1").unwrap())
541 );
542
543 assert!(
544 !NamedPackageIdent::from_str("ns/name@2")
545 .unwrap()
546 .matches_id(&NamedPackageId::try_new("ns/name", "1.0.1").unwrap())
547 );
548 }
549}