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
9pub 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#[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 #[must_use]
76 pub fn urn(&self) -> String {
77 RAD_PREFIX.to_string() + &self.canonical()
78 }
79
80 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 #[must_use]
95 pub fn canonical(&self) -> String {
96 multibase::encode(multibase::Base::Base58Btc, AsRef::<[u8]>::as_ref(&self.0))
97 }
98
99 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}