1use crate::models::{Domain, Namespace};
35use crate::{Error, Result};
36use std::fmt;
37use std::str::FromStr;
38
39#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Urn {
44 domain: UrnComponent,
46 namespace: UrnComponent,
48 memory_id: Option<String>,
50 original: String,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum UrnComponent {
57 Value(String),
59 Wildcard,
61}
62
63impl UrnComponent {
64 #[must_use]
66 pub const fn is_wildcard(&self) -> bool {
67 matches!(self, Self::Wildcard)
68 }
69
70 #[must_use]
72 pub fn as_str(&self) -> Option<&str> {
73 match self {
74 Self::Value(s) => Some(s),
75 Self::Wildcard => None,
76 }
77 }
78}
79
80impl Urn {
81 pub fn parse(s: &str) -> Result<Self> {
103 let original = s.to_string();
104
105 let path = s
107 .strip_prefix("subcog://")
108 .ok_or_else(|| Error::InvalidInput(format!("URN must start with 'subcog://': {s}")))?;
109
110 let parts: Vec<&str> = path.split('/').collect();
112
113 if parts.is_empty() || parts.len() > 3 {
114 return Err(Error::InvalidInput(format!(
115 "URN must have 1-3 path components (domain/namespace/id): {s}"
116 )));
117 }
118
119 let domain = Self::parse_component(parts[0]);
121
122 let namespace = if parts.len() > 1 {
124 Self::parse_component(parts[1])
125 } else {
126 UrnComponent::Wildcard
127 };
128
129 let memory_id = if parts.len() > 2 && !parts[2].is_empty() && parts[2] != "_" {
131 Some(parts[2].to_string())
132 } else {
133 None
134 };
135
136 Ok(Self {
137 domain,
138 namespace,
139 memory_id,
140 original,
141 })
142 }
143
144 fn parse_component(s: &str) -> UrnComponent {
146 if s.is_empty() || s == "_" {
147 UrnComponent::Wildcard
148 } else {
149 UrnComponent::Value(s.to_string())
150 }
151 }
152
153 #[must_use]
157 pub fn try_parse(s: &str) -> Option<Self> {
158 if s.starts_with("subcog://") {
159 Self::parse(s).ok()
160 } else {
161 None
162 }
163 }
164
165 #[must_use]
170 pub fn extract_memory_id(s: &str) -> &str {
171 if !s.starts_with("subcog://") {
172 return s;
173 }
174 let Some(last_slash) = s.rfind('/') else {
176 return s;
177 };
178 let id = &s[last_slash + 1..];
179 if id.is_empty() || id == "_" {
180 return s;
181 }
182 id
183 }
184
185 #[must_use]
191 pub fn extract_memory_id_owned(s: &str) -> Option<String> {
192 if s.starts_with("subcog://") {
193 Self::parse(s).ok().and_then(|urn| urn.memory_id)
195 } else {
196 Some(s.to_string())
198 }
199 }
200
201 #[must_use]
203 pub const fn is_specific(&self) -> bool {
204 self.memory_id.is_some()
205 }
206
207 #[must_use]
209 pub const fn is_filter(&self) -> bool {
210 self.memory_id.is_none() || self.domain.is_wildcard() || self.namespace.is_wildcard()
211 }
212
213 #[must_use]
215 pub const fn domain(&self) -> &UrnComponent {
216 &self.domain
217 }
218
219 #[must_use]
221 pub fn domain_str(&self) -> Option<&str> {
222 self.domain.as_str()
223 }
224
225 #[must_use]
227 pub const fn namespace(&self) -> &UrnComponent {
228 &self.namespace
229 }
230
231 #[must_use]
233 pub fn namespace_str(&self) -> Option<&str> {
234 self.namespace.as_str()
235 }
236
237 #[must_use]
239 pub fn memory_id(&self) -> Option<&str> {
240 self.memory_id.as_deref()
241 }
242
243 pub fn to_domain(&self) -> Result<Option<Domain>> {
249 match &self.domain {
250 UrnComponent::Wildcard => Ok(None),
251 UrnComponent::Value(s) => {
252 match s.as_str() {
254 "project" => Ok(Some(Domain::default_for_context())),
255 "user" => Ok(Some(Domain::for_user())),
256 "org" => Ok(Some(Domain::for_org())),
257 _ => Err(Error::InvalidInput(format!("Unknown domain: {s}"))),
258 }
259 },
260 }
261 }
262
263 #[must_use]
265 pub fn to_namespace(&self) -> Option<Namespace> {
266 match &self.namespace {
267 UrnComponent::Wildcard => None,
268 UrnComponent::Value(s) => Namespace::from_str(s).ok(),
269 }
270 }
271
272 #[must_use]
274 pub fn as_str(&self) -> &str {
275 &self.original
276 }
277}
278
279impl fmt::Display for Urn {
280 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
281 write!(f, "{}", self.original)
282 }
283}
284
285impl FromStr for Urn {
286 type Err = Error;
287
288 fn from_str(s: &str) -> Result<Self> {
289 Self::parse(s)
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn test_parse_full_urn() {
299 let urn = Urn::parse("subcog://project/patterns/abc123").unwrap();
300 assert_eq!(urn.domain_str(), Some("project"));
301 assert_eq!(urn.namespace_str(), Some("patterns"));
302 assert_eq!(urn.memory_id(), Some("abc123"));
303 assert!(urn.is_specific());
304 assert!(!urn.domain().is_wildcard());
305 assert!(!urn.namespace().is_wildcard());
306 }
307
308 #[test]
309 fn test_parse_namespace_filter() {
310 let urn = Urn::parse("subcog://_/learnings").unwrap();
311 assert!(urn.domain().is_wildcard());
312 assert_eq!(urn.namespace_str(), Some("learnings"));
313 assert!(urn.memory_id().is_none());
314 assert!(urn.is_filter());
315 }
316
317 #[test]
318 fn test_parse_domain_filter() {
319 let urn = Urn::parse("subcog://project/_").unwrap();
320 assert_eq!(urn.domain_str(), Some("project"));
321 assert!(urn.namespace().is_wildcard());
322 assert!(urn.memory_id().is_none());
323 assert!(urn.is_filter());
324 }
325
326 #[test]
327 fn test_parse_user_decisions() {
328 let urn = Urn::parse("subcog://user/decisions/_").unwrap();
329 assert_eq!(urn.domain_str(), Some("user"));
330 assert_eq!(urn.namespace_str(), Some("decisions"));
331 assert!(urn.memory_id().is_none());
332 assert!(urn.is_filter());
333 }
334
335 #[test]
336 fn test_parse_all_wildcard() {
337 let urn = Urn::parse("subcog://_/_").unwrap();
338 assert!(urn.domain().is_wildcard());
339 assert!(urn.namespace().is_wildcard());
340 assert!(urn.memory_id().is_none());
341 assert!(urn.is_filter());
342 }
343
344 #[test]
345 fn test_extract_memory_id_from_urn() {
346 assert_eq!(
347 Urn::extract_memory_id("subcog://project/patterns/abc123"),
348 "abc123"
349 );
350 }
351
352 #[test]
353 fn test_extract_memory_id_raw() {
354 assert_eq!(Urn::extract_memory_id("abc123"), "abc123");
355 }
356
357 #[test]
358 fn test_extract_memory_id_owned_from_urn() {
359 assert_eq!(
360 Urn::extract_memory_id_owned("subcog://project/patterns/abc123"),
361 Some("abc123".to_string())
362 );
363 }
364
365 #[test]
366 fn test_extract_memory_id_owned_filter() {
367 assert_eq!(Urn::extract_memory_id_owned("subcog://_/learnings"), None);
368 }
369
370 #[test]
371 fn test_extract_memory_id_owned_raw() {
372 assert_eq!(
373 Urn::extract_memory_id_owned("abc123"),
374 Some("abc123".to_string())
375 );
376 }
377
378 #[test]
379 fn test_try_parse_urn() {
380 assert!(Urn::try_parse("subcog://project/patterns/abc").is_some());
381 assert!(Urn::try_parse("abc123").is_none());
382 assert!(Urn::try_parse("not-a-urn").is_none());
383 }
384
385 #[test]
386 fn test_invalid_urn_no_prefix() {
387 assert!(Urn::parse("project/patterns/abc").is_err());
388 }
389
390 #[test]
391 fn test_to_namespace() {
392 let urn = Urn::parse("subcog://project/decisions/abc").unwrap();
393 assert_eq!(urn.to_namespace(), Some(Namespace::Decisions));
394
395 let urn = Urn::parse("subcog://project/_/abc").unwrap();
396 assert!(urn.to_namespace().is_none());
397 }
398
399 #[test]
400 fn test_display() {
401 let urn = Urn::parse("subcog://project/patterns/abc123").unwrap();
402 assert_eq!(urn.to_string(), "subcog://project/patterns/abc123");
403 }
404}