radicle/storage/git/transport/local/
url.rs1use std::fmt;
3use std::str::FromStr;
4
5use thiserror::Error;
6
7use crate::{
8 crypto,
9 identity::{IdError, RepoId},
10};
11
12type Namespace = crypto::PublicKey;
14
15#[derive(Debug, Error)]
16pub enum UrlError {
17 #[error("invalid url format: expected `rad://<repo>[/<namespace>]`")]
19 InvalidFormat,
20 #[error("unsupported scheme: expected `rad://`")]
22 UnsupportedScheme,
23 #[error("repo: {0}")]
25 InvalidRepository(#[source] IdError),
26 #[error("namespace: {0}")]
28 InvalidNamespace(#[source] crypto::PublicKeyError),
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
39pub struct Url {
40 pub repo: RepoId,
42 pub namespace: Option<Namespace>,
44}
45
46impl Url {
47 pub const SCHEME: &'static str = "rad";
49
50 pub fn with_namespace(mut self, namespace: Namespace) -> Self {
52 self.namespace = Some(namespace);
53 self
54 }
55}
56
57impl From<RepoId> for Url {
58 fn from(repo: RepoId) -> Self {
59 Self {
60 repo,
61 namespace: None,
62 }
63 }
64}
65
66impl fmt::Display for Url {
67 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68 if let Some(ns) = self.namespace {
69 write!(f, "{}://{}/{}", Self::SCHEME, self.repo.canonical(), ns)
70 } else {
71 write!(f, "{}://{}", Self::SCHEME, self.repo.canonical())
72 }
73 }
74}
75
76impl FromStr for Url {
77 type Err = UrlError;
78
79 fn from_str(s: &str) -> Result<Self, Self::Err> {
80 let rest = s
81 .strip_prefix("rad://")
82 .ok_or(UrlError::UnsupportedScheme)?;
83 let components = rest.split('/').collect::<Vec<_>>();
84
85 let (resource, namespace) = match components.as_slice() {
86 [resource] => (resource, None),
87 [resource, namespace] => (resource, Some(namespace)),
88 _ => return Err(UrlError::InvalidFormat),
89 };
90
91 let resource = RepoId::from_canonical(resource).map_err(UrlError::InvalidRepository)?;
92 let namespace = namespace
93 .map(|pk| Namespace::from_str(pk).map_err(UrlError::InvalidNamespace))
94 .transpose()?;
95
96 Ok(Url {
97 repo: resource,
98 namespace,
99 })
100 }
101}
102
103#[cfg(test)]
104#[allow(clippy::unwrap_used)]
105mod test {
106 use super::*;
107
108 #[test]
109 fn test_url_parse() {
110 let repo = RepoId::from_canonical("z2w8RArM3gaBXZxXhQUswE3hhLcss").unwrap();
111 let namespace =
112 Namespace::from_str("z6Mkifeb5NPS6j7JP72kEQEeuqMTpCAVcHsJi1C86jGTzHRi").unwrap();
113
114 let url = format!("rad://{}", repo.canonical());
115 let url = Url::from_str(&url).unwrap();
116
117 assert_eq!(url.repo, repo);
118 assert_eq!(url.namespace, None);
119
120 let url = format!("rad://{}/{namespace}", repo.canonical());
121 let url = Url::from_str(&url).unwrap();
122
123 assert_eq!(url.repo, repo);
124 assert_eq!(url.namespace, Some(namespace));
125
126 assert!(format!("heartwood://{}", repo.canonical())
127 .parse::<Url>()
128 .is_err());
129 assert!(format!("git://{}", repo.canonical())
130 .parse::<Url>()
131 .is_err());
132 assert!(format!("rad://{namespace}").parse::<Url>().is_err());
133 assert!(format!("rad://{}/{namespace}/fnord", repo.canonical())
134 .parse::<Url>()
135 .is_err());
136 }
137
138 #[test]
139 fn test_url_to_string() {
140 let repo = RepoId::from_canonical("z2w8RArM3gaBXZxXhQUswE3hhLcss").unwrap();
141 let namespace =
142 Namespace::from_str("z6Mkifeb5NPS6j7JP72kEQEeuqMTpCAVcHsJi1C86jGTzHRi").unwrap();
143
144 let url = Url {
145 repo,
146 namespace: None,
147 };
148 assert_eq!(url.to_string(), format!("rad://{}", repo.canonical()));
149
150 let url = Url {
151 repo,
152 namespace: Some(namespace),
153 };
154 assert_eq!(
155 url.to_string(),
156 format!("rad://{}/{namespace}", repo.canonical())
157 );
158 }
159}