Skip to main content

radicle_core/
repo.rs

1use alloc::fmt;
2use alloc::string::String;
3use alloc::string::ToString as _;
4use alloc::vec::Vec;
5
6use radicle_oid::Oid;
7use thiserror::Error;
8
9/// Radicle identifier prefix.
10pub const RAD_PREFIX: &str = "rad:";
11
12#[non_exhaustive]
13#[derive(Error, Debug)]
14pub enum IdError {
15    #[error(transparent)]
16    Multibase(#[from] multibase::Error),
17    #[error("invalid length: expected {expected} bytes, got {actual} bytes")]
18    Length { expected: usize, actual: usize },
19    #[error(fmt = fmt_mismatched_base_encoding)]
20    MismatchedBaseEncoding {
21        input: String,
22        expected: Vec<multibase::Base>,
23        found: multibase::Base,
24    },
25}
26
27fn fmt_mismatched_base_encoding(
28    input: &String,
29    expected: &[multibase::Base],
30    found: &multibase::Base,
31    formatter: &mut fmt::Formatter,
32) -> fmt::Result {
33    write!(
34        formatter,
35        "invalid multibase encoding '{}' for '{}', expected one of {:?}",
36        found.code(),
37        input,
38        expected.iter().map(|base| base.code()).collect::<Vec<_>>()
39    )
40}
41
42/// A repository identifier.
43#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
44#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
45pub struct RepoId(
46    #[cfg_attr(feature = "schemars", schemars(
47        with = "String",
48        description = "A repository identifier. Starts with \"rad:\", followed by a multibase Base58 encoded Git object identifier.",
49        regex(pattern = r"rad:z[1-9a-km-zA-HJ-NP-Z]+"),
50        length(min = 5),
51        example = &"rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5",
52    ))]
53    Oid,
54);
55
56impl core::fmt::Display for RepoId {
57    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
58        f.write_str(self.urn().as_str())
59    }
60}
61
62impl core::fmt::Debug for RepoId {
63    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
64        write!(f, "RepoId({self})")
65    }
66}
67
68impl RepoId {
69    const ALLOWED_BASES: [multibase::Base; 1] = [multibase::Base::Base58Btc];
70
71    /// Format the identifier as a human-readable URN.
72    ///
73    /// Eg. `rad:z3XncAdkZjeK9mQS5Sdc4qhw98BUX`.
74    ///
75    #[must_use]
76    pub fn urn(&self) -> String {
77        RAD_PREFIX.to_string() + &self.canonical()
78    }
79
80    /// Parse an identifier from the human-readable URN format.
81    /// Accepts strings without the radicle prefix as well,
82    /// for convenience.
83    pub fn from_urn(s: &str) -> Result<Self, IdError> {
84        let s = s.strip_prefix(RAD_PREFIX).unwrap_or(s);
85        let id = Self::from_canonical(s)?;
86
87        Ok(id)
88    }
89
90    /// Format the identifier as a multibase string.
91    ///
92    /// Eg. `z3XncAdkZjeK9mQS5Sdc4qhw98BUX`.
93    ///
94    #[must_use]
95    pub fn canonical(&self) -> String {
96        multibase::encode(multibase::Base::Base58Btc, AsRef::<[u8]>::as_ref(&self.0))
97    }
98
99    /// Decode the input string into a [`RepoId`].
100    ///
101    /// # Errors
102    ///
103    /// - The [multibase] decoding fails
104    /// - The decoded [multibase] code does not match any expected multibase code
105    /// - The input exceeds the expected number of bytes, post multibase decoding
106    ///
107    /// [multibase]: https://github.com/multiformats/multibase?tab=readme-ov-file#multibase-table
108    pub fn from_canonical(input: &str) -> Result<Self, IdError> {
109        const EXPECTED_LEN: usize = 20;
110        let (base, bytes) = multibase::decode(input)?;
111        Self::guard_base_encoding(input, base)?;
112        let bytes: [u8; EXPECTED_LEN] =
113            bytes.try_into().map_err(|bytes: Vec<u8>| IdError::Length {
114                expected: EXPECTED_LEN,
115                actual: bytes.len(),
116            })?;
117        Ok(Self(Oid::from_sha1(bytes)))
118    }
119
120    fn guard_base_encoding(input: &str, base: multibase::Base) -> Result<(), IdError> {
121        if !Self::ALLOWED_BASES.contains(&base) {
122            Err(IdError::MismatchedBaseEncoding {
123                input: input.to_string(),
124                expected: Self::ALLOWED_BASES.to_vec(),
125                found: base,
126            })
127        } else {
128            Ok(())
129        }
130    }
131}
132
133impl core::str::FromStr for RepoId {
134    type Err = IdError;
135
136    fn from_str(s: &str) -> Result<Self, Self::Err> {
137        Self::from_urn(s)
138    }
139}
140
141#[cfg(feature = "std")]
142mod std_impls {
143    extern crate std;
144
145    use super::{IdError, RepoId};
146
147    use std::ffi::OsString;
148
149    impl TryFrom<OsString> for RepoId {
150        type Error = IdError;
151
152        fn try_from(value: OsString) -> Result<Self, Self::Error> {
153            let string = value.to_string_lossy();
154            Self::from_canonical(&string)
155        }
156    }
157
158    impl std::hash::Hash for RepoId {
159        fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
160            self.0.hash(state)
161        }
162    }
163}
164
165impl From<Oid> for RepoId {
166    fn from(oid: Oid) -> Self {
167        Self(oid)
168    }
169}
170
171impl core::ops::Deref for RepoId {
172    type Target = Oid;
173
174    fn deref(&self) -> &Self::Target {
175        &self.0
176    }
177}
178
179#[cfg(feature = "git2")]
180mod git2_impls {
181    use super::RepoId;
182
183    impl From<git2::Oid> for RepoId {
184        fn from(oid: git2::Oid) -> Self {
185            Self(oid.into())
186        }
187    }
188}
189
190#[cfg(feature = "gix")]
191mod gix_impls {
192    use super::RepoId;
193
194    impl From<gix_hash::ObjectId> for RepoId {
195        fn from(oid: gix_hash::ObjectId) -> Self {
196            Self(oid.into())
197        }
198    }
199}
200
201#[cfg(feature = "radicle-git-ref-format")]
202mod radicle_git_ref_format_impls {
203    use alloc::string::ToString;
204
205    use radicle_git_ref_format::{Component, RefString};
206
207    use super::RepoId;
208
209    impl From<&RepoId> for Component<'_> {
210        fn from(id: &RepoId) -> Self {
211            let refstr = RefString::try_from(id.0.to_string())
212                .expect("repository id's are valid ref strings");
213            Component::from_refstr(refstr).expect("repository id's are valid refname components")
214        }
215    }
216}
217
218#[cfg(feature = "serde")]
219mod serde_impls {
220    use alloc::string::String;
221
222    use serde::{de, Deserialize, Deserializer, Serialize};
223
224    use super::RepoId;
225
226    impl Serialize for RepoId {
227        fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
228        where
229            S: serde::Serializer,
230        {
231            serializer.collect_str(&self.urn())
232        }
233    }
234
235    impl<'de> Deserialize<'de> for RepoId {
236        fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
237        where
238            D: Deserializer<'de>,
239        {
240            String::deserialize(deserializer)?
241                .parse()
242                .map_err(de::Error::custom)
243        }
244    }
245
246    #[cfg(test)]
247    mod test {
248        use proptest::proptest;
249
250        use super::super::*;
251
252        fn prop_roundtrip_serde_json(rid: RepoId) {
253            let encoded = serde_json::to_string(&rid).unwrap();
254            let decoded = serde_json::from_str(&encoded).unwrap();
255
256            assert_eq!(rid, decoded);
257        }
258
259        proptest! {
260            #[test]
261            fn assert_prop_roundtrip_serde_json(rid in arbitrary::rid()) {
262                prop_roundtrip_serde_json(rid)
263            }
264        }
265    }
266}
267
268#[cfg(feature = "sqlite")]
269mod sqlite_impls {
270    use alloc::format;
271    use alloc::string::ToString;
272
273    use super::RepoId;
274
275    use sqlite::{BindableWithIndex, Error, ParameterIndex, Statement, Value};
276
277    impl TryFrom<&Value> for RepoId {
278        type Error = Error;
279
280        fn try_from(value: &Value) -> Result<Self, Self::Error> {
281            match value {
282                Value::String(id) => RepoId::from_urn(id).map_err(|e| Error {
283                    code: None,
284                    message: Some(e.to_string()),
285                }),
286                Value::Binary(_) | Value::Float(_) | Value::Integer(_) | Value::Null => {
287                    Err(Error {
288                        code: None,
289                        message: Some(format!("sql: invalid type `{:?}` for id", value.kind())),
290                    })
291                }
292            }
293        }
294    }
295
296    impl BindableWithIndex for &RepoId {
297        fn bind<I: ParameterIndex>(self, stmt: &mut Statement<'_>, i: I) -> sqlite::Result<()> {
298            self.urn().as_str().bind(stmt, i)
299        }
300    }
301}
302
303#[cfg(any(test, feature = "proptest"))]
304pub mod arbitrary {
305    use proptest::prelude::Strategy;
306
307    use super::RepoId;
308
309    pub fn rid() -> impl Strategy<Value = RepoId> {
310        proptest::array::uniform20(proptest::num::u8::ANY)
311            .prop_map(|bytes| RepoId::from(radicle_oid::Oid::from_sha1(bytes)))
312    }
313}
314
315#[cfg(feature = "qcheck")]
316impl qcheck::Arbitrary for RepoId {
317    fn arbitrary(g: &mut qcheck::Gen) -> Self {
318        let bytes = <[u8; 20]>::arbitrary(g);
319        let oid = radicle_oid::Oid::from_sha1(bytes);
320
321        RepoId::from(oid)
322    }
323}
324
325#[cfg(test)]
326#[allow(clippy::unwrap_used)]
327mod test {
328    use super::*;
329    use proptest::proptest;
330
331    fn prop_roundtrip_parse(rid: RepoId) {
332        use core::str::FromStr as _;
333        let encoded = rid.to_string();
334        let decoded = RepoId::from_str(&encoded).unwrap();
335
336        assert_eq!(rid, decoded);
337    }
338
339    proptest! {
340        #[test]
341        fn assert_prop_roundtrip_parse(rid in arbitrary::rid()) {
342            prop_roundtrip_parse(rid)
343        }
344    }
345
346    #[test]
347    fn invalid() {
348        assert!("".parse::<RepoId>().is_err());
349        assert!("not-a-valid-rid".parse::<RepoId>().is_err());
350        assert!("xyz:z3gqcJUoA1n9HaHKufZs5FCSGazv5"
351            .parse::<RepoId>()
352            .is_err());
353        assert!("RAD:z3gqcJUoA1n9HaHKufZs5FCSGazv5"
354            .parse::<RepoId>()
355            .is_err());
356        assert!("rad:".parse::<RepoId>().is_err());
357        assert!("rad:z3gqcJUoA1n9HaHKufZs5FCSG0zv5"
358            .parse::<RepoId>()
359            .is_err());
360        assert!("rad:z3gqcJUoA1n9HaHKufZs5FCSGOzv5"
361            .parse::<RepoId>()
362            .is_err());
363        assert!("rad:z3gqcJUoA1n9HaHKufZs5FCSGIzv5"
364            .parse::<RepoId>()
365            .is_err());
366        assert!("rad:z3gqcJUoA1n9HaHKufZs5FCSGlzv5"
367            .parse::<RepoId>()
368            .is_err());
369        assert!("rad:z3gqcJUoA1n9HaHKufZs5FCSGázv5"
370            .parse::<RepoId>()
371            .is_err());
372        assert!("rad:z3gqcJUoA1n9HaHKufZs5FCSG@zv5"
373            .parse::<RepoId>()
374            .is_err());
375        assert!("rad:Z3gqcJUoA1n9HaHKufZs5FCSGazv5"
376            .parse::<RepoId>()
377            .is_err());
378        assert!("rad:z3gqcJUoA1n9HaHKuf".parse::<RepoId>().is_err());
379        assert!("rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5abcdef"
380            .parse::<RepoId>()
381            .is_err());
382        assert!("rad: z3gqcJUoA1n9HaHKufZs5FCSGazv5"
383            .parse::<RepoId>()
384            .is_err());
385    }
386
387    #[test]
388    fn valid() {
389        assert!("rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5"
390            .parse::<RepoId>()
391            .is_ok());
392        assert!("z3gqcJUoA1n9HaHKufZs5FCSGazv5".parse::<RepoId>().is_ok());
393        assert!("z3XncAdkZjeK9mQS5Sdc4qhw98BUX".parse::<RepoId>().is_ok());
394    }
395}