use alloc::string::String; use alloc::string::ToString as _; use alloc::vec::Vec; use radicle_oid::Oid; use thiserror::Error; /// Radicle identifier prefix. pub const RAD_PREFIX: &str = "rad:"; #[derive(Error, Debug)] pub enum IdError { #[error(transparent)] Multibase(#[from] multibase::Error), #[error("invalid length: expected {expected} bytes, got {actual} bytes")] Length { expected: usize, actual: usize }, } /// A repository identifier. #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct RepoId( #[cfg_attr(feature = "schemars", schemars( with = "String", description = "A repository identifier. Starts with \"rad:\", followed by a multibase Base58 encoded Git object identifier.", regex(pattern = r"rad:z[1-9a-km-zA-HJ-NP-Z]+"), length(min = 5), example = &"rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5", ))] Oid, ); impl core::fmt::Display for RepoId { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.write_str(self.urn().as_str()) } } impl core::fmt::Debug for RepoId { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!(f, "RepoId({self})") } } impl RepoId { /// Format the identifier as a human-readable URN. /// /// Eg. `rad:z3XncAdkZjeK9mQS5Sdc4qhw98BUX`. /// #[must_use] pub fn urn(&self) -> String { RAD_PREFIX.to_string() + &self.canonical() } /// Parse an identifier from the human-readable URN format. /// Accepts strings without the radicle prefix as well, /// for convenience. pub fn from_urn(s: &str) -> Result { let s = s.strip_prefix(RAD_PREFIX).unwrap_or(s); let id = Self::from_canonical(s)?; Ok(id) } /// Format the identifier as a multibase string. /// /// Eg. `z3XncAdkZjeK9mQS5Sdc4qhw98BUX`. /// #[must_use] pub fn canonical(&self) -> String { multibase::encode(multibase::Base::Base58Btc, AsRef::<[u8]>::as_ref(&self.0)) } pub fn from_canonical(input: &str) -> Result { const EXPECTED_LEN: usize = 20; let (_, bytes) = multibase::decode(input)?; let bytes: [u8; EXPECTED_LEN] = bytes.try_into().map_err(|bytes: Vec| IdError::Length { expected: EXPECTED_LEN, actual: bytes.len(), })?; Ok(Self(Oid::from_sha1(bytes))) } } impl core::str::FromStr for RepoId { type Err = IdError; fn from_str(s: &str) -> Result { Self::from_urn(s) } } #[cfg(feature = "std")] mod std_impls { extern crate std; use super::{IdError, RepoId}; use std::ffi::OsString; impl TryFrom for RepoId { type Error = IdError; fn try_from(value: OsString) -> Result { let string = value.to_string_lossy(); Self::from_canonical(&string) } } impl std::hash::Hash for RepoId { fn hash(&self, state: &mut H) { self.0.hash(state) } } } impl From for RepoId { fn from(oid: Oid) -> Self { Self(oid) } } impl core::ops::Deref for RepoId { type Target = Oid; fn deref(&self) -> &Self::Target { &self.0 } } #[cfg(feature = "git2")] mod git2_impls { use super::RepoId; impl From for RepoId { fn from(oid: git2::Oid) -> Self { Self(oid.into()) } } } #[cfg(feature = "gix")] mod gix_impls { use super::RepoId; impl From for RepoId { fn from(oid: gix_hash::ObjectId) -> Self { Self(oid.into()) } } } #[cfg(feature = "radicle-git-ref-format")] mod radicle_git_ref_format_impls { use alloc::string::ToString; use radicle_git_ref_format::{Component, RefString}; use super::RepoId; impl From<&RepoId> for Component<'_> { fn from(id: &RepoId) -> Self { let refstr = RefString::try_from(id.0.to_string()) .expect("repository id's are valid ref strings"); Component::from_refstr(refstr).expect("repository id's are valid refname components") } } } #[cfg(feature = "serde")] mod serde_impls { use alloc::string::String; use serde::{de, Deserialize, Deserializer, Serialize}; use super::RepoId; impl Serialize for RepoId { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { serializer.collect_str(&self.urn()) } } impl<'de> Deserialize<'de> for RepoId { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { String::deserialize(deserializer)? .parse() .map_err(de::Error::custom) } } #[cfg(test)] mod test { use proptest::proptest; use super::super::*; fn prop_roundtrip_serde_json(rid: RepoId) { let encoded = serde_json::to_string(&rid).unwrap(); let decoded = serde_json::from_str(&encoded).unwrap(); assert_eq!(rid, decoded); } proptest! { #[test] fn assert_prop_roundtrip_serde_json(rid in arbitrary::rid()) { prop_roundtrip_serde_json(rid) } } } } #[cfg(feature = "sqlite")] mod sqlite_impls { use alloc::format; use alloc::string::ToString; use super::RepoId; use sqlite::{BindableWithIndex, Error, ParameterIndex, Statement, Value}; impl TryFrom<&Value> for RepoId { type Error = Error; fn try_from(value: &Value) -> Result { match value { Value::String(id) => RepoId::from_urn(id).map_err(|e| Error { code: None, message: Some(e.to_string()), }), Value::Binary(_) | Value::Float(_) | Value::Integer(_) | Value::Null => { Err(Error { code: None, message: Some(format!("sql: invalid type `{:?}` for id", value.kind())), }) } } } } impl BindableWithIndex for &RepoId { fn bind(self, stmt: &mut Statement<'_>, i: I) -> sqlite::Result<()> { self.urn().as_str().bind(stmt, i) } } } #[cfg(any(test, feature = "proptest"))] pub mod arbitrary { use proptest::prelude::Strategy; use super::RepoId; pub fn rid() -> impl Strategy { proptest::array::uniform20(proptest::num::u8::ANY) .prop_map(|bytes| RepoId::from(radicle_oid::Oid::from_sha1(bytes))) } } #[cfg(feature = "qcheck")] impl qcheck::Arbitrary for RepoId { fn arbitrary(g: &mut qcheck::Gen) -> Self { let bytes = <[u8; 20]>::arbitrary(g); let oid = radicle_oid::Oid::from_sha1(bytes); RepoId::from(oid) } } #[cfg(test)] #[allow(clippy::unwrap_used)] mod test { use super::*; use proptest::proptest; fn prop_roundtrip_parse(rid: RepoId) { use core::str::FromStr as _; let encoded = rid.to_string(); let decoded = RepoId::from_str(&encoded).unwrap(); assert_eq!(rid, decoded); } proptest! { #[test] fn assert_prop_roundtrip_parse(rid in arbitrary::rid()) { prop_roundtrip_parse(rid) } } }