1use crate::error::{AreevError, Result};
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum NsScope {
30 Exact(String),
32 Prefix {
35 base: String,
37 sep: char,
39 },
40}
41
42impl NsScope {
43 pub fn parse(value: &str) -> Result<NsScope> {
47 if !value.contains('*') {
48 return Ok(NsScope::Exact(value.to_string()));
49 }
50 let Some(head) = value.strip_suffix('*') else {
51 return Err(AreevError::Validation(format!(
52 "namespace pattern \"{value}\": '*' is only valid as the trailing character \
53 (e.g. \"org.*\")"
54 )));
55 };
56 if head.contains('*') {
57 return Err(AreevError::Validation(format!(
58 "namespace pattern \"{value}\": only one '*' is allowed, as the trailing \
59 character (e.g. \"org.*\")"
60 )));
61 }
62 let Some(sep) = head.chars().last() else {
63 return Err(AreevError::Validation(
64 "namespace pattern \"*\": a prefix scope needs a base namespace \
65 (e.g. \"org.*\"); \"every namespace\" is not a recall scope"
66 .into(),
67 ));
68 };
69 if sep.is_alphanumeric() {
70 return Err(AreevError::Validation(format!(
71 "namespace pattern \"{value}\": '*' must follow a separator — write \
72 \"{head}.*\" to select \"{head}\" and its descendants ({head}.x, {head}.y.z); \
73 \"{value}\" would ambiguously match unrelated names sharing the spelling"
74 )));
75 }
76 let base: String = head[..head.len() - sep.len_utf8()].to_string();
77 if base.is_empty() {
78 return Err(AreevError::Validation(format!(
79 "namespace pattern \"{value}\": a prefix scope needs a base namespace before \
80 the separator (e.g. \"org{sep}*\")"
81 )));
82 }
83 Ok(NsScope::Prefix { base, sep })
84 }
85
86 #[inline]
89 pub fn is_pattern(value: &str) -> bool {
90 value.contains('*')
91 }
92
93 pub fn matches(&self, ns: &str) -> bool {
96 match self {
97 NsScope::Exact(e) => ns == e,
98 NsScope::Prefix { base, sep } => {
99 ns == base
100 || (ns.len() > base.len()
101 && ns.starts_with(base.as_str())
102 && ns[base.len()..].starts_with(*sep))
103 }
104 }
105 }
106}
107
108pub fn require_exact_ns(what: &str, ns: &str) -> Result<()> {
113 if NsScope::is_pattern(ns) {
114 return Err(AreevError::Validation(format!(
115 "{what} takes an exact namespace, not a pattern (got \"{ns}\"): '*' is reserved \
116 for read scoping (e.g. RECALL … WHERE namespace = \"org.*\")"
117 )));
118 }
119 Ok(())
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 #[test]
127 fn exact_when_no_star() {
128 assert_eq!(NsScope::parse("org").unwrap(), NsScope::Exact("org".into()));
129 assert_eq!(
130 NsScope::parse("org.sales").unwrap(),
131 NsScope::Exact("org.sales".into())
132 );
133 assert_eq!(NsScope::parse("").unwrap(), NsScope::Exact("".into()));
134 }
135
136 #[test]
137 fn prefix_forms_parse() {
138 assert_eq!(
139 NsScope::parse("org.*").unwrap(),
140 NsScope::Prefix { base: "org".into(), sep: '.' }
141 );
142 assert_eq!(
143 NsScope::parse("agent:*").unwrap(),
144 NsScope::Prefix { base: "agent".into(), sep: ':' }
145 );
146 assert_eq!(
147 NsScope::parse("org.sales.*").unwrap(),
148 NsScope::Prefix { base: "org.sales".into(), sep: '.' }
149 );
150 assert_eq!(
153 NsScope::parse("areev-*").unwrap(),
154 NsScope::Prefix { base: "areev".into(), sep: '-' }
155 );
156 }
157
158 #[test]
159 fn malformed_patterns_refuse() {
160 for bad in ["*", "org*", "*.org", "org.*x", "o*.sales.*", "**", "org.**"] {
161 let err = NsScope::parse(bad).unwrap_err();
162 assert!(
163 matches!(err, AreevError::Validation(_)),
164 "{bad} should be a validation error, got {err:?}"
165 );
166 }
167 }
168
169 #[test]
170 fn separator_only_pattern_needs_a_base() {
171 assert!(NsScope::parse(".*").is_err());
172 assert!(NsScope::parse(":*").is_err());
173 }
174
175 #[test]
176 fn matches_parent_and_descendants_only() {
177 let s = NsScope::parse("org.*").unwrap();
178 assert!(s.matches("org"), "parent is included");
179 assert!(s.matches("org.sales"));
180 assert!(s.matches("org.sales.emea"));
181 assert!(!s.matches("organization"), "separator required");
182 assert!(!s.matches("org:x"), "the caller chose '.' as the hierarchy");
183 assert!(!s.matches("orgs"));
184 assert!(!s.matches(""));
185 assert!(!s.matches("xorg.sales"));
186 }
187
188 #[test]
189 fn matches_with_colon_separator() {
190 let s = NsScope::parse("agent:*").unwrap();
191 assert!(s.matches("agent"));
192 assert!(s.matches("agent:authz"));
193 assert!(!s.matches("agent.authz"));
194 assert!(!s.matches("agents"));
195 }
196
197 #[test]
198 fn unicode_separator_boundary_is_char_correct() {
199 let s = NsScope::parse("org→*").unwrap();
201 assert_eq!(s, NsScope::Prefix { base: "org".into(), sep: '→' });
202 assert!(s.matches("org"));
203 assert!(s.matches("org→x"));
204 assert!(!s.matches("org.x"));
205 }
206
207 #[test]
208 fn exact_matches_exactly() {
209 let s = NsScope::parse("org").unwrap();
210 assert!(s.matches("org"));
211 assert!(!s.matches("org.sales"));
212 assert!(!s.matches("or"));
213 }
214
215 #[test]
216 fn require_exact_refuses_patterns() {
217 assert!(require_exact_ns("latest", "org").is_ok());
218 let err = require_exact_ns("PURGE", "org.*").unwrap_err();
219 assert!(err.to_string().starts_with("VAL-E001"), "{err}");
220 assert!(require_exact_ns("forget_subject", "o*rg").is_err());
221 }
222}