Skip to main content

agentstategraph_core/
namespace.rs

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