spiral-sdk 0.1.10

Rust SDK for Spiral resource and table APIs
Documentation
//! Resource paths qualified by a branch, project commit, or exact version.

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,
};

/// A branch name, project commit, or exact resource version.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub enum ResourceRef {
    /// A branch head, pinned on first use in a table session.
    Branch(ProjectBranchName),
    /// A project commit containing the resource's path and version.
    Commit(ProjectCommitId),
    /// An exact version of the resource identified in the default snapshot.
    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()),
        }
    }
}

/// A slash-separated resource path with an optional `@ref` suffix.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct ResourceAddress {
    path: NamespacePath,
    reference: Option<ResourceRef>,
}

impl ResourceAddress {
    /// Validated path without its reference suffix.
    pub fn path(&self) -> &NamespacePath {
        &self.path
    }

    /// Explicit reference, or None to use the session's default snapshot.
    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)),
        }
    }

    /// Namespace slot binding this path from the project root: leading segments form
    /// an implicit prefix, the last one is the bound name.
    #[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}");
        }
    }
}