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 {} bytes, got {actual} bytes", Oid::LEN_SHA1)]
18    Length { 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 prefix [`RAD_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        let (base, bytes) = multibase::decode(input)?;
110        Self::guard_base_encoding(input, base)?;
111        let bytes: [u8; Oid::LEN_SHA1] =
112            bytes.try_into().map_err(|bytes: Vec<u8>| IdError::Length {
113                actual: bytes.len(),
114            })?;
115        Ok(Self(Oid::from_sha1(bytes)))
116    }
117
118    fn guard_base_encoding(input: &str, base: multibase::Base) -> Result<(), IdError> {
119        if !Self::ALLOWED_BASES.contains(&base) {
120            Err(IdError::MismatchedBaseEncoding {
121                input: input.to_string(),
122                expected: Self::ALLOWED_BASES.to_vec(),
123                found: base,
124            })
125        } else {
126            Ok(())
127        }
128    }
129}
130
131impl core::str::FromStr for RepoId {
132    type Err = IdError;
133
134    fn from_str(s: &str) -> Result<Self, Self::Err> {
135        Self::from_urn(s)
136    }
137}
138
139#[cfg(feature = "std")]
140mod std_impls {
141    extern crate std;
142
143    use super::{IdError, RepoId};
144
145    use std::ffi::OsString;
146
147    impl TryFrom<OsString> for RepoId {
148        type Error = IdError;
149
150        fn try_from(value: OsString) -> Result<Self, Self::Error> {
151            let string = value.to_string_lossy();
152            Self::from_canonical(&string)
153        }
154    }
155
156    impl std::hash::Hash for RepoId {
157        fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
158            self.0.hash(state)
159        }
160    }
161}
162
163impl From<Oid> for RepoId {
164    fn from(oid: Oid) -> Self {
165        Self(oid)
166    }
167}
168
169impl core::ops::Deref for RepoId {
170    type Target = Oid;
171
172    fn deref(&self) -> &Self::Target {
173        &self.0
174    }
175}
176
177#[cfg(feature = "git2")]
178mod git2_impls {
179    use super::RepoId;
180
181    impl From<git2::Oid> for RepoId {
182        fn from(oid: git2::Oid) -> Self {
183            Self(oid.into())
184        }
185    }
186}
187
188#[cfg(feature = "gix")]
189mod gix_impls {
190    use super::RepoId;
191
192    impl From<gix_hash::ObjectId> for RepoId {
193        fn from(oid: gix_hash::ObjectId) -> Self {
194            Self(oid.into())
195        }
196    }
197}
198
199#[cfg(feature = "radicle-git-ref-format")]
200mod radicle_git_ref_format_impls {
201    use alloc::string::ToString;
202
203    use radicle_git_ref_format::{Component, RefString};
204
205    use super::RepoId;
206
207    impl From<&RepoId> for Component<'_> {
208        fn from(id: &RepoId) -> Self {
209            let refstr = RefString::try_from(id.0.to_string())
210                .expect("repository id's are valid ref strings");
211            Component::from_refstr(refstr).expect("repository id's are valid refname components")
212        }
213    }
214}
215
216#[cfg(feature = "serde")]
217mod serde_impls {
218    use alloc::string::String;
219
220    use serde::{Deserialize, Deserializer, Serialize, de};
221
222    use super::RepoId;
223
224    impl Serialize for RepoId {
225        fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
226        where
227            S: serde::Serializer,
228        {
229            serializer.collect_str(&self.urn())
230        }
231    }
232
233    impl<'de> Deserialize<'de> for RepoId {
234        fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
235        where
236            D: Deserializer<'de>,
237        {
238            String::deserialize(deserializer)?
239                .parse()
240                .map_err(de::Error::custom)
241        }
242    }
243
244    #[cfg(test)]
245    mod test {
246        use proptest::proptest;
247
248        use super::super::*;
249
250        fn prop_roundtrip_serde_json(rid: RepoId) {
251            let encoded = serde_json::to_string(&rid).unwrap();
252            let decoded = serde_json::from_str(&encoded).unwrap();
253
254            assert_eq!(rid, decoded);
255        }
256
257        proptest! {
258            #[test]
259            fn assert_prop_roundtrip_serde_json(rid in arbitrary::rid()) {
260                prop_roundtrip_serde_json(rid)
261            }
262        }
263    }
264}
265
266#[cfg(feature = "sqlite")]
267mod sqlite_impls {
268    use alloc::format;
269    use alloc::string::ToString;
270
271    use super::RepoId;
272
273    use sqlite::{BindableWithIndex, Error, ParameterIndex, Statement, Value};
274
275    impl TryFrom<&Value> for RepoId {
276        type Error = Error;
277
278        fn try_from(value: &Value) -> Result<Self, Self::Error> {
279            match value {
280                Value::String(id) => RepoId::from_urn(id).map_err(|e| Error {
281                    code: None,
282                    message: Some(e.to_string()),
283                }),
284                Value::Binary(_) | Value::Float(_) | Value::Integer(_) | Value::Null => {
285                    Err(Error {
286                        code: None,
287                        message: Some(format!("sql: invalid type `{:?}` for id", value.kind())),
288                    })
289                }
290            }
291        }
292    }
293
294    impl BindableWithIndex for &RepoId {
295        fn bind<I: ParameterIndex>(self, stmt: &mut Statement<'_>, i: I) -> sqlite::Result<()> {
296            self.urn().as_str().bind(stmt, i)
297        }
298    }
299}
300
301#[cfg(any(test, feature = "proptest"))]
302pub mod arbitrary {
303    use proptest::prelude::Strategy;
304
305    use super::RepoId;
306
307    pub fn rid() -> impl Strategy<Value = RepoId> {
308        proptest::array::uniform20(proptest::num::u8::ANY)
309            .prop_map(|bytes| RepoId::from(radicle_oid::Oid::from_sha1(bytes)))
310    }
311}
312
313#[cfg(feature = "qcheck")]
314impl qcheck::Arbitrary for RepoId {
315    fn arbitrary(g: &mut qcheck::Gen) -> Self {
316        RepoId::from(radicle_oid::Oid::arbitrary(g))
317    }
318}
319
320#[cfg(test)]
321#[allow(clippy::unwrap_used)]
322mod test {
323    use super::*;
324    use proptest::proptest;
325
326    fn prop_roundtrip_parse(rid: RepoId) {
327        use core::str::FromStr as _;
328        let encoded = rid.to_string();
329        let decoded = RepoId::from_str(&encoded).unwrap();
330
331        assert_eq!(rid, decoded);
332    }
333
334    proptest! {
335        #[test]
336        fn assert_prop_roundtrip_parse(rid in arbitrary::rid()) {
337            prop_roundtrip_parse(rid)
338        }
339    }
340
341    #[test]
342    fn invalid() {
343        assert!("".parse::<RepoId>().is_err());
344        assert!("not-a-valid-rid".parse::<RepoId>().is_err());
345        assert!(
346            "xyz:z3gqcJUoA1n9HaHKufZs5FCSGazv5"
347                .parse::<RepoId>()
348                .is_err()
349        );
350        assert!(
351            "RAD:z3gqcJUoA1n9HaHKufZs5FCSGazv5"
352                .parse::<RepoId>()
353                .is_err()
354        );
355        assert!("rad:".parse::<RepoId>().is_err());
356        assert!(
357            "rad:z3gqcJUoA1n9HaHKufZs5FCSG0zv5"
358                .parse::<RepoId>()
359                .is_err()
360        );
361        assert!(
362            "rad:z3gqcJUoA1n9HaHKufZs5FCSGOzv5"
363                .parse::<RepoId>()
364                .is_err()
365        );
366        assert!(
367            "rad:z3gqcJUoA1n9HaHKufZs5FCSGIzv5"
368                .parse::<RepoId>()
369                .is_err()
370        );
371        assert!(
372            "rad:z3gqcJUoA1n9HaHKufZs5FCSGlzv5"
373                .parse::<RepoId>()
374                .is_err()
375        );
376        assert!(
377            "rad:z3gqcJUoA1n9HaHKufZs5FCSGázv5"
378                .parse::<RepoId>()
379                .is_err()
380        );
381        assert!(
382            "rad:z3gqcJUoA1n9HaHKufZs5FCSG@zv5"
383                .parse::<RepoId>()
384                .is_err()
385        );
386        assert!(
387            "rad:Z3gqcJUoA1n9HaHKufZs5FCSGazv5"
388                .parse::<RepoId>()
389                .is_err()
390        );
391        assert!("rad:z3gqcJUoA1n9HaHKuf".parse::<RepoId>().is_err());
392        assert!(
393            "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5abcdef"
394                .parse::<RepoId>()
395                .is_err()
396        );
397        assert!(
398            "rad: z3gqcJUoA1n9HaHKufZs5FCSGazv5"
399                .parse::<RepoId>()
400                .is_err()
401        );
402    }
403
404    #[test]
405    fn valid() {
406        assert!(
407            "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5"
408                .parse::<RepoId>()
409                .is_ok()
410        );
411        assert!("z3gqcJUoA1n9HaHKufZs5FCSGazv5".parse::<RepoId>().is_ok());
412        assert!("z3XncAdkZjeK9mQS5Sdc4qhw98BUX".parse::<RepoId>().is_ok());
413    }
414}