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::{iter_str_context, traits::ParserUntil};
9#[cfg(feature = "serde")]
10use serde::{Deserialize, Serialize};
11use winnow::{
12    ModalResult,
13    Parser,
14    ascii::alpha1,
15    combinator::{alt, eof, not, opt, peek, repeat_till, terminated},
16    error::{ContextError, ErrMode, StrContext, StrContextValue},
17    token::{any, rest},
18};
19
20use crate::Error;
21
22/// Represents a URL.
23///
24/// It is used to represent the upstream URL of a package.
25/// This type does not yet enforce a secure connection (e.g. HTTPS).
26///
27/// The `Url` type wraps the [`url::Url`] type.
28///
29/// ## Examples
30///
31/// ```
32/// use std::str::FromStr;
33///
34/// use alpm_types::Url;
35///
36/// # fn main() -> Result<(), alpm_types::Error> {
37/// // Create Url from &str
38/// let url = Url::from_str("https://example.com/download")?;
39/// assert_eq!(url.as_str(), "https://example.com/download");
40///
41/// // Format as String
42/// assert_eq!(format!("{url}"), "https://example.com/download");
43/// # Ok(())
44/// # }
45/// ```
46#[derive(Clone, Debug, Eq, PartialEq)]
47#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
48pub struct Url(url::Url);
49
50impl Url {
51    /// Creates a new `Url` instance.
52    pub fn new(url: url::Url) -> Result<Self, Error> {
53        Ok(Self(url))
54    }
55
56    /// Returns a reference to the inner `url::Url` as a `&str`.
57    pub fn as_str(&self) -> &str {
58        self.0.as_str()
59    }
60
61    /// Consumes the `Url` and returns the inner `url::Url`.
62    pub fn into_inner(self) -> url::Url {
63        self.0
64    }
65
66    /// Returns a reference to the inner `url::Url`.
67    pub fn inner(&self) -> &url::Url {
68        &self.0
69    }
70}
71
72impl AsRef<str> for Url {
73    fn as_ref(&self) -> &str {
74        self.as_str()
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.as_str())
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(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.as_str());
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<&'a str, Self, ErrMode<ContextError>>
274    where
275        P: Parser<&'a str, &'a str, ErrMode<ContextError>>,
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        move |input: &mut &'a str| -> ModalResult<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, simply interpret the rest of the string 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 = rest
291                    .try_map(Url::from_str)
292                    .context(StrContext::Label("url"))
293                    .parse_next(input)?;
294                return Ok(SourceUrl {
295                    url,
296                    vcs_info: None,
297                });
298            };
299
300            // We now know that we look at a URL that's supposed to be used by a VCS.
301            // Get the URL first, error if we cannot find it.
302            // Recognizes a URL in an alpm-package-source string.
303            //
304            // Considers all chars until a special char or the EOF is encountered:
305            // - `#` character that indicates a fragment
306            // - `?` character indicates a query
307            // - `EOF` we reached the end of the string.
308            //
309            // All of the above indicate that the end of the URL has been reached.
310            // The `#` or `?` are not consumed, so that an outer parser may continue parsing
311            // afterwards.
312            let url = repeat_till(0.., any, peek(alt(("#", "?", delimiter.by_ref()))))
313                .map(|((), _): ((), &str)| ())
314                .take()
315                .try_map(|url: &str| Url::from_str(url))
316                .context(StrContext::Label("url"))
317                .parse_next(input)?;
318
319            let vcs_info = VcsInfo::parser(vcs).parse_next(input)?;
320
321            // Produce a special error message for unconsumed query parameters.
322            // The unused result with error type are necessary to please the type checker.
323            not("?")
324                .context(StrContext::Label(
325                    "or duplicate query parameter for detected VCS.",
326                ))
327                .parse_next(input)?;
328
329            delimiter
330                .by_ref()
331                .context(StrContext::Label("unexpected trailing content in URL."))
332                .context(StrContext::Expected(StrContextValue::Description(
333                    "end of input.",
334                )))
335                .parse_next(input)?;
336
337            Ok(SourceUrl {
338                url,
339                vcs_info: Some(vcs_info),
340            })
341        }
342    }
343}
344
345/// Information on Version Control Systems (VCS) using a URL.
346///
347/// Several different VCS systems can be used in the context of a [`SourceUrl`].
348/// Each system supports addressing different types of objects and may optionally require signature
349/// verification for those objects.
350#[derive(Clone, Debug, Eq, PartialEq)]
351#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
352#[cfg_attr(feature = "serde", serde(tag = "protocol", rename_all = "lowercase"))]
353pub enum VcsInfo {
354    /// Bazaar/Breezy VCS information.
355    Bzr {
356        /// Optional URL fragment information.
357        fragment: Option<BzrFragment>,
358    },
359    /// Fossil VCS information.
360    Fossil {
361        /// Optional URL fragment information.
362        fragment: Option<FossilFragment>,
363    },
364    /// Git VCS information.
365    Git {
366        /// Optional URL fragment information.
367        fragment: Option<GitFragment>,
368        /// Whether OpenPGP signature verification is required.
369        signed: bool,
370    },
371    /// Mercurial VCS information.
372    Hg {
373        /// Optional URL fragment information.
374        fragment: Option<HgFragment>,
375    },
376    /// Apache Subversion VCS information.
377    Svn {
378        /// Optional URL fragment information.
379        fragment: Option<SvnFragment>,
380    },
381}
382
383impl VcsInfo {
384    /// Recognizes VCS-specific URL fragment and query based on a [`VcsProtocol`].
385    ///
386    /// As the parser is parameterized due to the earlier detected [`VcsProtocol`], it returns a
387    /// new stateful parser closure.
388    fn parser(vcs: VcsProtocol) -> impl FnMut(&mut &str) -> ModalResult<VcsInfo> {
389        move |input: &mut &str| match vcs {
390            VcsProtocol::Bzr => {
391                let fragment = BzrFragment::parser.parse_next(input)?;
392                Ok(VcsInfo::Bzr { fragment })
393            }
394            VcsProtocol::Fossil => {
395                let fragment = FossilFragment::parser.parse_next(input)?;
396                Ok(VcsInfo::Fossil { fragment })
397            }
398            VcsProtocol::Git => {
399                // Pacman actually allows a parameter **after** the fragment, which is
400                // theoretically an invalid URL.
401                // Hence, we have to check for the parameter before and after the url.
402                let mut signed = git_query(input)?;
403                let fragment = GitFragment::parser.parse_next(input)?;
404                if !signed {
405                    // Check for the theoretically invalid query after the fragment if it wasn't
406                    // already at the front.
407                    signed = git_query(input)?;
408                }
409                Ok(VcsInfo::Git { fragment, signed })
410            }
411            VcsProtocol::Hg => {
412                let fragment = HgFragment::parser.parse_next(input)?;
413                Ok(VcsInfo::Hg { fragment })
414            }
415            VcsProtocol::Svn => {
416                let fragment = SvnFragment::parser.parse_next(input)?;
417                Ok(VcsInfo::Svn { fragment })
418            }
419        }
420    }
421}
422
423/// A VCS protocol
424///
425/// This identifier is only used during parsing to have some static representation of the detected
426/// VCS.
427/// This is necessary as the fragment and the query are parsed at a later step and we have to
428/// keep track of the VCS somehow.
429#[derive(strum::Display, strum::EnumString)]
430#[strum(serialize_all = "lowercase")]
431enum VcsProtocol {
432    Bzr,
433    Fossil,
434    Git,
435    Hg,
436    Svn,
437}
438
439impl VcsProtocol {
440    /// Parses the start of an alpm-package-source string to determine the VCS protocol in use.
441    ///
442    /// VCS protocol information is used in [`SourceUrl`]s and can be detected in the following
443    /// ways:
444    ///
445    /// - An explicit VCS protocol identifier, followed by a literal `+`. E.g. `git+https://...`, `svn+https://...`
446    /// - Some VCS (i.e. git and svn) support URLs in which their protocol type is exposed in the
447    ///   `scheme` component of the URL itself:
448    ///    - `git://...`
449    ///    - `svn://...`
450    fn parser(input: &mut &str) -> ModalResult<VcsProtocol> {
451        // Check for an explicit vcs definition like `git+` first.
452        let protocol =
453            opt(terminated(alpha1.try_map(VcsProtocol::from_str), "+")).parse_next(input)?;
454
455        if let Some(protocol) = protocol {
456            return Ok(protocol);
457        }
458
459        // We didn't find any explicit identifiers.
460        // Now see if we find any vcs protocol at the start of the URL.
461        // Make sure to **not** consume anything from inside URL!
462        //
463        // If this doesn't find anything, it backtracks to the parent function.
464        let protocol = peek(alt(("git://", "svn://"))).parse_next(input)?;
465
466        match protocol {
467            "git://" => Ok(VcsProtocol::Git),
468            "svn://" => Ok(VcsProtocol::Svn),
469            _ => unreachable!(),
470        }
471    }
472}
473
474/// Parses the value of a URL fragment from an alpm-package-source string.
475///
476/// Parsing is attempted after the URL fragment type has been determined.
477///
478/// E.g. `tag=v1.0.0`
479///           ^^^^^^
480///          This part
481fn fragment_value(input: &mut &str) -> ModalResult<String> {
482    // Error if we don't find the separator
483    let _ = "="
484        .context(StrContext::Label("fragment separator"))
485        .context(StrContext::Expected(StrContextValue::Description(
486            "a literal '='",
487        )))
488        .parse_next(input)?;
489
490    // Get the value of the fragment.
491    let (value, _) = repeat_till(0.., any, peek(alt(("?", "#", eof)))).parse_next(input)?;
492
493    Ok(value)
494}
495
496/// The available URL fragments and their values when using the Breezy VCS in a [`SourceUrl`].
497#[derive(Clone, Debug, Eq, PartialEq)]
498#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
499#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
500pub enum BzrFragment {
501    /// A specific revision in the repository.
502    Revision(String),
503}
504
505impl Display for BzrFragment {
506    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
507        match self {
508            BzrFragment::Revision(revision) => write!(f, "revision={revision}"),
509        }
510    }
511}
512
513impl BzrFragment {
514    /// Recognizes URL fragments and values specific to Breezy VCS.
515    ///
516    /// This parser considers all variants of [`BzrFragment`] (including a leading `#` character).
517    fn parser(input: &mut &str) -> ModalResult<Option<BzrFragment>> {
518        // Check for the `#` fragment start first. If it isn't here, there's no fragment.
519        let exists = opt("#").parse_next(input)?;
520        if exists.is_none() {
521            return Ok(None);
522        }
523
524        // Expect the only allowed revision keyword.
525        "revision"
526            .context(StrContext::Label("bzr revision type"))
527            .context(StrContext::Expected(StrContextValue::Description(
528                "revision keyword",
529            )))
530            .parse_next(input)?;
531
532        let value = fragment_value.parse_next(input)?;
533
534        Ok(Some(BzrFragment::Revision(value)))
535    }
536}
537
538/// The available URL fragments and their values when using the Fossil VCS in a [`SourceUrl`].
539#[derive(Clone, Debug, Eq, PartialEq)]
540#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
541#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
542pub enum FossilFragment {
543    /// A specific branch in the repository.
544    Branch(String),
545    /// A specific commit in the repository.
546    Commit(String),
547    /// A specific tag in the repository.
548    Tag(String),
549}
550
551impl Display for FossilFragment {
552    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
553        match self {
554            FossilFragment::Branch(revision) => write!(f, "branch={revision}"),
555            FossilFragment::Commit(revision) => write!(f, "commit={revision}"),
556            FossilFragment::Tag(revision) => write!(f, "tag={revision}"),
557        }
558    }
559}
560
561impl FossilFragment {
562    /// Recognizes URL fragments and values specific to Fossil VCS.
563    ///
564    /// This parser considers all variants of [`FossilFragment`] as fragments in an
565    /// alpm-package-source string (including the leading `#` character).
566    fn parser(input: &mut &str) -> ModalResult<Option<FossilFragment>> {
567        // Check for the `#` fragment start first. If it isn't here, there's no fragment.
568        let exists = opt("#").parse_next(input)?;
569        if exists.is_none() {
570            return Ok(None);
571        }
572
573        // Error if we don't find one of the expected fossil revision types.
574        let version_keywords = ["branch", "commit", "tag"];
575        let version_type = alt(version_keywords)
576            .context(StrContext::Label("fossil revision type"))
577            .context_with(iter_str_context!([version_keywords]))
578            .parse_next(input)?;
579
580        let value = fragment_value.parse_next(input)?;
581
582        let fragment = match version_type {
583            "branch" => FossilFragment::Branch(value.to_string()),
584            "commit" => FossilFragment::Commit(value.to_string()),
585            "tag" => FossilFragment::Tag(value.to_string()),
586            _ => unreachable!(),
587        };
588
589        Ok(Some(fragment))
590    }
591}
592
593/// The available URL fragments and their values when using the Git VCS in a [`SourceUrl`].
594#[derive(Clone, Debug, Eq, PartialEq)]
595#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
596#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
597pub enum GitFragment {
598    /// A specific branch in the repository.
599    Branch(String),
600    /// A specific commit in the repository.
601    Commit(String),
602    /// A specific tag in the repository.
603    Tag(String),
604}
605
606impl Display for GitFragment {
607    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
608        match self {
609            GitFragment::Branch(revision) => write!(f, "branch={revision}"),
610            GitFragment::Commit(revision) => write!(f, "commit={revision}"),
611            GitFragment::Tag(revision) => write!(f, "tag={revision}"),
612        }
613    }
614}
615
616impl GitFragment {
617    /// Recognizes URL fragments and values specific to the Git VCS.
618    ///
619    /// This parser considers all variants of [`GitFragment`] as fragments in an alpm-package-source
620    /// string (including the leading `#` character).
621    fn parser(input: &mut &str) -> ModalResult<Option<GitFragment>> {
622        // Check for the `#` fragment start first. If it isn't here, there's no fragment.
623        let exists = opt("#").parse_next(input)?;
624        if exists.is_none() {
625            return Ok(None);
626        }
627
628        // Error if we don't find one of the expected git revision types.
629        let version_keywords = ["branch", "commit", "tag"];
630        let version_type = alt(version_keywords)
631            .context(StrContext::Label("git revision type"))
632            .context_with(iter_str_context!([version_keywords]))
633            .parse_next(input)?;
634
635        let value = fragment_value.parse_next(input)?;
636
637        let fragment = match version_type {
638            "branch" => GitFragment::Branch(value.to_string()),
639            "commit" => GitFragment::Commit(value.to_string()),
640            "tag" => GitFragment::Tag(value.to_string()),
641            _ => unreachable!(),
642        };
643
644        Ok(Some(fragment))
645    }
646}
647
648/// Recognizes URL queries specific to the Git VCS.
649///
650/// This parser considers the `?signed` URL query in an alpm-package-source string.
651fn git_query(input: &mut &str) -> ModalResult<bool> {
652    let query = opt("?signed").parse_next(input)?;
653    Ok(query.is_some())
654}
655
656/// An optional version specification used in a [`SourceUrl`] for the Hg VCS.
657#[derive(Clone, Debug, Eq, PartialEq)]
658#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
659#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
660pub enum HgFragment {
661    /// A specific branch in the repository.
662    Branch(String),
663    /// A specific revision in the repository.
664    Revision(String),
665    /// A specific tag in the repository.
666    Tag(String),
667}
668
669impl Display for HgFragment {
670    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
671        match self {
672            HgFragment::Branch(revision) => write!(f, "branch={revision}"),
673            HgFragment::Revision(revision) => write!(f, "revision={revision}"),
674            HgFragment::Tag(revision) => write!(f, "tag={revision}"),
675        }
676    }
677}
678
679impl HgFragment {
680    /// Recognizes URL fragments and values specific to the Mercurial VCS.
681    ///
682    /// This parser considers all variants of [`HgFragment`] as fragments in an alpm-package-source
683    /// string (including the leading `#` character).
684    fn parser(input: &mut &str) -> ModalResult<Option<HgFragment>> {
685        // Check for the `#` fragment start first. If it isn't here, there's no fragment.
686        let exists = opt("#").parse_next(input)?;
687        if exists.is_none() {
688            return Ok(None);
689        }
690
691        // Error if we don't find one of the expected git revision types.
692        let version_keywords = ["branch", "revision", "tag"];
693        let version_type = alt(version_keywords)
694            .context(StrContext::Label("hg revision type"))
695            .context_with(iter_str_context!([version_keywords]))
696            .parse_next(input)?;
697
698        let value = fragment_value.parse_next(input)?;
699
700        let fragment = match version_type {
701            "branch" => HgFragment::Branch(value.to_string()),
702            "revision" => HgFragment::Revision(value.to_string()),
703            "tag" => HgFragment::Tag(value.to_string()),
704            _ => unreachable!(),
705        };
706
707        Ok(Some(fragment))
708    }
709}
710
711/// The available URL fragments and their values when using Apache Subversion in a [`SourceUrl`].
712#[derive(Clone, Debug, Eq, PartialEq)]
713#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
714#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
715pub enum SvnFragment {
716    /// A specific revision in the repository.
717    Revision(String),
718}
719
720impl Display for SvnFragment {
721    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
722        match self {
723            SvnFragment::Revision(revision) => write!(f, "revision={revision}"),
724        }
725    }
726}
727
728impl SvnFragment {
729    /// Recognizes URL fragments and values specific to Apache Subversion.
730    ///
731    /// This parser considers all variants of [`SvnFragment`] as fragments in an alpm-package-source
732    /// string (including the leading `#` character).
733    fn parser(input: &mut &str) -> ModalResult<Option<SvnFragment>> {
734        // Check for the `#` fragment start first. If it isn't here, there's no fragment.
735        let exists = opt("#").parse_next(input)?;
736        if exists.is_none() {
737            return Ok(None);
738        }
739
740        // Expect the only allowed revision keyword.
741        "revision"
742            .context(StrContext::Label("svn revision type"))
743            .context(StrContext::Expected(StrContextValue::Description(
744                "revision keyword",
745            )))
746            .parse_next(input)?;
747
748        let value = fragment_value.parse_next(input)?;
749
750        Ok(Some(SvnFragment::Revision(value)))
751    }
752}
753
754#[cfg(test)]
755mod tests {
756    use insta::assert_snapshot;
757    use rstest::rstest;
758    use testresult::TestResult;
759
760    use super::*;
761    use crate::configure_insta;
762
763    #[rstest]
764    #[case("https://example.com/", Ok("https://example.com/"))]
765    #[case(
766        "https://example.com/path?query=1",
767        Ok("https://example.com/path?query=1")
768    )]
769    #[case("ftp://example.com/", Ok("ftp://example.com/"))]
770    #[case("not-a-url", Err(url::ParseError::RelativeUrlWithoutBase.into()))]
771    fn test_url_parsing(#[case] input: &str, #[case] expected: Result<&str, Error>) {
772        let result = input.parse::<Url>();
773        assert_eq!(
774            result.as_ref().map(|v| v.to_string()),
775            expected.as_ref().map(|v| v.to_string())
776        );
777
778        if let Ok(url) = result {
779            assert_eq!(url.as_str(), input);
780        }
781    }
782
783    #[rstest]
784    #[case(
785        "git+https://example/project#tag=v1.0.0?signed",
786        Some("git+https://example/project?signed#tag=v1.0.0"),
787        SourceUrl {
788            url: Url::from_str("https://example/project").unwrap(),
789            vcs_info: Some(VcsInfo::Git {
790                fragment: Some(GitFragment::Tag("v1.0.0".to_string())),
791                signed: true
792            })
793        }
794    )]
795    #[case(
796        "git+https://example/project?signed#tag=v1.0.0",
797        None,
798        SourceUrl {
799            url: Url::from_str("https://example/project").unwrap(),
800            vcs_info: Some(VcsInfo::Git {
801                fragment: Some(GitFragment::Tag("v1.0.0".to_string())),
802                signed: true
803            })
804        }
805    )]
806    #[case(
807        "git://example/project#commit=a51720b",
808        None,
809        SourceUrl {
810            url: Url::from_str("git://example/project").unwrap(),
811            vcs_info: Some(VcsInfo::Git {
812                fragment: Some(GitFragment::Commit("a51720b".to_string())),
813                signed: false
814            })
815        }
816    )]
817    #[case(
818        "svn+https://example/project#revision=a51720b",
819        None,
820        SourceUrl {
821            url: Url::from_str("https://example/project").unwrap(),
822            vcs_info: Some(VcsInfo::Svn {
823                fragment: Some(SvnFragment::Revision("a51720b".to_string())),
824            })
825        }
826    )]
827    #[case(
828        "bzr+https://example/project#revision=a51720b",
829        None,
830        SourceUrl {
831            url: Url::from_str("https://example/project").unwrap(),
832            vcs_info: Some(VcsInfo::Bzr {
833                fragment: Some(BzrFragment::Revision("a51720b".to_string())),
834            })
835        }
836    )]
837    #[case(
838        "hg+https://example/project#branch=feature",
839        None,
840        SourceUrl {
841            url: Url::from_str("https://example/project").unwrap(),
842            vcs_info: Some(VcsInfo::Hg {
843                fragment: Some(HgFragment::Branch("feature".to_string())),
844            })
845        }
846    )]
847    #[case(
848        "fossil+https://example/project#branch=feature",
849        None,
850        SourceUrl {
851            url: Url::from_str("https://example/project").unwrap(),
852            vcs_info: Some(VcsInfo::Fossil {
853                fragment: Some(FossilFragment::Branch("feature".to_string())),
854            })
855        }
856    )]
857    #[case(
858        "https://example/project#branch=feature?signed",
859        None,
860        SourceUrl {
861            url: Url::from_str("https://example/project#branch=feature?signed").unwrap(),
862            vcs_info: None,
863        }
864    )]
865    fn test_source_url_parsing_success(
866        #[case] input: &str,
867        #[case] expected_to_string: Option<&str>,
868        #[case] expected: SourceUrl,
869    ) -> TestResult {
870        let source_url = SourceUrl::from_str(input)?;
871        assert_eq!(
872            source_url, expected,
873            "Parsed source_url should resemble the expected output."
874        );
875
876        // Some representations are shortened or brought into the proper representation, hence we
877        // have a slightly different ToString output than input.
878        let expected_to_string = expected_to_string.unwrap_or(input);
879        assert_eq!(
880            source_url.to_string(),
881            expected_to_string,
882            "Parsed and displayed source_url should resemble original."
883        );
884
885        Ok(())
886    }
887
888    /// Run the parser for SourceUrl and ensure that the expected parse error messages show up.
889    #[rstest]
890    #[case("git+https://example/project#revision=v1.0.0?signed")]
891    #[case("git+https://example/project#branch=feature#branch=feature")]
892    #[case("git+https://example/project#branch=feature?signed?signed")]
893    #[case("bzr+https://example/project#branch=feature")]
894    #[case("svn+https://example/project#branch=feature")]
895    #[case("hg+https://example/project#commit=154021a")]
896    #[case("hg+https://example/project#branch=feature?signed")]
897    fn test_source_url_parsing_failure(#[case] input: &str) {
898        let Err(Error::ParseError(err_msg)) = SourceUrl::from_str(input) else {
899            panic!("'{input}' erroneously parsed as a SourceUrl")
900        };
901
902        let (test_name, _guard) = configure_insta();
903        assert_snapshot!(test_name, err_msg.to_string());
904    }
905}