use std::fmt::{self, Display, Formatter};
use std::str::FromStr;
use uuid::Uuid;
use crate::Error;
#[cfg(feature = "tables")]
use crate::catalog_types::NamespaceParent;
use crate::catalog_types::{
NamespacePath, NamespaceSegment, ProjectBranchName, ProjectCommitId, VersionId,
};
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub enum ResourceRef {
Branch(ProjectBranchName),
Commit(ProjectCommitId),
Version(VersionId),
}
impl FromStr for ResourceRef {
type Err = Error;
fn from_str(value: &str) -> Result<Self, Error> {
if value.len() == 33 && (value.starts_with('v') || value.starts_with('c')) {
let hex = &value[1..];
if !hex
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(Error::invalid(
"version and commit refs require 32 lowercase hex digits",
));
}
let id = Uuid::parse_str(hex).map_err(Error::invalid)?;
return if value.starts_with('v') {
Ok(Self::Version(VersionId::new(id).map_err(Error::invalid)?))
} else {
Ok(Self::Commit(
ProjectCommitId::new(id).map_err(Error::invalid)?,
))
};
}
if value.contains(['@', '~']) {
return Err(Error::invalid(
"refs cannot contain `@` or ancestry expressions",
));
}
Ok(Self::Branch(
ProjectBranchName::new(value).map_err(Error::invalid)?,
))
}
}
impl Display for ResourceRef {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
match self {
Self::Branch(branch) => branch.fmt(formatter),
Self::Commit(commit) => write!(formatter, "c{}", commit.as_uuid().simple()),
Self::Version(version) => write!(formatter, "v{}", version.as_uuid().simple()),
}
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct ResourceAddress {
path: NamespacePath,
reference: Option<ResourceRef>,
}
impl ResourceAddress {
pub fn path(&self) -> &NamespacePath {
&self.path
}
pub fn reference(&self) -> Option<&ResourceRef> {
self.reference.as_ref()
}
#[cfg(feature = "tables")]
pub(crate) fn at_version(&self, version: VersionId) -> Self {
Self {
path: self.path.clone(),
reference: Some(ResourceRef::Version(version)),
}
}
#[cfg(feature = "tables")]
pub(crate) fn root_binding(&self) -> Result<(NamespaceParent, NamespaceSegment), Error> {
let (name, prefix) = self
.path
.segments()
.split_last()
.expect("namespace paths are nonempty");
let parent = prefix
.iter()
.try_fold(NamespaceParent::project_root(), |parent, segment| {
parent.child_prefix(segment.clone())
})
.map_err(Error::invalid)?;
Ok((parent, name.clone()))
}
}
impl FromStr for ResourceAddress {
type Err = Error;
fn from_str(value: &str) -> Result<Self, Error> {
let (path, reference) = match value.split_once('@') {
Some((path, reference)) => (path, Some(reference.parse()?)),
None => (value, None),
};
let segments = path
.split('/')
.map(NamespaceSegment::new)
.collect::<Result<Vec<_>, _>>()
.map_err(Error::invalid)?;
Ok(Self {
path: NamespacePath::new(segments).map_err(Error::invalid)?,
reference,
})
}
}
impl Display for ResourceAddress {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
for (index, segment) in self.path.segments().iter().enumerate() {
if index > 0 {
formatter.write_str("/")?;
}
segment.fmt(formatter)?;
}
if let Some(reference) = &self.reference {
write!(formatter, "@{reference}")?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::{ResourceAddress, ResourceRef};
#[test]
fn address_grammar_round_trips_and_rejects_ambiguous_refs() {
for value in [
"recordings",
"driving/frames@main",
"recordings@Dev_branch",
"recordings@v019a7c3e2f8d7b1e9f0a1b2c3d4e5f60",
"recordings@c019a7c3e2f8d7b1e9f0a1b2c3d4e5f60",
] {
let address: ResourceAddress = value.parse().unwrap();
assert_eq!(address.to_string(), value);
}
assert!(matches!(
"v2".parse::<ResourceRef>().unwrap(),
ResourceRef::Branch(_)
));
for value in [
"",
"a/",
"/a",
"a//b",
"a@",
"a@main@dev",
"a@main~1",
"a@v019A7C3E2F8D7B1E9F0A1B2C3D4E5F60",
"a@v00000000000000000000000000000000",
] {
assert!(value.parse::<ResourceAddress>().is_err(), "{value}");
}
}
}