Skip to main content

spiral/
address.rs

1//! Resource paths qualified by a branch, project commit, or exact version.
2
3use 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/// A branch name, project commit, or exact resource version.
16#[derive(Clone, Debug, Eq, Hash, PartialEq)]
17pub enum ResourceRef {
18    /// A branch head, pinned on first use in a table session.
19    Branch(ProjectBranchName),
20    /// A project commit containing the resource's path and version.
21    Commit(ProjectCommitId),
22    /// An exact version of the resource identified in the default snapshot.
23    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/// A slash-separated resource path with an optional `@ref` suffix.
71#[derive(Clone, Debug, Eq, Hash, PartialEq)]
72pub struct ResourceAddress {
73    path: NamespacePath,
74    reference: Option<ResourceRef>,
75}
76
77impl ResourceAddress {
78    /// Validated path without its reference suffix.
79    pub fn path(&self) -> &NamespacePath {
80        &self.path
81    }
82
83    /// Explicit reference, or None to use the session's default snapshot.
84    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    /// Namespace slot binding this path from the project root: leading segments form
97    /// an implicit prefix, the last one is the bound name.
98    #[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}