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