1use 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#[derive(Clone, Debug, Eq, PartialEq)]
47#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
48pub struct Url(url::Url);
49
50impl Url {
51 pub fn new(url: url::Url) -> Result<Self, Error> {
53 Ok(Self(url))
54 }
55
56 pub fn as_str(&self) -> &str {
58 self.0.as_str()
59 }
60
61 pub fn into_inner(self) -> url::Url {
63 self.0
64 }
65
66 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 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#[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(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.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 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<&'a str, Self, ErrMode<ContextError>>
274 where
275 P: Parser<&'a str, &'a str, ErrMode<ContextError>>,
276 {
277 let mut delimiter = delimiter;
280 move |input: &mut &'a str| -> ModalResult<Self> {
281 let vcs = opt(VcsProtocol::parser).parse_next(input)?;
283
284 let Some(vcs) = vcs else {
285 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 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 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#[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 Bzr {
356 fragment: Option<BzrFragment>,
358 },
359 Fossil {
361 fragment: Option<FossilFragment>,
363 },
364 Git {
366 fragment: Option<GitFragment>,
368 signed: bool,
370 },
371 Hg {
373 fragment: Option<HgFragment>,
375 },
376 Svn {
378 fragment: Option<SvnFragment>,
380 },
381}
382
383impl VcsInfo {
384 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 let mut signed = git_query(input)?;
403 let fragment = GitFragment::parser.parse_next(input)?;
404 if !signed {
405 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#[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 fn parser(input: &mut &str) -> ModalResult<VcsProtocol> {
451 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 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
474fn fragment_value(input: &mut &str) -> ModalResult<String> {
482 let _ = "="
484 .context(StrContext::Label("fragment separator"))
485 .context(StrContext::Expected(StrContextValue::Description(
486 "a literal '='",
487 )))
488 .parse_next(input)?;
489
490 let (value, _) = repeat_till(0.., any, peek(alt(("?", "#", eof)))).parse_next(input)?;
492
493 Ok(value)
494}
495
496#[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 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 fn parser(input: &mut &str) -> ModalResult<Option<BzrFragment>> {
518 let exists = opt("#").parse_next(input)?;
520 if exists.is_none() {
521 return Ok(None);
522 }
523
524 "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#[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 Branch(String),
545 Commit(String),
547 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 fn parser(input: &mut &str) -> ModalResult<Option<FossilFragment>> {
567 let exists = opt("#").parse_next(input)?;
569 if exists.is_none() {
570 return Ok(None);
571 }
572
573 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#[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 Branch(String),
600 Commit(String),
602 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 fn parser(input: &mut &str) -> ModalResult<Option<GitFragment>> {
622 let exists = opt("#").parse_next(input)?;
624 if exists.is_none() {
625 return Ok(None);
626 }
627
628 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
648fn git_query(input: &mut &str) -> ModalResult<bool> {
652 let query = opt("?signed").parse_next(input)?;
653 Ok(query.is_some())
654}
655
656#[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 Branch(String),
663 Revision(String),
665 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 fn parser(input: &mut &str) -> ModalResult<Option<HgFragment>> {
685 let exists = opt("#").parse_next(input)?;
687 if exists.is_none() {
688 return Ok(None);
689 }
690
691 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#[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 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 fn parser(input: &mut &str) -> ModalResult<Option<SvnFragment>> {
734 let exists = opt("#").parse_next(input)?;
736 if exists.is_none() {
737 return Ok(None);
738 }
739
740 "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 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 #[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}