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 {} 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#[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 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}