wasmer_config/package/
named_package_ident.rs

1use std::{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() -> String {
304        "NamedPackageIdent".to_string()
305    }
306
307    fn json_schema(r#gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
308        String::json_schema(r#gen)
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use std::str::FromStr;
315
316    use crate::package::PackageParseError;
317
318    use super::*;
319
320    #[test]
321    fn test_parse_webc_ident() {
322        // Success cases.
323
324        assert_eq!(
325            NamedPackageIdent::from_str("ns/name").unwrap(),
326            NamedPackageIdent {
327                registry: None,
328                namespace: Some("ns".to_string()),
329                name: "name".to_string(),
330                tag: None,
331            }
332        );
333
334        assert_eq!(
335            NamedPackageIdent::from_str("ns/name@").unwrap(),
336            NamedPackageIdent {
337                registry: None,
338                namespace: Some("ns".to_string()),
339                name: "name".to_string(),
340                tag: None,
341            },
342            "empty tag should be parsed as None"
343        );
344
345        assert_eq!(
346            NamedPackageIdent::from_str("ns/name@tag").unwrap(),
347            NamedPackageIdent {
348                registry: None,
349                namespace: Some("ns".to_string()),
350                name: "name".to_string(),
351                tag: Some(Tag::Named("tag".to_string())),
352            }
353        );
354
355        assert_eq!(
356            NamedPackageIdent::from_str("reg.com:ns/name").unwrap(),
357            NamedPackageIdent {
358                registry: Some("reg.com".to_string()),
359                namespace: Some("ns".to_string()),
360                name: "name".to_string(),
361                tag: None,
362            }
363        );
364
365        assert_eq!(
366            NamedPackageIdent::from_str("reg.com:ns/name@tag").unwrap(),
367            NamedPackageIdent {
368                registry: Some("reg.com".to_string()),
369                namespace: Some("ns".to_string()),
370                name: "name".to_string(),
371                tag: Some(Tag::Named("tag".to_string())),
372            }
373        );
374
375        assert_eq!(
376            NamedPackageIdent::from_str("reg.com:ns/name").unwrap(),
377            NamedPackageIdent {
378                registry: Some("reg.com".to_string()),
379                namespace: Some("ns".to_string()),
380                name: "name".to_string(),
381                tag: None,
382            }
383        );
384
385        assert_eq!(
386            NamedPackageIdent::from_str("reg.com:ns/name@tag").unwrap(),
387            NamedPackageIdent {
388                registry: Some("reg.com".to_string()),
389                namespace: Some("ns".to_string()),
390                name: "name".to_string(),
391                tag: Some(Tag::Named("tag".to_string())),
392            }
393        );
394
395        assert_eq!(
396            NamedPackageIdent::from_str("reg.com:ns/name").unwrap(),
397            NamedPackageIdent {
398                registry: Some("reg.com".to_string()),
399                namespace: Some("ns".to_string()),
400                name: "name".to_string(),
401                tag: None,
402            }
403        );
404
405        assert_eq!(
406            NamedPackageIdent::from_str("reg.com:ns/name@tag").unwrap(),
407            NamedPackageIdent {
408                registry: Some("reg.com".to_string()),
409                namespace: Some("ns".to_string()),
410                name: "name".to_string(),
411                tag: Some(Tag::Named("tag".to_string())),
412            }
413        );
414
415        // Failure cases.
416
417        assert_eq!(
418            NamedPackageIdent::from_str("alpha").unwrap(),
419            NamedPackageIdent {
420                registry: None,
421                namespace: None,
422                name: "alpha".to_string(),
423                tag: None,
424            },
425        );
426
427        assert_eq!(
428            NamedPackageIdent::from_str(""),
429            Err(PackageParseError::new("", "package name is required"))
430        );
431    }
432
433    #[test]
434    fn test_serde_serialize_package_ident_with_repo() {
435        // Serialize
436        let ident = NamedPackageIdent {
437            registry: Some("wapm.io".to_string()),
438            namespace: Some("ns".to_string()),
439            name: "name".to_string(),
440            tag: None,
441        };
442
443        let raw = serde_json::to_string(&ident).unwrap();
444        assert_eq!(raw, "\"wapm.io:ns/name\"");
445
446        let ident2 = serde_json::from_str::<NamedPackageIdent>(&raw).unwrap();
447        assert_eq!(ident, ident2);
448    }
449
450    #[test]
451    fn test_serde_serialize_webc_str_ident_without_repo() {
452        // Serialize
453        let ident = NamedPackageIdent {
454            registry: None,
455            namespace: Some("ns".to_string()),
456            name: "name".to_string(),
457            tag: None,
458        };
459
460        let raw = serde_json::to_string(&ident).unwrap();
461        assert_eq!(raw, "\"ns/name\"");
462
463        let ident2 = serde_json::from_str::<NamedPackageIdent>(&raw).unwrap();
464        assert_eq!(ident, ident2);
465    }
466
467    #[test]
468    fn test_named_package_ident_matches_id() {
469        assert!(
470            NamedPackageIdent::from_str("ns/name")
471                .unwrap()
472                .matches_id(&NamedPackageId::try_new("ns/name", "0.1.0").unwrap())
473        );
474
475        assert!(
476            NamedPackageIdent::from_str("ns/name")
477                .unwrap()
478                .matches_id(&NamedPackageId::try_new("ns/name", "1.0.1").unwrap())
479        );
480
481        assert!(
482            NamedPackageIdent::from_str("ns/name@1")
483                .unwrap()
484                .matches_id(&NamedPackageId::try_new("ns/name", "1.0.1").unwrap())
485        );
486
487        assert!(
488            !NamedPackageIdent::from_str("ns/name@2")
489                .unwrap()
490                .matches_id(&NamedPackageId::try_new("ns/name", "1.0.1").unwrap())
491        );
492    }
493}