use std::{
fmt::{Display, Formatter},
str::FromStr,
};
use alpm_parsers::iter_str_context;
use serde::{Deserialize, Serialize};
use winnow::{
ModalResult,
Parser,
ascii::{alpha1, space0},
combinator::{alt, cut_err, eof, fail, opt, peek, repeat_till, terminated},
error::{StrContext, StrContextValue},
token::{any, rest},
};
use crate::Error;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Url(url::Url);
impl Url {
pub fn new(url: url::Url) -> Result<Self, Error> {
Ok(Self(url))
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
pub fn into_inner(self) -> url::Url {
self.0
}
pub fn inner(&self) -> &url::Url {
&self.0
}
}
impl AsRef<str> for Url {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl FromStr for Url {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let url = url::Url::parse(s).map_err(Error::InvalidUrl)?;
Self::new(url)
}
}
impl Display for Url {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct SourceUrl {
pub url: Url,
pub vcs_info: Option<VcsInfo>,
}
impl FromStr for SourceUrl {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::parser.parse(s)?)
}
}
impl Display for SourceUrl {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let Some(vcs_info) = &self.vcs_info else {
return write!(f, "{}", self.url.as_str());
};
let mut prefix = None;
let url = self.url.as_str();
let mut formatted_fragment = String::new();
let mut query = String::new();
match vcs_info {
VcsInfo::Bzr { fragment } => {
prefix = Some(VcsProtocol::Bzr);
if let Some(fragment) = fragment {
formatted_fragment = format!("#{fragment}");
}
}
VcsInfo::Fossil { fragment } => {
prefix = Some(VcsProtocol::Fossil);
if let Some(fragment) = fragment {
formatted_fragment = format!("#{fragment}");
}
}
VcsInfo::Git { fragment, signed } => {
if !url.starts_with("git://") {
prefix = Some(VcsProtocol::Git);
}
if *signed {
query = "?signed".to_string();
}
if let Some(fragment) = fragment {
formatted_fragment = format!("#{fragment}");
}
}
VcsInfo::Hg { fragment } => {
prefix = Some(VcsProtocol::Hg);
if let Some(fragment) = fragment {
formatted_fragment = format!("#{fragment}");
}
}
VcsInfo::Svn { fragment } => {
if !url.starts_with("svn://") {
prefix = Some(VcsProtocol::Svn);
}
if let Some(fragment) = fragment {
formatted_fragment = format!("#{fragment}");
}
}
}
let prefix = if let Some(prefix) = prefix {
format!("{prefix}+")
} else {
String::new()
};
write!(f, "{prefix}{url}{query}{formatted_fragment}",)
}
}
impl SourceUrl {
fn parser(input: &mut &str) -> ModalResult<SourceUrl> {
let vcs = opt(VcsProtocol::parser).parse_next(input)?;
let Some(vcs) = vcs else {
let url = cut_err(rest.try_map(Url::from_str))
.context(StrContext::Label("url"))
.parse_next(input)?;
return Ok(SourceUrl {
url,
vcs_info: None,
});
};
let url = cut_err(SourceUrl::inner_url_parser.try_map(|url| Url::from_str(&url)))
.context(StrContext::Label("url"))
.parse_next(input)?;
let vcs_info = VcsInfo::parser(vcs).parse_next(input)?;
let _: Option<String> =
opt(("?", rest)
.take()
.and_then(cut_err(fail.context(StrContext::Label(
"or duplicate query parameter for detected VCS.",
)))))
.parse_next(input)?;
cut_err((space0, eof))
.context(StrContext::Label("unexpected trailing content in URL."))
.context(StrContext::Expected(StrContextValue::Description(
"end of input.",
)))
.parse_next(input)?;
Ok(SourceUrl {
url,
vcs_info: Some(vcs_info),
})
}
fn inner_url_parser(input: &mut &str) -> ModalResult<String> {
let (url, _) = repeat_till(0.., any, peek(alt(("#", "?", eof)))).parse_next(input)?;
Ok(url)
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(tag = "protocol", rename_all = "lowercase")]
pub enum VcsInfo {
Bzr {
fragment: Option<BzrFragment>,
},
Fossil {
fragment: Option<FossilFragment>,
},
Git {
fragment: Option<GitFragment>,
signed: bool,
},
Hg {
fragment: Option<HgFragment>,
},
Svn {
fragment: Option<SvnFragment>,
},
}
impl VcsInfo {
fn parser(vcs: VcsProtocol) -> impl FnMut(&mut &str) -> ModalResult<VcsInfo> {
move |input: &mut &str| match vcs {
VcsProtocol::Bzr => {
let fragment = opt(BzrFragment::parser).parse_next(input)?;
Ok(VcsInfo::Bzr { fragment })
}
VcsProtocol::Fossil => {
let fragment = opt(FossilFragment::parser).parse_next(input)?;
Ok(VcsInfo::Fossil { fragment })
}
VcsProtocol::Git => {
let mut signed = git_query(input)?;
let fragment = opt(GitFragment::parser).parse_next(input)?;
if !signed {
signed = git_query(input)?;
}
Ok(VcsInfo::Git { fragment, signed })
}
VcsProtocol::Hg => {
let fragment = opt(HgFragment::parser).parse_next(input)?;
Ok(VcsInfo::Hg { fragment })
}
VcsProtocol::Svn => {
let fragment = opt(SvnFragment::parser).parse_next(input)?;
Ok(VcsInfo::Svn { fragment })
}
}
}
}
#[derive(strum::Display, strum::EnumString)]
#[strum(serialize_all = "lowercase")]
enum VcsProtocol {
Bzr,
Fossil,
Git,
Hg,
Svn,
}
impl VcsProtocol {
fn parser(input: &mut &str) -> ModalResult<VcsProtocol> {
let protocol =
opt(terminated(alpha1.try_map(VcsProtocol::from_str), "+")).parse_next(input)?;
if let Some(protocol) = protocol {
return Ok(protocol);
}
let protocol = peek(alt(("git://", "svn://"))).parse_next(input)?;
match protocol {
"git://" => Ok(VcsProtocol::Git),
"svn://" => Ok(VcsProtocol::Svn),
_ => unreachable!(),
}
}
}
fn fragment_value(input: &mut &str) -> ModalResult<String> {
let _ = cut_err("=")
.context(StrContext::Label("fragment separator"))
.context(StrContext::Expected(StrContextValue::Description(
"a literal '='",
)))
.parse_next(input)?;
let (value, _) = repeat_till(0.., any, peek(alt(("?", "#", eof)))).parse_next(input)?;
Ok(value)
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum BzrFragment {
Revision(String),
}
impl Display for BzrFragment {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
BzrFragment::Revision(revision) => write!(f, "revision={revision}"),
}
}
}
impl BzrFragment {
fn parser(input: &mut &str) -> ModalResult<BzrFragment> {
let _ = "#".parse_next(input)?;
cut_err("revision")
.context(StrContext::Label("bzr revision type"))
.context(StrContext::Expected(StrContextValue::Description(
"revision keyword",
)))
.parse_next(input)?;
let value = fragment_value.parse_next(input)?;
Ok(BzrFragment::Revision(value))
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FossilFragment {
Branch(String),
Commit(String),
Tag(String),
}
impl Display for FossilFragment {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
FossilFragment::Branch(revision) => write!(f, "branch={revision}"),
FossilFragment::Commit(revision) => write!(f, "commit={revision}"),
FossilFragment::Tag(revision) => write!(f, "tag={revision}"),
}
}
}
impl FossilFragment {
fn parser(input: &mut &str) -> ModalResult<FossilFragment> {
let _ = "#".parse_next(input)?;
let version_keywords = ["branch", "commit", "tag"];
let version_type = cut_err(alt(version_keywords))
.context(StrContext::Label("fossil revision type"))
.context_with(iter_str_context!([version_keywords]))
.parse_next(input)?;
let value = fragment_value.parse_next(input)?;
match version_type {
"branch" => Ok(FossilFragment::Branch(value.to_string())),
"commit" => Ok(FossilFragment::Commit(value.to_string())),
"tag" => Ok(FossilFragment::Tag(value.to_string())),
_ => unreachable!(),
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GitFragment {
Branch(String),
Commit(String),
Tag(String),
}
impl Display for GitFragment {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
GitFragment::Branch(revision) => write!(f, "branch={revision}"),
GitFragment::Commit(revision) => write!(f, "commit={revision}"),
GitFragment::Tag(revision) => write!(f, "tag={revision}"),
}
}
}
impl GitFragment {
fn parser(input: &mut &str) -> ModalResult<GitFragment> {
let _ = "#".parse_next(input)?;
let version_keywords = ["branch", "commit", "tag"];
let version_type = cut_err(alt(version_keywords))
.context(StrContext::Label("git revision type"))
.context_with(iter_str_context!([version_keywords]))
.parse_next(input)?;
let value = fragment_value.parse_next(input)?;
match version_type {
"branch" => Ok(GitFragment::Branch(value.to_string())),
"commit" => Ok(GitFragment::Commit(value.to_string())),
"tag" => Ok(GitFragment::Tag(value.to_string())),
_ => unreachable!(),
}
}
}
fn git_query(input: &mut &str) -> ModalResult<bool> {
let query = opt("?signed").parse_next(input)?;
Ok(query.is_some())
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum HgFragment {
Branch(String),
Revision(String),
Tag(String),
}
impl Display for HgFragment {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
HgFragment::Branch(revision) => write!(f, "branch={revision}"),
HgFragment::Revision(revision) => write!(f, "revision={revision}"),
HgFragment::Tag(revision) => write!(f, "tag={revision}"),
}
}
}
impl HgFragment {
fn parser(input: &mut &str) -> ModalResult<HgFragment> {
let _ = "#".parse_next(input)?;
let version_keywords = ["branch", "revision", "tag"];
let version_type = cut_err(alt(version_keywords))
.context(StrContext::Label("hg revision type"))
.context_with(iter_str_context!([version_keywords]))
.parse_next(input)?;
let value = fragment_value.parse_next(input)?;
match version_type {
"branch" => Ok(HgFragment::Branch(value.to_string())),
"revision" => Ok(HgFragment::Revision(value.to_string())),
"tag" => Ok(HgFragment::Tag(value.to_string())),
_ => unreachable!(),
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SvnFragment {
Revision(String),
}
impl Display for SvnFragment {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
SvnFragment::Revision(revision) => write!(f, "revision={revision}"),
}
}
}
impl SvnFragment {
fn parser(input: &mut &str) -> ModalResult<SvnFragment> {
let _ = "#".parse_next(input)?;
cut_err("revision")
.context(StrContext::Label("svn revision type"))
.context(StrContext::Expected(StrContextValue::Description(
"revision keyword",
)))
.parse_next(input)?;
let value = fragment_value.parse_next(input)?;
Ok(SvnFragment::Revision(value))
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use testresult::TestResult;
use super::*;
#[rstest]
#[case("https://example.com/", Ok("https://example.com/"))]
#[case(
"https://example.com/path?query=1",
Ok("https://example.com/path?query=1")
)]
#[case("ftp://example.com/", Ok("ftp://example.com/"))]
#[case("not-a-url", Err(url::ParseError::RelativeUrlWithoutBase.into()))]
fn test_url_parsing(#[case] input: &str, #[case] expected: Result<&str, Error>) {
let result = input.parse::<Url>();
assert_eq!(
result.as_ref().map(|v| v.to_string()),
expected.as_ref().map(|v| v.to_string())
);
if let Ok(url) = result {
assert_eq!(url.as_str(), input);
}
}
#[rstest]
#[case(
"git+https://example/project#tag=v1.0.0?signed",
Some("git+https://example/project?signed#tag=v1.0.0"),
SourceUrl {
url: Url::from_str("https://example/project").unwrap(),
vcs_info: Some(VcsInfo::Git {
fragment: Some(GitFragment::Tag("v1.0.0".to_string())),
signed: true
})
}
)]
#[case(
"git+https://example/project?signed#tag=v1.0.0",
None,
SourceUrl {
url: Url::from_str("https://example/project").unwrap(),
vcs_info: Some(VcsInfo::Git {
fragment: Some(GitFragment::Tag("v1.0.0".to_string())),
signed: true
})
}
)]
#[case(
"git://example/project#commit=a51720b",
None,
SourceUrl {
url: Url::from_str("git://example/project").unwrap(),
vcs_info: Some(VcsInfo::Git {
fragment: Some(GitFragment::Commit("a51720b".to_string())),
signed: false
})
}
)]
#[case(
"svn+https://example/project#revision=a51720b",
None,
SourceUrl {
url: Url::from_str("https://example/project").unwrap(),
vcs_info: Some(VcsInfo::Svn {
fragment: Some(SvnFragment::Revision("a51720b".to_string())),
})
}
)]
#[case(
"bzr+https://example/project#revision=a51720b",
None,
SourceUrl {
url: Url::from_str("https://example/project").unwrap(),
vcs_info: Some(VcsInfo::Bzr {
fragment: Some(BzrFragment::Revision("a51720b".to_string())),
})
}
)]
#[case(
"hg+https://example/project#branch=feature",
None,
SourceUrl {
url: Url::from_str("https://example/project").unwrap(),
vcs_info: Some(VcsInfo::Hg {
fragment: Some(HgFragment::Branch("feature".to_string())),
})
}
)]
#[case(
"fossil+https://example/project#branch=feature",
None,
SourceUrl {
url: Url::from_str("https://example/project").unwrap(),
vcs_info: Some(VcsInfo::Fossil {
fragment: Some(FossilFragment::Branch("feature".to_string())),
})
}
)]
#[case(
"https://example/project#branch=feature?signed",
None,
SourceUrl {
url: Url::from_str("https://example/project#branch=feature?signed").unwrap(),
vcs_info: None,
}
)]
fn test_source_url_parsing_success(
#[case] input: &str,
#[case] expected_to_string: Option<&str>,
#[case] expected: SourceUrl,
) -> TestResult {
let source_url = SourceUrl::from_str(input)?;
assert_eq!(
source_url, expected,
"Parsed source_url should resemble the expected output."
);
let expected_to_string = expected_to_string.unwrap_or(input);
assert_eq!(
source_url.to_string(),
expected_to_string,
"Parsed and displayed source_url should resemble original."
);
Ok(())
}
#[rstest]
#[case(
"git+https://example/project#revision=v1.0.0?signed",
"invalid git revision type\nexpected `branch`, `commit`, `tag`"
)]
#[case(
"git+https://example/project#branch=feature#branch=feature",
"invalid unexpected trailing content in URL."
)]
#[case(
"git+https://example/project#branch=feature?signed?signed",
"invalid or duplicate query parameter for detected VCS."
)]
#[case(
"bzr+https://example/project#branch=feature",
"invalid bzr revision type\nexpected revision keyword"
)]
#[case(
"svn+https://example/project#branch=feature",
"invalid svn revision type\nexpected revision keyword"
)]
#[case(
"hg+https://example/project#commit=154021a",
"invalid hg revision type\nexpected `branch`, `revision`, `tag`"
)]
#[case(
"hg+https://example/project#branch=feature?signed",
"invalid or duplicate query parameter for detected VCS."
)]
fn test_source_url_parsing_failure(#[case] input: &str, #[case] error_snippet: &str) {
let result = SourceUrl::from_str(input);
assert!(result.is_err(), "Invalid source_url should fail to parse.");
let err = result.unwrap_err();
let pretty_error = err.to_string();
assert!(
pretty_error.contains(error_snippet),
"Error:\n=====\n{pretty_error}\n=====\nshould contain snippet:\n\n{error_snippet}"
);
}
}