1use 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#[derive(Clone, Debug, Eq, PartialEq)]
45#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
46pub struct Url(url::Url);
47
48impl Url {
49 pub fn new(url: url::Url) -> Result<Self, Error> {
51 Ok(Self(url))
52 }
53
54 pub fn as_str(&self) -> &str {
56 AsRef::<str>::as_ref(self)
57 }
58
59 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 fn as_ref(&self) -> &url::Url {
74 &self.0
75 }
76}
77
78impl FromStr for Url {
79 type Err = Error;
80
81 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#[derive(Clone, Debug, Eq, PartialEq)]
157#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
158pub struct SourceUrl {
159 pub url: Url,
161 pub vcs_info: Option<VcsInfo>,
163}
164
165impl FromStr for SourceUrl {
166 type Err = Error;
167
168 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 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 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 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 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
263impl ParserUntil for SourceUrl {
267 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 let mut delimiter = delimiter;
280 let parser = move |input: &mut Input<'a>| -> PResult<Self> {
281 let vcs = opt(VcsProtocol::parser).parse_next(input)?;
283
284 let Some(vcs) = vcs else {
285 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 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 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#[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 Bzr {
355 fragment: Option<BzrFragment>,
357 },
358 Fossil {
360 fragment: Option<FossilFragment>,
362 },
363 Git {
365 fragment: Option<GitFragment>,
367 signed: bool,
369 },
370 Hg {
372 fragment: Option<HgFragment>,
374 },
375 Svn {
377 fragment: Option<SvnFragment>,
379 },
380}
381
382impl VcsInfo {
383 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 let mut signed = git_query(input)?;
409 let fragment = GitFragment::parser(delimiter.by_ref()).parse_next(input)?;
410 if !signed {
411 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#[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 fn parser<'a>(input: &mut Input<'a>) -> PResult<'a, VcsProtocol> {
457 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 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
480fn 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 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#[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 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 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 let exists = opt("#").parse_next(input)?;
531 if exists.is_none() {
532 return Ok(None);
533 }
534
535 "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#[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 Branch(String),
557 Commit(String),
559 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 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 let exists = opt("#").parse_next(input)?;
588 if exists.is_none() {
589 return Ok(None);
590 }
591
592 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#[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 Branch(String),
622 Commit(String),
624 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 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 let exists = opt("#").parse_next(input)?;
653 if exists.is_none() {
654 return Ok(None);
655 }
656
657 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
680fn 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#[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 Branch(String),
695 Revision(String),
697 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 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 let exists = opt("#").parse_next(input)?;
726 if exists.is_none() {
727 return Ok(None);
728 }
729
730 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#[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 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 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 let exists = opt("#").parse_next(input)?;
783 if exists.is_none() {
784 return Ok(None);
785 }
786
787 "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 #[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 #[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 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 #[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}