1use 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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, 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 self.0.as_str()
57 }
58
59 pub fn into_inner(self) -> url::Url {
61 self.0
62 }
63
64 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 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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
155pub struct SourceUrl {
156 pub url: Url,
158 pub vcs_info: Option<VcsInfo>,
160}
161
162impl FromStr for SourceUrl {
163 type Err = Error;
164
165 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 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 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 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 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
260impl ParserUntil for SourceUrl {
264 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 let mut delimiter = delimiter;
277 move |input: &mut &'a str| -> ModalResult<Self> {
278 let vcs = opt(VcsProtocol::parser).parse_next(input)?;
280
281 let Some(vcs) = vcs else {
282 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 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 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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
348#[serde(tag = "protocol", rename_all = "lowercase")]
349pub enum VcsInfo {
350 Bzr {
352 fragment: Option<BzrFragment>,
354 },
355 Fossil {
357 fragment: Option<FossilFragment>,
359 },
360 Git {
362 fragment: Option<GitFragment>,
364 signed: bool,
366 },
367 Hg {
369 fragment: Option<HgFragment>,
371 },
372 Svn {
374 fragment: Option<SvnFragment>,
376 },
377}
378
379impl VcsInfo {
380 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 let mut signed = git_query(input)?;
399 let fragment = GitFragment::parser.parse_next(input)?;
400 if !signed {
401 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#[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 fn parser(input: &mut &str) -> ModalResult<VcsProtocol> {
447 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 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
470fn fragment_value(input: &mut &str) -> ModalResult<String> {
478 let _ = "="
480 .context(StrContext::Label("fragment separator"))
481 .context(StrContext::Expected(StrContextValue::Description(
482 "a literal '='",
483 )))
484 .parse_next(input)?;
485
486 let (value, _) = repeat_till(0.., any, peek(alt(("?", "#", eof)))).parse_next(input)?;
488
489 Ok(value)
490}
491
492#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
494#[serde(rename_all = "snake_case")]
495pub enum BzrFragment {
496 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 fn parser(input: &mut &str) -> ModalResult<Option<BzrFragment>> {
513 let exists = opt("#").parse_next(input)?;
515 if exists.is_none() {
516 return Ok(None);
517 }
518
519 "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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
535#[serde(rename_all = "snake_case")]
536pub enum FossilFragment {
537 Branch(String),
539 Commit(String),
541 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 fn parser(input: &mut &str) -> ModalResult<Option<FossilFragment>> {
561 let exists = opt("#").parse_next(input)?;
563 if exists.is_none() {
564 return Ok(None);
565 }
566
567 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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
589#[serde(rename_all = "snake_case")]
590pub enum GitFragment {
591 Branch(String),
593 Commit(String),
595 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 fn parser(input: &mut &str) -> ModalResult<Option<GitFragment>> {
615 let exists = opt("#").parse_next(input)?;
617 if exists.is_none() {
618 return Ok(None);
619 }
620
621 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
641fn git_query(input: &mut &str) -> ModalResult<bool> {
645 let query = opt("?signed").parse_next(input)?;
646 Ok(query.is_some())
647}
648
649#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
651#[serde(rename_all = "snake_case")]
652pub enum HgFragment {
653 Branch(String),
655 Revision(String),
657 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 fn parser(input: &mut &str) -> ModalResult<Option<HgFragment>> {
677 let exists = opt("#").parse_next(input)?;
679 if exists.is_none() {
680 return Ok(None);
681 }
682
683 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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
705#[serde(rename_all = "snake_case")]
706pub enum SvnFragment {
707 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 fn parser(input: &mut &str) -> ModalResult<Option<SvnFragment>> {
725 let exists = opt("#").parse_next(input)?;
727 if exists.is_none() {
728 return Ok(None);
729 }
730
731 "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 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 #[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}