radicle/storage/git/transport/local/
url.rs

1//! Git local transport URLs.
2use std::fmt;
3use std::str::FromStr;
4
5use thiserror::Error;
6
7use crate::{
8    crypto,
9    identity::{IdError, RepoId},
10};
11
12/// Repository namespace.
13type Namespace = crypto::PublicKey;
14
15#[derive(Debug, Error)]
16pub enum UrlError {
17    /// Invalid format.
18    #[error("invalid url format: expected `rad://<repo>[/<namespace>]`")]
19    InvalidFormat,
20    /// Unsupported URL scheme.
21    #[error("unsupported scheme: expected `rad://`")]
22    UnsupportedScheme,
23    /// Invalid repository identifier.
24    #[error("repo: {0}")]
25    InvalidRepository(#[source] IdError),
26    /// Invalid namespace.
27    #[error("namespace: {0}")]
28    InvalidNamespace(#[source] crypto::PublicKeyError),
29}
30
31/// A git local transport URL.
32///
33/// * Used to content-address a repository, eg. when sharing projects.
34/// * Used as a remore url in a git working copy.
35///
36/// `rad://<repo>[/<namespace>]`
37///
38#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
39pub struct Url {
40    /// Repository identifier.
41    pub repo: RepoId,
42    /// Repository sub-tree.
43    pub namespace: Option<Namespace>,
44}
45
46impl Url {
47    /// URL scheme.
48    pub const SCHEME: &'static str = "rad";
49
50    /// Return this URL with the given namespace added.
51    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}