Skip to main content

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