Skip to main content

agentstategraph_core/
namespace.rs

1//! Namespace primitive — the storage-layer isolation boundary for branches.
2//!
3//! A namespace scopes a set of branches. All ref operations are keyed on
4//! `(namespace, branch_name)` at the storage layer, so two namespaces can
5//! hold branches with the same name without collision, and cross-namespace
6//! access is denied unless a policy explicitly permits it.
7//!
8//! The `"default"` namespace is the migration target for all pre-namespace
9//! branch data and the fallback for single-namespace deployments.
10
11use serde::{Deserialize, Serialize};
12use std::fmt;
13use std::str::FromStr;
14
15/// Maximum length (in bytes) of a namespace name.
16pub const MAX_NAMESPACE_LEN: usize = 64;
17
18/// A validated namespace identifier.
19///
20/// Valid names contain only ASCII alphanumerics, hyphens (`-`), and
21/// underscores (`_`), and are between 1 and 64 characters long.
22/// The name `"default"` is valid and is the conventional namespace for
23/// single-namespace deployments and migrated pre-namespace data.
24#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
25#[serde(transparent)]
26pub struct Namespace(String);
27
28impl Namespace {
29    /// The conventional default namespace used for migration and single-ns
30    /// deployments.
31    pub const DEFAULT: &'static str = "default";
32
33    /// Parse and validate a namespace name.
34    pub fn new(name: impl Into<String>) -> Result<Self, NamespaceError> {
35        let name = name.into();
36        if name.is_empty() {
37            return Err(NamespaceError::Empty);
38        }
39        if name.len() > MAX_NAMESPACE_LEN {
40            return Err(NamespaceError::TooLong(name.len()));
41        }
42        if !name
43            .chars()
44            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
45        {
46            return Err(NamespaceError::InvalidChars(name));
47        }
48        Ok(Namespace(name))
49    }
50
51    /// Return the `"default"` namespace. Equivalent to
52    /// `Namespace::new(Namespace::DEFAULT).unwrap()`.
53    pub fn default_ns() -> Self {
54        Namespace(Self::DEFAULT.to_string())
55    }
56
57    /// View the namespace name as a string slice.
58    pub fn as_str(&self) -> &str {
59        &self.0
60    }
61}
62
63impl fmt::Display for Namespace {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        f.write_str(&self.0)
66    }
67}
68
69impl AsRef<str> for Namespace {
70    fn as_ref(&self) -> &str {
71        &self.0
72    }
73}
74
75impl FromStr for Namespace {
76    type Err = NamespaceError;
77
78    fn from_str(s: &str) -> Result<Self, Self::Err> {
79        Namespace::new(s)
80    }
81}
82
83impl TryFrom<String> for Namespace {
84    type Error = NamespaceError;
85
86    fn try_from(s: String) -> Result<Self, Self::Error> {
87        Namespace::new(s)
88    }
89}
90
91impl TryFrom<&str> for Namespace {
92    type Error = NamespaceError;
93
94    fn try_from(s: &str) -> Result<Self, Self::Error> {
95        Namespace::new(s)
96    }
97}
98
99/// Errors produced by [`Namespace::new`].
100#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
101pub enum NamespaceError {
102    #[error("namespace name must not be empty")]
103    Empty,
104
105    #[error("namespace name is too long ({0} bytes, max {MAX_NAMESPACE_LEN})")]
106    TooLong(usize),
107
108    #[error(
109        "namespace name contains invalid characters: {0:?} \
110         (only ASCII alphanumerics, hyphens, and underscores are allowed)"
111    )]
112    InvalidChars(String),
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn valid_names() {
121        for name in ["default", "my-project", "proj_42", "a", "A1-b_C"] {
122            assert!(Namespace::new(name).is_ok(), "expected valid: {name}");
123        }
124    }
125
126    #[test]
127    fn invalid_names() {
128        assert_eq!(Namespace::new("").unwrap_err(), NamespaceError::Empty);
129        assert!(matches!(
130            Namespace::new("a".repeat(65)).unwrap_err(),
131            NamespaceError::TooLong(_)
132        ));
133        for bad in ["has space", "dot.name", "slash/name", "colon:name", "bang!"] {
134            assert!(
135                matches!(
136                    Namespace::new(bad).unwrap_err(),
137                    NamespaceError::InvalidChars(_)
138                ),
139                "expected invalid: {bad}"
140            );
141        }
142    }
143
144    #[test]
145    fn default_namespace_is_valid() {
146        let ns = Namespace::default_ns();
147        assert_eq!(ns.as_str(), "default");
148        assert_eq!(Namespace::new(Namespace::DEFAULT).unwrap(), ns);
149    }
150
151    #[test]
152    fn roundtrip_serde() {
153        let ns = Namespace::new("my-project").unwrap();
154        let json = serde_json::to_string(&ns).unwrap();
155        assert_eq!(json, r#""my-project""#);
156        let back: Namespace = serde_json::from_str(&json).unwrap();
157        assert_eq!(back, ns);
158    }
159}