1use std::{
4 fmt::{Display, Formatter},
5 str::FromStr,
6};
7
8use alpm_parsers::iter_str_context;
9use serde::{Deserialize, Serialize};
10use winnow::{
11 ModalResult,
12 Parser,
13 ascii::{alpha1, space0},
14 combinator::{alt, cut_err, eof, fail, opt, peek, repeat_till, terminated},
15 error::{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> {
185 Ok(Self::parser.parse(s)?)
186 }
187}
188
189impl Display for SourceUrl {
190 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
191 let Some(vcs_info) = &self.vcs_info else {
193 return write!(f, "{}", self.url.as_str());
194 };
195
196 let mut prefix = None;
197 let url = self.url.as_str();
198 let mut formatted_fragment = String::new();
199 let mut query = String::new();
200
201 match vcs_info {
203 VcsInfo::Bzr { fragment } => {
204 prefix = Some(VcsProtocol::Bzr);
205 if let Some(fragment) = fragment {
206 formatted_fragment = format!("#{fragment}");
207 }
208 }
209 VcsInfo::Fossil { fragment } => {
210 prefix = Some(VcsProtocol::Fossil);
211 if let Some(fragment) = fragment {
212 formatted_fragment = format!("#{fragment}");
213 }
214 }
215 VcsInfo::Git { fragment, signed } => {
216 if !url.starts_with("git://") {
218 prefix = Some(VcsProtocol::Git);
219 }
220 if *signed {
221 query = "?signed".to_string();
222 }
223 if let Some(fragment) = fragment {
224 formatted_fragment = format!("#{fragment}");
225 }
226 }
227 VcsInfo::Hg { fragment } => {
228 prefix = Some(VcsProtocol::Hg);
229 if let Some(fragment) = fragment {
230 formatted_fragment = format!("#{fragment}");
231 }
232 }
233 VcsInfo::Svn { fragment } => {
234 if !url.starts_with("svn://") {
236 prefix = Some(VcsProtocol::Svn);
237 }
238 if let Some(fragment) = fragment {
239 formatted_fragment = format!("#{fragment}");
240 }
241 }
242 }
243
244 let prefix = if let Some(prefix) = prefix {
245 format!("{prefix}+")
246 } else {
247 String::new()
248 };
249
250 write!(f, "{prefix}{url}{query}{formatted_fragment}",)
251 }
252}
253
254impl SourceUrl {
255 fn parser(input: &mut &str) -> ModalResult<SourceUrl> {
257 let vcs = opt(VcsProtocol::parser).parse_next(input)?;
259
260 let Some(vcs) = vcs else {
261 let url = cut_err(rest.try_map(Url::from_str))
266 .context(StrContext::Label("url"))
267 .parse_next(input)?;
268 return Ok(SourceUrl {
269 url,
270 vcs_info: None,
271 });
272 };
273
274 let url = cut_err(SourceUrl::inner_url_parser.try_map(|url| Url::from_str(&url)))
277 .context(StrContext::Label("url"))
278 .parse_next(input)?;
279
280 let vcs_info = VcsInfo::parser(vcs).parse_next(input)?;
281
282 let _: Option<String> =
285 opt(("?", rest)
286 .take()
287 .and_then(cut_err(fail.context(StrContext::Label(
288 "or duplicate query parameter for detected VCS.",
289 )))))
290 .parse_next(input)?;
291
292 cut_err((space0, eof))
293 .context(StrContext::Label("unexpected trailing content in URL."))
294 .context(StrContext::Expected(StrContextValue::Description(
295 "end of input.",
296 )))
297 .parse_next(input)?;
298
299 Ok(SourceUrl {
300 url,
301 vcs_info: Some(vcs_info),
302 })
303 }
304
305 fn inner_url_parser(input: &mut &str) -> ModalResult<String> {
315 let (url, _) = repeat_till(0.., any, peek(alt(("#", "?", eof)))).parse_next(input)?;
316 Ok(url)
317 }
318}
319
320#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
326#[serde(tag = "protocol", rename_all = "lowercase")]
327pub enum VcsInfo {
328 Bzr {
330 fragment: Option<BzrFragment>,
332 },
333 Fossil {
335 fragment: Option<FossilFragment>,
337 },
338 Git {
340 fragment: Option<GitFragment>,
342 signed: bool,
344 },
345 Hg {
347 fragment: Option<HgFragment>,
349 },
350 Svn {
352 fragment: Option<SvnFragment>,
354 },
355}
356
357impl VcsInfo {
358 fn parser(vcs: VcsProtocol) -> impl FnMut(&mut &str) -> ModalResult<VcsInfo> {
363 move |input: &mut &str| match vcs {
364 VcsProtocol::Bzr => {
365 let fragment = opt(BzrFragment::parser).parse_next(input)?;
366 Ok(VcsInfo::Bzr { fragment })
367 }
368 VcsProtocol::Fossil => {
369 let fragment = opt(FossilFragment::parser).parse_next(input)?;
370 Ok(VcsInfo::Fossil { fragment })
371 }
372 VcsProtocol::Git => {
373 let mut signed = git_query(input)?;
377 let fragment = opt(GitFragment::parser).parse_next(input)?;
378 if !signed {
379 signed = git_query(input)?;
382 }
383 Ok(VcsInfo::Git { fragment, signed })
384 }
385 VcsProtocol::Hg => {
386 let fragment = opt(HgFragment::parser).parse_next(input)?;
387 Ok(VcsInfo::Hg { fragment })
388 }
389 VcsProtocol::Svn => {
390 let fragment = opt(SvnFragment::parser).parse_next(input)?;
391 Ok(VcsInfo::Svn { fragment })
392 }
393 }
394 }
395}
396
397#[derive(strum::Display, strum::EnumString)]
404#[strum(serialize_all = "lowercase")]
405enum VcsProtocol {
406 Bzr,
407 Fossil,
408 Git,
409 Hg,
410 Svn,
411}
412
413impl VcsProtocol {
414 fn parser(input: &mut &str) -> ModalResult<VcsProtocol> {
425 let protocol =
427 opt(terminated(alpha1.try_map(VcsProtocol::from_str), "+")).parse_next(input)?;
428
429 if let Some(protocol) = protocol {
430 return Ok(protocol);
431 }
432
433 let protocol = peek(alt(("git://", "svn://"))).parse_next(input)?;
439
440 match protocol {
441 "git://" => Ok(VcsProtocol::Git),
442 "svn://" => Ok(VcsProtocol::Svn),
443 _ => unreachable!(),
444 }
445 }
446}
447
448fn fragment_value(input: &mut &str) -> ModalResult<String> {
456 let _ = cut_err("=")
458 .context(StrContext::Label("fragment separator"))
459 .context(StrContext::Expected(StrContextValue::Description(
460 "a literal '='",
461 )))
462 .parse_next(input)?;
463
464 let (value, _) = repeat_till(0.., any, peek(alt(("?", "#", eof)))).parse_next(input)?;
466
467 Ok(value)
468}
469
470#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
472#[serde(rename_all = "snake_case")]
473pub enum BzrFragment {
474 Revision(String),
476}
477
478impl Display for BzrFragment {
479 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
480 match self {
481 BzrFragment::Revision(revision) => write!(f, "revision={revision}"),
482 }
483 }
484}
485
486impl BzrFragment {
487 fn parser(input: &mut &str) -> ModalResult<BzrFragment> {
491 let _ = "#".parse_next(input)?;
493
494 cut_err("revision")
496 .context(StrContext::Label("bzr revision type"))
497 .context(StrContext::Expected(StrContextValue::Description(
498 "revision keyword",
499 )))
500 .parse_next(input)?;
501
502 let value = fragment_value.parse_next(input)?;
503
504 Ok(BzrFragment::Revision(value))
505 }
506}
507
508#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
510#[serde(rename_all = "snake_case")]
511pub enum FossilFragment {
512 Branch(String),
514 Commit(String),
516 Tag(String),
518}
519
520impl Display for FossilFragment {
521 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
522 match self {
523 FossilFragment::Branch(revision) => write!(f, "branch={revision}"),
524 FossilFragment::Commit(revision) => write!(f, "commit={revision}"),
525 FossilFragment::Tag(revision) => write!(f, "tag={revision}"),
526 }
527 }
528}
529
530impl FossilFragment {
531 fn parser(input: &mut &str) -> ModalResult<FossilFragment> {
536 let _ = "#".parse_next(input)?;
538
539 let version_keywords = ["branch", "commit", "tag"];
541 let version_type = cut_err(alt(version_keywords))
542 .context(StrContext::Label("fossil revision type"))
543 .context_with(iter_str_context!([version_keywords]))
544 .parse_next(input)?;
545
546 let value = fragment_value.parse_next(input)?;
547
548 match version_type {
549 "branch" => Ok(FossilFragment::Branch(value.to_string())),
550 "commit" => Ok(FossilFragment::Commit(value.to_string())),
551 "tag" => Ok(FossilFragment::Tag(value.to_string())),
552 _ => unreachable!(),
553 }
554 }
555}
556
557#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
559#[serde(rename_all = "snake_case")]
560pub enum GitFragment {
561 Branch(String),
563 Commit(String),
565 Tag(String),
567}
568
569impl Display for GitFragment {
570 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
571 match self {
572 GitFragment::Branch(revision) => write!(f, "branch={revision}"),
573 GitFragment::Commit(revision) => write!(f, "commit={revision}"),
574 GitFragment::Tag(revision) => write!(f, "tag={revision}"),
575 }
576 }
577}
578
579impl GitFragment {
580 fn parser(input: &mut &str) -> ModalResult<GitFragment> {
585 let _ = "#".parse_next(input)?;
587
588 let version_keywords = ["branch", "commit", "tag"];
590 let version_type = cut_err(alt(version_keywords))
591 .context(StrContext::Label("git revision type"))
592 .context_with(iter_str_context!([version_keywords]))
593 .parse_next(input)?;
594
595 let value = fragment_value.parse_next(input)?;
596
597 match version_type {
598 "branch" => Ok(GitFragment::Branch(value.to_string())),
599 "commit" => Ok(GitFragment::Commit(value.to_string())),
600 "tag" => Ok(GitFragment::Tag(value.to_string())),
601 _ => unreachable!(),
602 }
603 }
604}
605
606fn git_query(input: &mut &str) -> ModalResult<bool> {
610 let query = opt("?signed").parse_next(input)?;
611 Ok(query.is_some())
612}
613
614#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
616#[serde(rename_all = "snake_case")]
617pub enum HgFragment {
618 Branch(String),
620 Revision(String),
622 Tag(String),
624}
625
626impl Display for HgFragment {
627 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
628 match self {
629 HgFragment::Branch(revision) => write!(f, "branch={revision}"),
630 HgFragment::Revision(revision) => write!(f, "revision={revision}"),
631 HgFragment::Tag(revision) => write!(f, "tag={revision}"),
632 }
633 }
634}
635
636impl HgFragment {
637 fn parser(input: &mut &str) -> ModalResult<HgFragment> {
642 let _ = "#".parse_next(input)?;
644
645 let version_keywords = ["branch", "revision", "tag"];
647 let version_type = cut_err(alt(version_keywords))
648 .context(StrContext::Label("hg revision type"))
649 .context_with(iter_str_context!([version_keywords]))
650 .parse_next(input)?;
651
652 let value = fragment_value.parse_next(input)?;
653
654 match version_type {
655 "branch" => Ok(HgFragment::Branch(value.to_string())),
656 "revision" => Ok(HgFragment::Revision(value.to_string())),
657 "tag" => Ok(HgFragment::Tag(value.to_string())),
658 _ => unreachable!(),
659 }
660 }
661}
662
663#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
665#[serde(rename_all = "snake_case")]
666pub enum SvnFragment {
667 Revision(String),
669}
670
671impl Display for SvnFragment {
672 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
673 match self {
674 SvnFragment::Revision(revision) => write!(f, "revision={revision}"),
675 }
676 }
677}
678
679impl SvnFragment {
680 fn parser(input: &mut &str) -> ModalResult<SvnFragment> {
685 let _ = "#".parse_next(input)?;
687
688 cut_err("revision")
690 .context(StrContext::Label("svn revision type"))
691 .context(StrContext::Expected(StrContextValue::Description(
692 "revision keyword",
693 )))
694 .parse_next(input)?;
695
696 let value = fragment_value.parse_next(input)?;
697
698 Ok(SvnFragment::Revision(value))
699 }
700}
701
702#[cfg(test)]
703mod tests {
704 use rstest::rstest;
705 use testresult::TestResult;
706
707 use super::*;
708
709 #[rstest]
710 #[case("https://example.com/", Ok("https://example.com/"))]
711 #[case(
712 "https://example.com/path?query=1",
713 Ok("https://example.com/path?query=1")
714 )]
715 #[case("ftp://example.com/", Ok("ftp://example.com/"))]
716 #[case("not-a-url", Err(url::ParseError::RelativeUrlWithoutBase.into()))]
717 fn test_url_parsing(#[case] input: &str, #[case] expected: Result<&str, Error>) {
718 let result = input.parse::<Url>();
719 assert_eq!(
720 result.as_ref().map(|v| v.to_string()),
721 expected.as_ref().map(|v| v.to_string())
722 );
723
724 if let Ok(url) = result {
725 assert_eq!(url.as_str(), input);
726 }
727 }
728
729 #[rstest]
730 #[case(
731 "git+https://example/project#tag=v1.0.0?signed",
732 Some("git+https://example/project?signed#tag=v1.0.0"),
733 SourceUrl {
734 url: Url::from_str("https://example/project").unwrap(),
735 vcs_info: Some(VcsInfo::Git {
736 fragment: Some(GitFragment::Tag("v1.0.0".to_string())),
737 signed: true
738 })
739 }
740 )]
741 #[case(
742 "git+https://example/project?signed#tag=v1.0.0",
743 None,
744 SourceUrl {
745 url: Url::from_str("https://example/project").unwrap(),
746 vcs_info: Some(VcsInfo::Git {
747 fragment: Some(GitFragment::Tag("v1.0.0".to_string())),
748 signed: true
749 })
750 }
751 )]
752 #[case(
753 "git://example/project#commit=a51720b",
754 None,
755 SourceUrl {
756 url: Url::from_str("git://example/project").unwrap(),
757 vcs_info: Some(VcsInfo::Git {
758 fragment: Some(GitFragment::Commit("a51720b".to_string())),
759 signed: false
760 })
761 }
762 )]
763 #[case(
764 "svn+https://example/project#revision=a51720b",
765 None,
766 SourceUrl {
767 url: Url::from_str("https://example/project").unwrap(),
768 vcs_info: Some(VcsInfo::Svn {
769 fragment: Some(SvnFragment::Revision("a51720b".to_string())),
770 })
771 }
772 )]
773 #[case(
774 "bzr+https://example/project#revision=a51720b",
775 None,
776 SourceUrl {
777 url: Url::from_str("https://example/project").unwrap(),
778 vcs_info: Some(VcsInfo::Bzr {
779 fragment: Some(BzrFragment::Revision("a51720b".to_string())),
780 })
781 }
782 )]
783 #[case(
784 "hg+https://example/project#branch=feature",
785 None,
786 SourceUrl {
787 url: Url::from_str("https://example/project").unwrap(),
788 vcs_info: Some(VcsInfo::Hg {
789 fragment: Some(HgFragment::Branch("feature".to_string())),
790 })
791 }
792 )]
793 #[case(
794 "fossil+https://example/project#branch=feature",
795 None,
796 SourceUrl {
797 url: Url::from_str("https://example/project").unwrap(),
798 vcs_info: Some(VcsInfo::Fossil {
799 fragment: Some(FossilFragment::Branch("feature".to_string())),
800 })
801 }
802 )]
803 #[case(
804 "https://example/project#branch=feature?signed",
805 None,
806 SourceUrl {
807 url: Url::from_str("https://example/project#branch=feature?signed").unwrap(),
808 vcs_info: None,
809 }
810 )]
811 fn test_source_url_parsing_success(
812 #[case] input: &str,
813 #[case] expected_to_string: Option<&str>,
814 #[case] expected: SourceUrl,
815 ) -> TestResult {
816 let source_url = SourceUrl::from_str(input)?;
817 assert_eq!(
818 source_url, expected,
819 "Parsed source_url should resemble the expected output."
820 );
821
822 let expected_to_string = expected_to_string.unwrap_or(input);
825 assert_eq!(
826 source_url.to_string(),
827 expected_to_string,
828 "Parsed and displayed source_url should resemble original."
829 );
830
831 Ok(())
832 }
833
834 #[rstest]
836 #[case(
837 "git+https://example/project#revision=v1.0.0?signed",
838 "invalid git revision type\nexpected `branch`, `commit`, `tag`"
839 )]
840 #[case(
841 "git+https://example/project#branch=feature#branch=feature",
842 "invalid unexpected trailing content in URL."
843 )]
844 #[case(
845 "git+https://example/project#branch=feature?signed?signed",
846 "invalid or duplicate query parameter for detected VCS."
847 )]
848 #[case(
849 "bzr+https://example/project#branch=feature",
850 "invalid bzr revision type\nexpected revision keyword"
851 )]
852 #[case(
853 "svn+https://example/project#branch=feature",
854 "invalid svn revision type\nexpected revision keyword"
855 )]
856 #[case(
857 "hg+https://example/project#commit=154021a",
858 "invalid hg revision type\nexpected `branch`, `revision`, `tag`"
859 )]
860 #[case(
861 "hg+https://example/project#branch=feature?signed",
862 "invalid or duplicate query parameter for detected VCS."
863 )]
864 fn test_source_url_parsing_failure(#[case] input: &str, #[case] error_snippet: &str) {
865 let result = SourceUrl::from_str(input);
866 assert!(result.is_err(), "Invalid source_url should fail to parse.");
867 let err = result.unwrap_err();
868 let pretty_error = err.to_string();
869 assert!(
870 pretty_error.contains(error_snippet),
871 "Error:\n=====\n{pretty_error}\n=====\nshould contain snippet:\n\n{error_snippet}"
872 );
873 }
874}