wasmer_config/package/
named_package_ident.rs

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) => Ok(Self::VersionReq(v)),
49                Err(_) => Ok(Self::Named(s.to_string())),
50            }
51        }
52    }
53}
54
55/// Parsed representation of a package identifier.
56///
57/// Format:
58/// `https?://<domain>/namespace/name@version`
59/// where the registry, namespace, and version components are optional.
60#[derive(PartialEq, Eq, Clone, Debug, Hash)]
61pub struct NamedPackageIdent {
62    pub registry: Option<String>,
63    pub namespace: Option<String>,
64    pub name: String,
65    pub tag: Option<Tag>,
66}
67
68impl NamedPackageIdent {
69    pub fn try_from_full_name_and_version(
70        full_name: &str,
71        version: &str,
72    ) -> Result<Self, PackageParseError> {
73        let (namespace, name) = match full_name.split_once('/') {
74            Some((ns, name)) => (Some(ns.to_owned()), name.to_owned()),
75            None => (None, full_name.to_owned()),
76        };
77
78        let version = version
79            .parse::<VersionReq>()
80            .map_err(|e| PackageParseError::new(version, e.to_string()))?;
81
82        Ok(Self {
83            registry: None,
84            namespace,
85            name,
86            tag: Some(Tag::VersionReq(version)),
87        })
88    }
89
90    pub fn tag_str(&self) -> Option<String> {
91        self.tag.as_ref().map(|x| x.to_string())
92    }
93
94    /// Namespaced name.
95    ///
96    /// Eg: "namespace/name"
97    pub fn full_name(&self) -> String {
98        if let Some(ns) = &self.namespace {
99            format!("{}/{}", ns, self.name)
100        } else {
101            self.name.clone()
102        }
103    }
104
105    pub fn version_opt(&self) -> Option<&VersionReq> {
106        match &self.tag {
107            Some(Tag::VersionReq(v)) => Some(v),
108            Some(Tag::Named(_)) | None => None,
109        }
110    }
111
112    pub fn version_or_default(&self) -> VersionReq {
113        match &self.tag {
114            Some(Tag::VersionReq(v)) => v.clone(),
115            Some(Tag::Named(_)) | None => semver::VersionReq::STAR,
116        }
117    }
118
119    pub fn registry_url(&self) -> Result<Option<url::Url>, PackageParseError> {
120        let Some(reg) = &self.registry else {
121            return Ok(None);
122        };
123
124        let reg = if !reg.starts_with("http://") && !reg.starts_with("https://") {
125            format!("https://{reg}")
126        } else {
127            reg.clone()
128        };
129
130        url::Url::parse(&reg)
131            .map_err(|e| PackageParseError::new(reg, e.to_string()))
132            .map(Some)
133    }
134
135    /// Build the ident for a package.
136    ///
137    /// Format: `NAMESPACE/NAME@tag`
138    /// where the namespace and tag components are optional.
139    pub fn build_identifier(&self) -> String {
140        let mut ident = if let Some(ns) = &self.namespace {
141            format!("{}/{}", ns, self.name)
142        } else {
143            self.name.to_string()
144        };
145
146        if let Some(tag) = &self.tag {
147            ident.push('@');
148            // Writing to a string only fails on memory allocation errors.
149            write!(&mut ident, "{tag}").unwrap();
150        }
151        ident
152    }
153
154    pub fn build(&self) -> String {
155        let mut out = String::new();
156        if let Some(url) = &self.registry {
157            // NOTE: writing to a String can only fail on allocation errors.
158            write!(&mut out, "{url}").unwrap();
159
160            if !out.ends_with('/') {
161                out.push(':');
162            }
163        }
164        if let Some(ns) = &self.namespace {
165            out.push_str(ns);
166            out.push('/');
167        }
168        out.push_str(&self.name);
169        if let Some(tag) = &self.tag {
170            out.push('@');
171            // Writing to a string only fails on memory allocation errors.
172            write!(&mut out, "{tag}").unwrap();
173        }
174
175        out
176    }
177
178    /// Returns true if this ident matches the given package id.
179    ///
180    /// Semver constraints are matched against the package id's version.
181    pub fn matches_id(&self, id: &NamedPackageId) -> bool {
182        if self.full_name() == id.full_name {
183            if let Some(tag) = &self.tag {
184                match tag {
185                    Tag::Named(n) => n == &id.version.to_string(),
186                    Tag::VersionReq(v) => v.matches(&id.version),
187                }
188            } else {
189                true
190            }
191        } else {
192            false
193        }
194    }
195}
196
197impl From<NamedPackageId> for NamedPackageIdent {
198    fn from(value: NamedPackageId) -> Self {
199        let (namespace, name) = match value.full_name.split_once('/') {
200            Some((ns, name)) => (Some(ns.to_owned()), name.to_owned()),
201            None => (None, value.full_name),
202        };
203
204        Self {
205            registry: None,
206            namespace,
207            name,
208            tag: Some(Tag::VersionReq(semver::VersionReq {
209                comparators: vec![semver::Comparator {
210                    op: semver::Op::Exact,
211                    major: value.version.major,
212                    minor: Some(value.version.minor),
213                    patch: Some(value.version.patch),
214                    pre: value.version.pre,
215                }],
216            })),
217        }
218    }
219}
220
221impl std::str::FromStr for NamedPackageIdent {
222    type Err = PackageParseError;
223
224    fn from_str(value: &str) -> Result<Self, Self::Err> {
225        let (rest, tag_opt) = value
226            .trim()
227            .rsplit_once('@')
228            .map(|(x, y)| (x, if y.is_empty() { None } else { Some(y) }))
229            .unwrap_or((value, None));
230
231        let tag = if let Some(v) = tag_opt.filter(|x| !x.is_empty()) {
232            Some(Tag::from_str(v)?)
233        } else {
234            None
235        };
236
237        let (rest, name) = if let Some((r, n)) = rest.rsplit_once('/') {
238            (r, n)
239        } else {
240            ("", rest)
241        };
242
243        let name = name.trim();
244        if name.is_empty() {
245            return Err(PackageParseError::new(value, "package name is required"));
246        }
247
248        let (rest, namespace) = if rest.is_empty() {
249            ("", None)
250        } else {
251            let (rest, ns) = rest.rsplit_once(':').unwrap_or(("", rest));
252
253            let ns = ns.trim();
254
255            if ns.is_empty() {
256                return Err(PackageParseError::new(value, "namespace can not be empty"));
257            }
258            (rest, Some(ns.to_string()))
259        };
260
261        let rest = rest.trim();
262        let registry = if rest.is_empty() {
263            None
264        } else {
265            Some(rest.to_string())
266        };
267
268        Ok(Self {
269            registry,
270            namespace,
271            name: name.to_string(),
272            tag,
273        })
274    }
275}
276
277impl std::fmt::Display for NamedPackageIdent {
278    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279        write!(f, "{}", self.build())
280    }
281}
282
283impl serde::Serialize for NamedPackageIdent {
284    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
285    where
286        S: serde::ser::Serializer,
287    {
288        self.to_string().serialize(serializer)
289    }
290}
291
292impl<'de> serde::Deserialize<'de> for NamedPackageIdent {
293    fn deserialize<D>(deserializer: D) -> Result<NamedPackageIdent, D::Error>
294    where
295        D: serde::de::Deserializer<'de>,
296    {
297        let s = String::deserialize(deserializer)?;
298        Self::from_str(&s).map_err(serde::de::Error::custom)
299    }
300}
301
302impl schemars::JsonSchema for NamedPackageIdent {
303    fn schema_name() -> Cow<'static, str> {
304        Cow::Borrowed("NamedPackageIdent")
305    }
306
307    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
308        String::json_schema(generator)
309    }
310
311    fn inline_schema() -> bool {
312        false
313    }
314
315    fn schema_id() -> Cow<'static, str> {
316        Self::schema_name()
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use std::str::FromStr;
323
324    use crate::package::PackageParseError;
325
326    use super::*;
327
328    #[test]
329    fn test_parse_webc_ident() {
330        // Success cases.
331
332        assert_eq!(
333            NamedPackageIdent::from_str("ns/name").unwrap(),
334            NamedPackageIdent {
335                registry: None,
336                namespace: Some("ns".to_string()),
337                name: "name".to_string(),
338                tag: None,
339            }
340        );
341
342        assert_eq!(
343            NamedPackageIdent::from_str("ns/name@").unwrap(),
344            NamedPackageIdent {
345                registry: None,
346                namespace: Some("ns".to_string()),
347                name: "name".to_string(),
348                tag: None,
349            },
350            "empty tag should be parsed as None"
351        );
352
353        assert_eq!(
354            NamedPackageIdent::from_str("ns/name@tag").unwrap(),
355            NamedPackageIdent {
356                registry: None,
357                namespace: Some("ns".to_string()),
358                name: "name".to_string(),
359                tag: Some(Tag::Named("tag".to_string())),
360            }
361        );
362
363        assert_eq!(
364            NamedPackageIdent::from_str("reg.com:ns/name").unwrap(),
365            NamedPackageIdent {
366                registry: Some("reg.com".to_string()),
367                namespace: Some("ns".to_string()),
368                name: "name".to_string(),
369                tag: None,
370            }
371        );
372
373        assert_eq!(
374            NamedPackageIdent::from_str("reg.com:ns/name@tag").unwrap(),
375            NamedPackageIdent {
376                registry: Some("reg.com".to_string()),
377                namespace: Some("ns".to_string()),
378                name: "name".to_string(),
379                tag: Some(Tag::Named("tag".to_string())),
380            }
381        );
382
383        assert_eq!(
384            NamedPackageIdent::from_str("reg.com:ns/name").unwrap(),
385            NamedPackageIdent {
386                registry: Some("reg.com".to_string()),
387                namespace: Some("ns".to_string()),
388                name: "name".to_string(),
389                tag: None,
390            }
391        );
392
393        assert_eq!(
394            NamedPackageIdent::from_str("reg.com:ns/name@tag").unwrap(),
395            NamedPackageIdent {
396                registry: Some("reg.com".to_string()),
397                namespace: Some("ns".to_string()),
398                name: "name".to_string(),
399                tag: Some(Tag::Named("tag".to_string())),
400            }
401        );
402
403        assert_eq!(
404            NamedPackageIdent::from_str("reg.com:ns/name").unwrap(),
405            NamedPackageIdent {
406                registry: Some("reg.com".to_string()),
407                namespace: Some("ns".to_string()),
408                name: "name".to_string(),
409                tag: None,
410            }
411        );
412
413        assert_eq!(
414            NamedPackageIdent::from_str("reg.com:ns/name@tag").unwrap(),
415            NamedPackageIdent {
416                registry: Some("reg.com".to_string()),
417                namespace: Some("ns".to_string()),
418                name: "name".to_string(),
419                tag: Some(Tag::Named("tag".to_string())),
420            }
421        );
422
423        // Failure cases.
424
425        assert_eq!(
426            NamedPackageIdent::from_str("alpha").unwrap(),
427            NamedPackageIdent {
428                registry: None,
429                namespace: None,
430                name: "alpha".to_string(),
431                tag: None,
432            },
433        );
434
435        assert_eq!(
436            NamedPackageIdent::from_str(""),
437            Err(PackageParseError::new("", "package name is required"))
438        );
439    }
440
441    #[test]
442    fn test_serde_serialize_package_ident_with_repo() {
443        // Serialize
444        let ident = NamedPackageIdent {
445            registry: Some("wapm.io".to_string()),
446            namespace: Some("ns".to_string()),
447            name: "name".to_string(),
448            tag: None,
449        };
450
451        let raw = serde_json::to_string(&ident).unwrap();
452        assert_eq!(raw, "\"wapm.io:ns/name\"");
453
454        let ident2 = serde_json::from_str::<NamedPackageIdent>(&raw).unwrap();
455        assert_eq!(ident, ident2);
456    }
457
458    #[test]
459    fn test_serde_serialize_webc_str_ident_without_repo() {
460        // Serialize
461        let ident = NamedPackageIdent {
462            registry: None,
463            namespace: Some("ns".to_string()),
464            name: "name".to_string(),
465            tag: None,
466        };
467
468        let raw = serde_json::to_string(&ident).unwrap();
469        assert_eq!(raw, "\"ns/name\"");
470
471        let ident2 = serde_json::from_str::<NamedPackageIdent>(&raw).unwrap();
472        assert_eq!(ident, ident2);
473    }
474
475    #[test]
476    fn test_named_package_ident_matches_id() {
477        assert!(
478            NamedPackageIdent::from_str("ns/name")
479                .unwrap()
480                .matches_id(&NamedPackageId::try_new("ns/name", "0.1.0").unwrap())
481        );
482
483        assert!(
484            NamedPackageIdent::from_str("ns/name")
485                .unwrap()
486                .matches_id(&NamedPackageId::try_new("ns/name", "1.0.1").unwrap())
487        );
488
489        assert!(
490            NamedPackageIdent::from_str("ns/name@1")
491                .unwrap()
492                .matches_id(&NamedPackageId::try_new("ns/name", "1.0.1").unwrap())
493        );
494
495        assert!(
496            !NamedPackageIdent::from_str("ns/name@2")
497                .unwrap()
498                .matches_id(&NamedPackageId::try_new("ns/name", "1.0.1").unwrap())
499        );
500    }
501}