1use std::fmt::{self, Display, Formatter};
4use std::str::FromStr;
5
6use uuid::Uuid;
7
8use crate::Error;
9#[cfg(feature = "tables")]
10use crate::catalog_types::NamespaceParent;
11use crate::catalog_types::{
12 NamespacePath, NamespaceSegment, ProjectBranchName, ProjectCommitId, VersionId,
13};
14
15#[derive(Clone, Debug, Eq, Hash, PartialEq)]
17pub enum ResourceRef {
18 Branch(ProjectBranchName),
20 Commit(ProjectCommitId),
22 Version(VersionId),
24}
25
26impl FromStr for ResourceRef {
27 type Err = Error;
28
29 fn from_str(value: &str) -> Result<Self, Error> {
30 if value.len() == 33 && (value.starts_with('v') || value.starts_with('c')) {
31 let hex = &value[1..];
32 if !hex
33 .bytes()
34 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
35 {
36 return Err(Error::invalid(
37 "version and commit refs require 32 lowercase hex digits",
38 ));
39 }
40 let id = Uuid::parse_str(hex).map_err(Error::invalid)?;
41 return if value.starts_with('v') {
42 Ok(Self::Version(VersionId::new(id).map_err(Error::invalid)?))
43 } else {
44 Ok(Self::Commit(
45 ProjectCommitId::new(id).map_err(Error::invalid)?,
46 ))
47 };
48 }
49 if value.contains(['@', '~']) {
50 return Err(Error::invalid(
51 "refs cannot contain `@` or ancestry expressions",
52 ));
53 }
54 Ok(Self::Branch(
55 ProjectBranchName::new(value).map_err(Error::invalid)?,
56 ))
57 }
58}
59
60impl Display for ResourceRef {
61 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
62 match self {
63 Self::Branch(branch) => branch.fmt(formatter),
64 Self::Commit(commit) => write!(formatter, "c{}", commit.as_uuid().simple()),
65 Self::Version(version) => write!(formatter, "v{}", version.as_uuid().simple()),
66 }
67 }
68}
69
70#[derive(Clone, Debug, Eq, Hash, PartialEq)]
72pub struct ResourceAddress {
73 path: NamespacePath,
74 reference: Option<ResourceRef>,
75}
76
77impl ResourceAddress {
78 pub fn path(&self) -> &NamespacePath {
80 &self.path
81 }
82
83 pub fn reference(&self) -> Option<&ResourceRef> {
85 self.reference.as_ref()
86 }
87
88 #[cfg(feature = "tables")]
89 pub(crate) fn at_version(&self, version: VersionId) -> Self {
90 Self {
91 path: self.path.clone(),
92 reference: Some(ResourceRef::Version(version)),
93 }
94 }
95
96 #[cfg(feature = "tables")]
99 pub(crate) fn root_binding(&self) -> Result<(NamespaceParent, NamespaceSegment), Error> {
100 let (name, prefix) = self
101 .path
102 .segments()
103 .split_last()
104 .expect("namespace paths are nonempty");
105 let parent = prefix
106 .iter()
107 .try_fold(NamespaceParent::project_root(), |parent, segment| {
108 parent.child_prefix(segment.clone())
109 })
110 .map_err(Error::invalid)?;
111 Ok((parent, name.clone()))
112 }
113}
114
115impl FromStr for ResourceAddress {
116 type Err = Error;
117
118 fn from_str(value: &str) -> Result<Self, Error> {
119 let (path, reference) = match value.split_once('@') {
120 Some((path, reference)) => (path, Some(reference.parse()?)),
121 None => (value, None),
122 };
123 let segments = path
124 .split('/')
125 .map(NamespaceSegment::new)
126 .collect::<Result<Vec<_>, _>>()
127 .map_err(Error::invalid)?;
128 Ok(Self {
129 path: NamespacePath::new(segments).map_err(Error::invalid)?,
130 reference,
131 })
132 }
133}
134
135impl Display for ResourceAddress {
136 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
137 for (index, segment) in self.path.segments().iter().enumerate() {
138 if index > 0 {
139 formatter.write_str("/")?;
140 }
141 segment.fmt(formatter)?;
142 }
143 if let Some(reference) = &self.reference {
144 write!(formatter, "@{reference}")?;
145 }
146 Ok(())
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::{ResourceAddress, ResourceRef};
153
154 #[test]
155 fn address_grammar_round_trips_and_rejects_ambiguous_refs() {
156 for value in [
157 "recordings",
158 "driving/frames@main",
159 "recordings@Dev_branch",
160 "recordings@v019a7c3e2f8d7b1e9f0a1b2c3d4e5f60",
161 "recordings@c019a7c3e2f8d7b1e9f0a1b2c3d4e5f60",
162 ] {
163 let address: ResourceAddress = value.parse().unwrap();
164 assert_eq!(address.to_string(), value);
165 }
166 assert!(matches!(
167 "v2".parse::<ResourceRef>().unwrap(),
168 ResourceRef::Branch(_)
169 ));
170 for value in [
171 "",
172 "a/",
173 "/a",
174 "a//b",
175 "a@",
176 "a@main@dev",
177 "a@main~1",
178 "a@v019A7C3E2F8D7B1E9F0A1B2C3D4E5F60",
179 "a@v00000000000000000000000000000000",
180 ] {
181 assert!(value.parse::<ResourceAddress>().is_err(), "{value}");
182 }
183 }
184}