#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedUri {
pub original: String,
pub scheme: String,
pub body: String,
pub rev: Option<String>,
pub subdir: Option<String>,
pub via: Option<String>,
}
#[derive(Debug)]
#[non_exhaustive]
pub enum UriParseError {
NoScheme,
EmptyScheme,
MultipleFragments,
MultipleQueries,
QueryBeforeFragment,
UnknownQueryKey { key: String },
DuplicateQueryKey { key: String },
QueryParamMissingValue,
UnsupportedVia { value: String },
}
impl std::fmt::Display for UriParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
UriParseError::NoScheme => write!(f, "missing scheme (expected `<scheme>:<body>`)"),
UriParseError::EmptyScheme => write!(f, "scheme is empty"),
UriParseError::MultipleFragments => write!(f, "multiple `#` fragments"),
UriParseError::MultipleQueries => write!(f, "multiple `?` query strings"),
UriParseError::QueryBeforeFragment => write!(
f,
"`?` appears before `#` — `#rev` must come first, then `?subdir=…`"
),
UriParseError::UnknownQueryKey { key } => write!(
f,
"unknown query parameter `{key}` (recognised keys: `subdir`, `via`)"
),
UriParseError::DuplicateQueryKey { key } => {
write!(f, "query parameter `{key}` appears more than once")
}
UriParseError::QueryParamMissingValue => write!(f, "query parameter has no `=` value"),
UriParseError::UnsupportedVia { value } => write!(
f,
"unsupported `via={value}` (recognised values: `https`, `git`)"
),
}
}
}
impl std::error::Error for UriParseError {}
impl ParsedUri {
pub fn parse(input: &str) -> Result<Self, UriParseError> {
let colon = input.find(':').ok_or(UriParseError::NoScheme)?;
if colon == 0 {
return Err(UriParseError::EmptyScheme);
}
let scheme = input[..colon].to_ascii_lowercase();
let rest = &input[colon + 1..];
let hash = rest.find('#');
let question = rest.find('?');
if let (Some(h), Some(q)) = (hash, question) {
if q < h {
return Err(UriParseError::QueryBeforeFragment);
}
}
let body_end = match (hash, question) {
(Some(h), Some(q)) => h.min(q),
(Some(h), None) => h,
(None, Some(q)) => q,
(None, None) => rest.len(),
};
let body = rest[..body_end].to_string();
let rev = if let Some(h) = hash {
let after_hash = &rest[h + 1..];
if after_hash.contains('#') {
return Err(UriParseError::MultipleFragments);
}
let frag_end = after_hash.find('?').unwrap_or(after_hash.len());
Some(after_hash[..frag_end].to_string())
} else {
None
};
let (subdir, via) = if let Some(q) = question {
let after_q = &rest[q + 1..];
if after_q.contains('?') {
return Err(UriParseError::MultipleQueries);
}
parse_query(after_q)?
} else {
(None, None)
};
Ok(Self {
original: input.to_string(),
scheme,
body,
rev,
subdir,
via,
})
}
pub fn has_fragment(&self) -> bool {
self.rev.is_some()
}
pub fn has_query(&self) -> bool {
self.subdir.is_some() || self.via.is_some()
}
}
fn parse_query(q: &str) -> Result<(Option<String>, Option<String>), UriParseError> {
if q.is_empty() {
return Ok((Some(String::new()), None));
}
let mut subdir = None;
let mut via = None;
for pair in q.split('&') {
let Some((key, value)) = pair.split_once('=') else {
return Err(UriParseError::QueryParamMissingValue);
};
match key {
"subdir" => {
if subdir.is_some() {
return Err(UriParseError::DuplicateQueryKey {
key: key.to_string(),
});
}
subdir = Some(value.to_string());
}
"via" => {
if via.is_some() {
return Err(UriParseError::DuplicateQueryKey {
key: key.to_string(),
});
}
via = Some(value.to_string());
}
_ => {
return Err(UriParseError::UnknownQueryKey {
key: key.to_string(),
});
}
}
}
Ok((subdir, via))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_path_uri() {
let p = ParsedUri::parse("path:acme-labels").unwrap();
assert_eq!(p.scheme, "path");
assert_eq!(p.body, "acme-labels");
assert_eq!(p.rev, None);
assert_eq!(p.subdir, None);
}
#[test]
fn parses_github_with_rev_and_subdir() {
let p = ParsedUri::parse("github:acme/repo#v1.2.0?subdir=labels").unwrap();
assert_eq!(p.scheme, "github");
assert_eq!(p.body, "acme/repo");
assert_eq!(p.rev.as_deref(), Some("v1.2.0"));
assert_eq!(p.subdir.as_deref(), Some("labels"));
}
#[test]
fn parses_https_with_authority() {
let p = ParsedUri::parse("https://example.com/path.tar.gz#main").unwrap();
assert_eq!(p.scheme, "https");
assert_eq!(p.body, "//example.com/path.tar.gz");
assert_eq!(p.rev.as_deref(), Some("main"));
}
#[test]
fn parses_git_ssh_uri() {
let p = ParsedUri::parse("git+ssh://git@host/path.git#v1").unwrap();
assert_eq!(p.scheme, "git+ssh");
assert_eq!(p.body, "//git@host/path.git");
assert_eq!(p.rev.as_deref(), Some("v1"));
}
#[test]
fn lowercases_scheme() {
let p = ParsedUri::parse("GITHUB:acme/repo").unwrap();
assert_eq!(p.scheme, "github");
}
#[test]
fn rejects_no_scheme() {
let err = ParsedUri::parse("acme-labels").unwrap_err();
assert!(matches!(err, UriParseError::NoScheme));
}
#[test]
fn rejects_empty_scheme() {
let err = ParsedUri::parse(":acme/repo").unwrap_err();
assert!(matches!(err, UriParseError::EmptyScheme));
}
#[test]
fn rejects_multiple_fragments() {
let err = ParsedUri::parse("github:acme/repo#a#b").unwrap_err();
assert!(matches!(err, UriParseError::MultipleFragments));
}
#[test]
fn rejects_query_before_fragment() {
let err = ParsedUri::parse("github:acme/repo?subdir=x#rev").unwrap_err();
assert!(matches!(err, UriParseError::QueryBeforeFragment));
}
#[test]
fn rejects_unknown_query_key() {
let err = ParsedUri::parse("github:acme/repo?otherkey=x").unwrap_err();
assert!(matches!(err, UriParseError::UnknownQueryKey { .. }));
}
#[test]
fn rejects_query_without_value() {
let err = ParsedUri::parse("github:acme/repo?subdir").unwrap_err();
assert!(matches!(err, UriParseError::QueryParamMissingValue));
}
#[test]
fn accepts_subdir_and_via_together() {
let p = ParsedUri::parse("github:acme/repo#v1?subdir=labels&via=git").unwrap();
assert_eq!(p.rev.as_deref(), Some("v1"));
assert_eq!(p.subdir.as_deref(), Some("labels"));
assert_eq!(p.via.as_deref(), Some("git"));
}
#[test]
fn accepts_via_alone() {
let p = ParsedUri::parse("github:acme/repo?via=git").unwrap();
assert_eq!(p.via.as_deref(), Some("git"));
assert_eq!(p.subdir, None);
assert!(p.has_query());
}
#[test]
fn rejects_multi_query_with_unknown_key() {
let err = ParsedUri::parse("github:acme/repo#v1?subdir=a&otherkey=b").unwrap_err();
assert!(matches!(err, UriParseError::UnknownQueryKey { key } if key == "otherkey"));
}
#[test]
fn rejects_duplicate_subdir_query_key() {
let err = ParsedUri::parse("github:acme/repo?subdir=a&subdir=b").unwrap_err();
assert!(matches!(err, UriParseError::DuplicateQueryKey { key } if key == "subdir"));
}
#[test]
fn rejects_duplicate_via_query_key() {
let err = ParsedUri::parse("github:acme/repo?via=git&via=https").unwrap_err();
assert!(matches!(err, UriParseError::DuplicateQueryKey { key } if key == "via"));
}
#[test]
fn empty_query_after_question_is_some_empty_not_none() {
let p = ParsedUri::parse("github:acme/repo#v1?").unwrap();
assert_eq!(p.rev.as_deref(), Some("v1"));
assert_eq!(p.subdir.as_deref(), Some(""));
assert!(p.has_query());
}
#[test]
fn fragment_without_rev_value_is_some_empty() {
let p = ParsedUri::parse("github:acme/repo#").unwrap();
assert_eq!(p.rev.as_deref(), Some(""));
assert!(p.has_fragment());
}
}