alpm_types/
url.rs

1//! Types for handling URLs and VCS-related information in package sources.
2
3use std::{
4    fmt::{Display, Formatter},
5    str::FromStr,
6};
7
8use alpm_parsers::iter_str_context;
9use serde::{Deserialize, Serialize};
10use winnow::{
11    ModalResult,
12    Parser,
13    ascii::{alpha1, space0},
14    combinator::{alt, cut_err, eof, fail, opt, peek, repeat_till, terminated},
15    error::{StrContext, StrContextValue},
16    token::{any, rest},
17};
18
19use crate::Error;
20
21/// Represents a URL.
22///
23/// It is used to represent the upstream URL of a package.
24/// This type does not yet enforce a secure connection (e.g. HTTPS).
25///
26/// The `Url` type wraps the [`url::Url`] type.
27///
28/// ## Examples
29///
30/// ```
31/// use std::str::FromStr;
32///
33/// use alpm_types::Url;
34///
35/// # fn main() -> Result<(), alpm_types::Error> {
36/// // Create Url from &str
37/// let url = Url::from_str("https://example.com/download")?;
38/// assert_eq!(url.as_str(), "https://example.com/download");
39///
40/// // Format as String
41/// assert_eq!(format!("{url}"), "https://example.com/download");
42/// # Ok(())
43/// # }
44/// ```
45#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
46pub struct Url(url::Url);
47
48impl Url {
49    /// Creates a new `Url` instance.
50    pub fn new(url: url::Url) -> Result<Self, Error> {
51        Ok(Self(url))
52    }
53
54    /// Returns a reference to the inner `url::Url` as a `&str`.
55    pub fn as_str(&self) -> &str {
56        self.0.as_str()
57    }
58
59    /// Consumes the `Url` and returns the inner `url::Url`.
60    pub fn into_inner(self) -> url::Url {
61        self.0
62    }
63
64    /// Returns a reference to the inner `url::Url`.
65    pub fn inner(&self) -> &url::Url {
66        &self.0
67    }
68}
69
70impl AsRef<str> for Url {
71    fn as_ref(&self) -> &str {
72        self.as_str()
73    }
74}
75
76impl FromStr for Url {
77    type Err = Error;
78
79    /// Creates a new `Url` instance from a string slice.
80    ///
81    /// ## Examples
82    ///
83    /// ```
84    /// use std::str::FromStr;
85    ///
86    /// use alpm_types::Url;
87    ///
88    /// # fn main() -> Result<(), alpm_types::Error> {
89    /// let url = Url::from_str("https://archlinux.org/")?;
90    /// assert_eq!(url.as_str(), "https://archlinux.org/");
91    /// # Ok(())
92    /// # }
93    /// ```
94    fn from_str(s: &str) -> Result<Self, Self::Err> {
95        let url = url::Url::parse(s).map_err(Error::InvalidUrl)?;
96        Self::new(url)
97    }
98}
99
100impl Display for Url {
101    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
102        write!(f, "{}", self.as_str())
103    }
104}
105
106/// A URL for package sources.
107///
108/// Wraps the [`Url`] type and provides optional information on [VCS] systems.
109///
110/// Can be created from custom URL strings, that in part resemble the default [URL syntax], e.g.:
111///
112/// ```txt
113/// git+https://example.org/example-project.git#tag=v1.0.0?signed
114/// ```
115///
116/// The above example provides an overview of the custom URL syntax:
117///
118/// - The optional [VCS] specifier `git` is prepended, directly followed by a "+" sign as delimiter,
119/// - specific URL `fragment` types such as `tag` are used to encode information about the
120///   particular VCS objects to address,
121/// - the URL `query` component `signed` is used to indicate that OpenPGP signature verification is
122///   required for a VCS type.
123///
124/// ## Note
125///
126/// The URL format used by [`SourceUrl`] deviates from the default [URL syntax] by allowing to
127/// change the order of the `query` and `fragment` component!
128///
129/// Refer to the [alpm-package-source] documentation for a more detailed overview of the custom URL
130/// syntax.
131///
132/// [URL syntax]: https://en.wikipedia.org/wiki/URL#Syntax
133/// [VCS]: https://en.wikipedia.org/wiki/Version_control
134/// [alpm-package-source]: https://alpm.archlinux.page/specifications/alpm-package-source.7.html
135///
136/// ## Examples
137///
138/// ```
139/// use std::str::FromStr;
140///
141/// use alpm_types::SourceUrl;
142///
143/// # fn main() -> Result<(), alpm_types::Error> {
144/// // Create Url from &str
145/// let url =
146///     SourceUrl::from_str("git+https://your-vcs.org/example-project.git?signed#tag=v1.0.0")?;
147/// assert_eq!(
148///     &url.to_string(),
149///     "git+https://your-vcs.org/example-project.git?signed#tag=v1.0.0"
150/// );
151/// # Ok(())
152/// # }
153/// ```
154#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
155pub struct SourceUrl {
156    /// The URL from where the sources are retrieved.
157    pub url: Url,
158    /// Optional data on VCS systems using the URL for the retrieval of sources.
159    pub vcs_info: Option<VcsInfo>,
160}
161
162impl FromStr for SourceUrl {
163    type Err = Error;
164
165    /// Creates a new `SourceUrl` instance from a string slice.
166    ///
167    /// ## Examples
168    ///
169    /// ```
170    /// use std::str::FromStr;
171    ///
172    /// use alpm_types::SourceUrl;
173    ///
174    /// # fn main() -> Result<(), alpm_types::Error> {
175    /// let url =
176    ///     SourceUrl::from_str("git+https://your-vcs.org/example-project.git?signed#tag=v1.0.0")?;
177    /// assert_eq!(
178    ///     &url.to_string(),
179    ///     "git+https://your-vcs.org/example-project.git?signed#tag=v1.0.0"
180    /// );
181    /// # Ok(())
182    /// # }
183    /// ```
184    fn from_str(s: &str) -> Result<Self, Self::Err> {
185        Ok(Self::parser.parse(s)?)
186    }
187}
188
189impl Display for SourceUrl {
190    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
191        // If there's no vcs info, print the URL and return.
192        let Some(vcs_info) = &self.vcs_info else {
193            return write!(f, "{}", self.url.as_str());
194        };
195
196        let mut prefix = None;
197        let url = self.url.as_str();
198        let mut formatted_fragment = String::new();
199        let mut query = String::new();
200
201        // Build all components of a source url, based on the protocol and provided options
202        match vcs_info {
203            VcsInfo::Bzr { fragment } => {
204                prefix = Some(VcsProtocol::Bzr);
205                if let Some(fragment) = fragment {
206                    formatted_fragment = format!("#{fragment}");
207                }
208            }
209            VcsInfo::Fossil { fragment } => {
210                prefix = Some(VcsProtocol::Fossil);
211                if let Some(fragment) = fragment {
212                    formatted_fragment = format!("#{fragment}");
213                }
214            }
215            VcsInfo::Git { fragment, signed } => {
216                // Only add the protocol prefix if the URL doesn't already encode the protocol
217                if !url.starts_with("git://") {
218                    prefix = Some(VcsProtocol::Git);
219                }
220                if *signed {
221                    query = "?signed".to_string();
222                }
223                if let Some(fragment) = fragment {
224                    formatted_fragment = format!("#{fragment}");
225                }
226            }
227            VcsInfo::Hg { fragment } => {
228                prefix = Some(VcsProtocol::Hg);
229                if let Some(fragment) = fragment {
230                    formatted_fragment = format!("#{fragment}");
231                }
232            }
233            VcsInfo::Svn { fragment } => {
234                // Only add the prefix if the URL doesn't already encode the protocol
235                if !url.starts_with("svn://") {
236                    prefix = Some(VcsProtocol::Svn);
237                }
238                if let Some(fragment) = fragment {
239                    formatted_fragment = format!("#{fragment}");
240                }
241            }
242        }
243
244        let prefix = if let Some(prefix) = prefix {
245            format!("{prefix}+")
246        } else {
247            String::new()
248        };
249
250        write!(f, "{prefix}{url}{query}{formatted_fragment}",)
251    }
252}
253
254impl SourceUrl {
255    /// Parses a full [`SourceUrl`] from a string slice.
256    fn parser(input: &mut &str) -> ModalResult<SourceUrl> {
257        // Check if we should use a VCS for this URL.
258        let vcs = opt(VcsProtocol::parser).parse_next(input)?;
259
260        let Some(vcs) = vcs else {
261            // If there's no VCS, simply interpret the rest of the string as a URL.
262            //
263            // We explicitly don't look for ALPM related fragments or queries, as the fragment and
264            // query might be a part of the inner URL string for retrieving the sources.
265            let url = cut_err(rest.try_map(Url::from_str))
266                .context(StrContext::Label("url"))
267                .parse_next(input)?;
268            return Ok(SourceUrl {
269                url,
270                vcs_info: None,
271            });
272        };
273
274        // We now know that we look at a URL that's supposed to be used by a VCS.
275        // Get the URL first, error if we cannot find it.
276        let url = cut_err(SourceUrl::inner_url_parser.try_map(|url| Url::from_str(&url)))
277            .context(StrContext::Label("url"))
278            .parse_next(input)?;
279
280        let vcs_info = VcsInfo::parser(vcs).parse_next(input)?;
281
282        // Produce a special error message for unconsumed query parameters.
283        // The unused result with error type are necessary to please the type checker.
284        let _: Option<String> =
285            opt(("?", rest)
286                .take()
287                .and_then(cut_err(fail.context(StrContext::Label(
288                    "or duplicate query parameter for detected VCS.",
289                )))))
290            .parse_next(input)?;
291
292        cut_err((space0, eof))
293            .context(StrContext::Label("unexpected trailing content in URL."))
294            .context(StrContext::Expected(StrContextValue::Description(
295                "end of input.",
296            )))
297            .parse_next(input)?;
298
299        Ok(SourceUrl {
300            url,
301            vcs_info: Some(vcs_info),
302        })
303    }
304
305    /// Recognizes a URL in an alpm-package-source string.
306    ///
307    /// Considers all chars until a special char or the EOF is encountered:
308    /// - `#` character that indicates a fragment
309    /// - `?` character indicates a query
310    /// - `EOF` we reached the end of the string.
311    ///
312    /// All of the above indicate that the end of the URL has been reached.
313    /// The `#` or `?` are not consumed, so that an outer parser may continue parsing afterwards.
314    fn inner_url_parser(input: &mut &str) -> ModalResult<String> {
315        let (url, _) = repeat_till(0.., any, peek(alt(("#", "?", eof)))).parse_next(input)?;
316        Ok(url)
317    }
318}
319
320/// Information on Version Control Systems (VCS) using a URL.
321///
322/// Several different VCS systems can be used in the context of a [`SourceUrl`].
323/// Each system supports addressing different types of objects and may optionally require signature
324/// verification for those objects.
325#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
326#[serde(tag = "protocol", rename_all = "lowercase")]
327pub enum VcsInfo {
328    /// Bazaar/Breezy VCS information.
329    Bzr {
330        /// Optional URL fragment information.
331        fragment: Option<BzrFragment>,
332    },
333    /// Fossil VCS information.
334    Fossil {
335        /// Optional URL fragment information.
336        fragment: Option<FossilFragment>,
337    },
338    /// Git VCS information.
339    Git {
340        /// Optional URL fragment information.
341        fragment: Option<GitFragment>,
342        /// Whether OpenPGP signature verification is required.
343        signed: bool,
344    },
345    /// Mercurial VCS information.
346    Hg {
347        /// Optional URL fragment information.
348        fragment: Option<HgFragment>,
349    },
350    /// Apache Subversion VCS information.
351    Svn {
352        /// Optional URL fragment information.
353        fragment: Option<SvnFragment>,
354    },
355}
356
357impl VcsInfo {
358    /// Recognizes VCS-specific URL fragment and query based on a [`VcsProtocol`].
359    ///
360    /// As the parser is parameterized due to the earlier detected [`VcsProtocol`], it returns a
361    /// new stateful parser closure.
362    fn parser(vcs: VcsProtocol) -> impl FnMut(&mut &str) -> ModalResult<VcsInfo> {
363        move |input: &mut &str| match vcs {
364            VcsProtocol::Bzr => {
365                let fragment = opt(BzrFragment::parser).parse_next(input)?;
366                Ok(VcsInfo::Bzr { fragment })
367            }
368            VcsProtocol::Fossil => {
369                let fragment = opt(FossilFragment::parser).parse_next(input)?;
370                Ok(VcsInfo::Fossil { fragment })
371            }
372            VcsProtocol::Git => {
373                // Pacman actually allows a parameter **after** the fragment, which is
374                // theoretically an invalid URL.
375                // Hence, we have to check for the parameter before and after the url.
376                let mut signed = git_query(input)?;
377                let fragment = opt(GitFragment::parser).parse_next(input)?;
378                if !signed {
379                    // Check for the theoretically invalid query after the fragment if it wasn't
380                    // already at the front.
381                    signed = git_query(input)?;
382                }
383                Ok(VcsInfo::Git { fragment, signed })
384            }
385            VcsProtocol::Hg => {
386                let fragment = opt(HgFragment::parser).parse_next(input)?;
387                Ok(VcsInfo::Hg { fragment })
388            }
389            VcsProtocol::Svn => {
390                let fragment = opt(SvnFragment::parser).parse_next(input)?;
391                Ok(VcsInfo::Svn { fragment })
392            }
393        }
394    }
395}
396
397/// A VCS protocol
398///
399/// This identifier is only used during parsing to have some static representation of the detected
400/// VCS.
401/// This is necessary as the fragment and the query are parsed at a later step and we have to
402/// keep track of the VCS somehow.
403#[derive(strum::Display, strum::EnumString)]
404#[strum(serialize_all = "lowercase")]
405enum VcsProtocol {
406    Bzr,
407    Fossil,
408    Git,
409    Hg,
410    Svn,
411}
412
413impl VcsProtocol {
414    /// Parses the start of an alpm-package-source string to determine the VCS protocol in use.
415    ///
416    /// VCS protocol information is used in [`SourceUrl`]s and can be detected in the following
417    /// ways:
418    ///
419    /// - An explicit VCS protocol identifier, followed by a literal `+`. E.g. `git+https://...`, `svn+https://...`
420    /// - Some VCS (i.e. git and svn) support URLs in which their protocol type is exposed in the
421    ///   `scheme` component of the URL itself:
422    ///    - `git://...`
423    ///    - `svn://...`
424    fn parser(input: &mut &str) -> ModalResult<VcsProtocol> {
425        // Check for an explicit vcs definition like `git+` first.
426        let protocol =
427            opt(terminated(alpha1.try_map(VcsProtocol::from_str), "+")).parse_next(input)?;
428
429        if let Some(protocol) = protocol {
430            return Ok(protocol);
431        }
432
433        // We didn't find any explicit identifiers.
434        // Now see if we find any vcs protocol at the start of the URL.
435        // Make sure to **not** consume anything from inside URL!
436        //
437        // If this doesn't find anything, it backtracks to the parent function.
438        let protocol = peek(alt(("git://", "svn://"))).parse_next(input)?;
439
440        match protocol {
441            "git://" => Ok(VcsProtocol::Git),
442            "svn://" => Ok(VcsProtocol::Svn),
443            _ => unreachable!(),
444        }
445    }
446}
447
448/// Parses the value of a URL fragment from an alpm-package-source string.
449///
450/// Parsing is attempted after the URL fragment type has been determined.
451///
452/// E.g. `tag=v1.0.0`
453///           ^^^^^^
454///          This part
455fn fragment_value(input: &mut &str) -> ModalResult<String> {
456    // Error if we don't find the separator
457    let _ = cut_err("=")
458        .context(StrContext::Label("fragment separator"))
459        .context(StrContext::Expected(StrContextValue::Description(
460            "a literal '='",
461        )))
462        .parse_next(input)?;
463
464    // Get the value of the fragment.
465    let (value, _) = repeat_till(0.., any, peek(alt(("?", "#", eof)))).parse_next(input)?;
466
467    Ok(value)
468}
469
470/// The available URL fragments and their values when using the Breezy VCS in a [`SourceUrl`].
471#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
472#[serde(rename_all = "snake_case")]
473pub enum BzrFragment {
474    /// A specific revision in the repository.
475    Revision(String),
476}
477
478impl Display for BzrFragment {
479    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
480        match self {
481            BzrFragment::Revision(revision) => write!(f, "revision={revision}"),
482        }
483    }
484}
485
486impl BzrFragment {
487    /// Recognizes URL fragments and values specific to Breezy VCS.
488    ///
489    /// This parser considers all variants of [`BzrFragment`] (including a leading `#` character).
490    fn parser(input: &mut &str) -> ModalResult<BzrFragment> {
491        // Check for the `#` fragment start first. If it isn't here, backtrack.
492        let _ = "#".parse_next(input)?;
493
494        // Expect the only allowed revision keyword.
495        cut_err("revision")
496            .context(StrContext::Label("bzr revision type"))
497            .context(StrContext::Expected(StrContextValue::Description(
498                "revision keyword",
499            )))
500            .parse_next(input)?;
501
502        let value = fragment_value.parse_next(input)?;
503
504        Ok(BzrFragment::Revision(value))
505    }
506}
507
508/// The available URL fragments and their values when using the Fossil VCS in a [`SourceUrl`].
509#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
510#[serde(rename_all = "snake_case")]
511pub enum FossilFragment {
512    /// A specific branch in the repository.
513    Branch(String),
514    /// A specific commit in the repository.
515    Commit(String),
516    /// A specific tag in the repository.
517    Tag(String),
518}
519
520impl Display for FossilFragment {
521    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
522        match self {
523            FossilFragment::Branch(revision) => write!(f, "branch={revision}"),
524            FossilFragment::Commit(revision) => write!(f, "commit={revision}"),
525            FossilFragment::Tag(revision) => write!(f, "tag={revision}"),
526        }
527    }
528}
529
530impl FossilFragment {
531    /// Recognizes URL fragments and values specific to Fossil VCS.
532    ///
533    /// This parser considers all variants of [`FossilFragment`] as fragments in an
534    /// alpm-package-source string (including the leading `#` character).
535    fn parser(input: &mut &str) -> ModalResult<FossilFragment> {
536        // Check for the `#` fragment start first. If it isn't here, backtrack.
537        let _ = "#".parse_next(input)?;
538
539        // Error if we don't find one of the expected fossil revision types.
540        let version_keywords = ["branch", "commit", "tag"];
541        let version_type = cut_err(alt(version_keywords))
542            .context(StrContext::Label("fossil revision type"))
543            .context_with(iter_str_context!([version_keywords]))
544            .parse_next(input)?;
545
546        let value = fragment_value.parse_next(input)?;
547
548        match version_type {
549            "branch" => Ok(FossilFragment::Branch(value.to_string())),
550            "commit" => Ok(FossilFragment::Commit(value.to_string())),
551            "tag" => Ok(FossilFragment::Tag(value.to_string())),
552            _ => unreachable!(),
553        }
554    }
555}
556
557/// The available URL fragments and their values when using the Git VCS in a [`SourceUrl`].
558#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
559#[serde(rename_all = "snake_case")]
560pub enum GitFragment {
561    /// A specific branch in the repository.
562    Branch(String),
563    /// A specific commit in the repository.
564    Commit(String),
565    /// A specific tag in the repository.
566    Tag(String),
567}
568
569impl Display for GitFragment {
570    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
571        match self {
572            GitFragment::Branch(revision) => write!(f, "branch={revision}"),
573            GitFragment::Commit(revision) => write!(f, "commit={revision}"),
574            GitFragment::Tag(revision) => write!(f, "tag={revision}"),
575        }
576    }
577}
578
579impl GitFragment {
580    /// Recognizes URL fragments and values specific to the Git VCS.
581    ///
582    /// This parser considers all variants of [`GitFragment`] as fragments in an alpm-package-source
583    /// string (including the leading `#` character).
584    fn parser(input: &mut &str) -> ModalResult<GitFragment> {
585        // Check for the `#` fragment start first. If it isn't here, backtrack.
586        let _ = "#".parse_next(input)?;
587
588        // Error if we don't find one of the expected git revision types.
589        let version_keywords = ["branch", "commit", "tag"];
590        let version_type = cut_err(alt(version_keywords))
591            .context(StrContext::Label("git revision type"))
592            .context_with(iter_str_context!([version_keywords]))
593            .parse_next(input)?;
594
595        let value = fragment_value.parse_next(input)?;
596
597        match version_type {
598            "branch" => Ok(GitFragment::Branch(value.to_string())),
599            "commit" => Ok(GitFragment::Commit(value.to_string())),
600            "tag" => Ok(GitFragment::Tag(value.to_string())),
601            _ => unreachable!(),
602        }
603    }
604}
605
606/// Recognizes URL queries specific to the Git VCS.
607///
608/// This parser considers the `?signed` URL query in an alpm-package-source string.
609fn git_query(input: &mut &str) -> ModalResult<bool> {
610    let query = opt("?signed").parse_next(input)?;
611    Ok(query.is_some())
612}
613
614/// An optional version specification used in a [`SourceUrl`] for the Hg VCS.
615#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
616#[serde(rename_all = "snake_case")]
617pub enum HgFragment {
618    /// A specific branch in the repository.
619    Branch(String),
620    /// A specific revision in the repository.
621    Revision(String),
622    /// A specific tag in the repository.
623    Tag(String),
624}
625
626impl Display for HgFragment {
627    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
628        match self {
629            HgFragment::Branch(revision) => write!(f, "branch={revision}"),
630            HgFragment::Revision(revision) => write!(f, "revision={revision}"),
631            HgFragment::Tag(revision) => write!(f, "tag={revision}"),
632        }
633    }
634}
635
636impl HgFragment {
637    /// Recognizes URL fragments and values specific to the Mercurial VCS.
638    ///
639    /// This parser considers all variants of [`HgFragment`] as fragments in an alpm-package-source
640    /// string (including the leading `#` character).
641    fn parser(input: &mut &str) -> ModalResult<HgFragment> {
642        // Check for the `#` fragment start first. If it isn't here, backtrack.
643        let _ = "#".parse_next(input)?;
644
645        // Error if we don't find one of the expected git revision types.
646        let version_keywords = ["branch", "revision", "tag"];
647        let version_type = cut_err(alt(version_keywords))
648            .context(StrContext::Label("hg revision type"))
649            .context_with(iter_str_context!([version_keywords]))
650            .parse_next(input)?;
651
652        let value = fragment_value.parse_next(input)?;
653
654        match version_type {
655            "branch" => Ok(HgFragment::Branch(value.to_string())),
656            "revision" => Ok(HgFragment::Revision(value.to_string())),
657            "tag" => Ok(HgFragment::Tag(value.to_string())),
658            _ => unreachable!(),
659        }
660    }
661}
662
663/// The available URL fragments and their values when using Apache Subversion in a [`SourceUrl`].
664#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
665#[serde(rename_all = "snake_case")]
666pub enum SvnFragment {
667    /// A specific revision in the repository.
668    Revision(String),
669}
670
671impl Display for SvnFragment {
672    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
673        match self {
674            SvnFragment::Revision(revision) => write!(f, "revision={revision}"),
675        }
676    }
677}
678
679impl SvnFragment {
680    /// Recognizes URL fragments and values specific to Apache Subversion.
681    ///
682    /// This parser considers all variants of [`SvnFragment`] as fragments in an alpm-package-source
683    /// string (including the leading `#` character).
684    fn parser(input: &mut &str) -> ModalResult<SvnFragment> {
685        // Check for the `#` fragment start first. If it isn't here, backtrack.
686        let _ = "#".parse_next(input)?;
687
688        // Expect the only allowed revision keyword.
689        cut_err("revision")
690            .context(StrContext::Label("svn revision type"))
691            .context(StrContext::Expected(StrContextValue::Description(
692                "revision keyword",
693            )))
694            .parse_next(input)?;
695
696        let value = fragment_value.parse_next(input)?;
697
698        Ok(SvnFragment::Revision(value))
699    }
700}
701
702#[cfg(test)]
703mod tests {
704    use rstest::rstest;
705    use testresult::TestResult;
706
707    use super::*;
708
709    #[rstest]
710    #[case("https://example.com/", Ok("https://example.com/"))]
711    #[case(
712        "https://example.com/path?query=1",
713        Ok("https://example.com/path?query=1")
714    )]
715    #[case("ftp://example.com/", Ok("ftp://example.com/"))]
716    #[case("not-a-url", Err(url::ParseError::RelativeUrlWithoutBase.into()))]
717    fn test_url_parsing(#[case] input: &str, #[case] expected: Result<&str, Error>) {
718        let result = input.parse::<Url>();
719        assert_eq!(
720            result.as_ref().map(|v| v.to_string()),
721            expected.as_ref().map(|v| v.to_string())
722        );
723
724        if let Ok(url) = result {
725            assert_eq!(url.as_str(), input);
726        }
727    }
728
729    #[rstest]
730    #[case(
731        "git+https://example/project#tag=v1.0.0?signed",
732        Some("git+https://example/project?signed#tag=v1.0.0"),
733        SourceUrl {
734            url: Url::from_str("https://example/project").unwrap(),
735            vcs_info: Some(VcsInfo::Git {
736                fragment: Some(GitFragment::Tag("v1.0.0".to_string())),
737                signed: true
738            })
739        }
740    )]
741    #[case(
742        "git+https://example/project?signed#tag=v1.0.0",
743        None,
744        SourceUrl {
745            url: Url::from_str("https://example/project").unwrap(),
746            vcs_info: Some(VcsInfo::Git {
747                fragment: Some(GitFragment::Tag("v1.0.0".to_string())),
748                signed: true
749            })
750        }
751    )]
752    #[case(
753        "git://example/project#commit=a51720b",
754        None,
755        SourceUrl {
756            url: Url::from_str("git://example/project").unwrap(),
757            vcs_info: Some(VcsInfo::Git {
758                fragment: Some(GitFragment::Commit("a51720b".to_string())),
759                signed: false
760            })
761        }
762    )]
763    #[case(
764        "svn+https://example/project#revision=a51720b",
765        None,
766        SourceUrl {
767            url: Url::from_str("https://example/project").unwrap(),
768            vcs_info: Some(VcsInfo::Svn {
769                fragment: Some(SvnFragment::Revision("a51720b".to_string())),
770            })
771        }
772    )]
773    #[case(
774        "bzr+https://example/project#revision=a51720b",
775        None,
776        SourceUrl {
777            url: Url::from_str("https://example/project").unwrap(),
778            vcs_info: Some(VcsInfo::Bzr {
779                fragment: Some(BzrFragment::Revision("a51720b".to_string())),
780            })
781        }
782    )]
783    #[case(
784        "hg+https://example/project#branch=feature",
785        None,
786        SourceUrl {
787            url: Url::from_str("https://example/project").unwrap(),
788            vcs_info: Some(VcsInfo::Hg {
789                fragment: Some(HgFragment::Branch("feature".to_string())),
790            })
791        }
792    )]
793    #[case(
794        "fossil+https://example/project#branch=feature",
795        None,
796        SourceUrl {
797            url: Url::from_str("https://example/project").unwrap(),
798            vcs_info: Some(VcsInfo::Fossil {
799                fragment: Some(FossilFragment::Branch("feature".to_string())),
800            })
801        }
802    )]
803    #[case(
804        "https://example/project#branch=feature?signed",
805        None,
806        SourceUrl {
807            url: Url::from_str("https://example/project#branch=feature?signed").unwrap(),
808            vcs_info: None,
809        }
810    )]
811    fn test_source_url_parsing_success(
812        #[case] input: &str,
813        #[case] expected_to_string: Option<&str>,
814        #[case] expected: SourceUrl,
815    ) -> TestResult {
816        let source_url = SourceUrl::from_str(input)?;
817        assert_eq!(
818            source_url, expected,
819            "Parsed source_url should resemble the expected output."
820        );
821
822        // Some representations are shortened or brought into the proper representation, hence we
823        // have a slightly different ToString output than input.
824        let expected_to_string = expected_to_string.unwrap_or(input);
825        assert_eq!(
826            source_url.to_string(),
827            expected_to_string,
828            "Parsed and displayed source_url should resemble original."
829        );
830
831        Ok(())
832    }
833
834    /// Run the parser for SourceUrl and ensure that the expected parse error messages show up.
835    #[rstest]
836    #[case(
837        "git+https://example/project#revision=v1.0.0?signed",
838        "invalid git revision type\nexpected `branch`, `commit`, `tag`"
839    )]
840    #[case(
841        "git+https://example/project#branch=feature#branch=feature",
842        "invalid unexpected trailing content in URL."
843    )]
844    #[case(
845        "git+https://example/project#branch=feature?signed?signed",
846        "invalid or duplicate query parameter for detected VCS."
847    )]
848    #[case(
849        "bzr+https://example/project#branch=feature",
850        "invalid bzr revision type\nexpected revision keyword"
851    )]
852    #[case(
853        "svn+https://example/project#branch=feature",
854        "invalid svn revision type\nexpected revision keyword"
855    )]
856    #[case(
857        "hg+https://example/project#commit=154021a",
858        "invalid hg revision type\nexpected `branch`, `revision`, `tag`"
859    )]
860    #[case(
861        "hg+https://example/project#branch=feature?signed",
862        "invalid or duplicate query parameter for detected VCS."
863    )]
864    fn test_source_url_parsing_failure(#[case] input: &str, #[case] error_snippet: &str) {
865        let result = SourceUrl::from_str(input);
866        assert!(result.is_err(), "Invalid source_url should fail to parse.");
867        let err = result.unwrap_err();
868        let pretty_error = err.to_string();
869        assert!(
870            pretty_error.contains(error_snippet),
871            "Error:\n=====\n{pretty_error}\n=====\nshould contain snippet:\n\n{error_snippet}"
872        );
873    }
874}