Skip to main content

subcog/models/
urn.rs

1//! URN (Uniform Resource Name) parsing and handling.
2//!
3//! Subcog uses URNs to identify and filter memories. The scheme is:
4//!
5//! ```text
6//! subcog://{domain}/{namespace}/{memory_id}
7//! ```
8//!
9//! Where:
10//! - `domain`: `project`, `user`, `org`, or `_` (wildcard)
11//! - `namespace`: `decisions`, `learnings`, `patterns`, etc., or `_` (wildcard)
12//! - `memory_id`: The specific memory ID (optional for filters)
13//!
14//! # Examples
15//!
16//! ```
17//! use subcog::models::Urn;
18//!
19//! // Specific memory lookup
20//! let urn = Urn::parse("subcog://project/patterns/abc123").unwrap();
21//! assert_eq!(urn.memory_id(), Some("abc123"));
22//!
23//! // Filter by namespace (any domain)
24//! let urn = Urn::parse("subcog://_/learnings").unwrap();
25//! assert!(urn.domain().is_wildcard());
26//! assert_eq!(urn.namespace_str(), Some("learnings"));
27//!
28//! // Filter by domain (any namespace)
29//! let urn = Urn::parse("subcog://project/_").unwrap();
30//! assert_eq!(urn.domain_str(), Some("project"));
31//! assert!(urn.namespace().is_wildcard());
32//! ```
33
34use crate::models::{Domain, Namespace};
35use crate::{Error, Result};
36use std::fmt;
37use std::str::FromStr;
38
39/// A parsed Subcog URN.
40///
41/// URNs can represent either a specific memory or a filter pattern.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Urn {
44    /// Domain component (project, user, org, or wildcard).
45    domain: UrnComponent,
46    /// Namespace component (decisions, learnings, etc., or wildcard).
47    namespace: UrnComponent,
48    /// Optional memory ID for specific lookups.
49    memory_id: Option<String>,
50    /// Original URN string for display.
51    original: String,
52}
53
54/// A component of a URN that can be a specific value or a wildcard.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum UrnComponent {
57    /// A specific value.
58    Value(String),
59    /// Wildcard (`_`) - matches any value.
60    Wildcard,
61}
62
63impl UrnComponent {
64    /// Returns `true` if this is a wildcard.
65    #[must_use]
66    pub const fn is_wildcard(&self) -> bool {
67        matches!(self, Self::Wildcard)
68    }
69
70    /// Returns the value if this is not a wildcard.
71    #[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    /// Parses a URN string.
82    ///
83    /// # Errors
84    ///
85    /// Returns an error if the string is not a valid Subcog URN.
86    ///
87    /// # Examples
88    ///
89    /// ```
90    /// use subcog::models::Urn;
91    ///
92    /// // Full URN with memory ID
93    /// let urn = Urn::parse("subcog://project/patterns/abc123")?;
94    ///
95    /// // Namespace filter
96    /// let urn = Urn::parse("subcog://_/learnings")?;
97    ///
98    /// // Domain filter
99    /// let urn = Urn::parse("subcog://user/_")?;
100    /// # Ok::<(), subcog::Error>(())
101    /// ```
102    pub fn parse(s: &str) -> Result<Self> {
103        let original = s.to_string();
104
105        // Must start with subcog://
106        let path = s
107            .strip_prefix("subcog://")
108            .ok_or_else(|| Error::InvalidInput(format!("URN must start with 'subcog://': {s}")))?;
109
110        // Split into components
111        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        // Parse domain (first component)
120        let domain = Self::parse_component(parts[0]);
121
122        // Parse namespace (second component, if present)
123        let namespace = if parts.len() > 1 {
124            Self::parse_component(parts[1])
125        } else {
126            UrnComponent::Wildcard
127        };
128
129        // Parse memory_id (third component, if present)
130        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    /// Parses a component, treating `_` or empty as wildcard.
145    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    /// Tries to parse a string as a URN, returning `None` if it's not a URN.
154    ///
155    /// This is useful for checking if a `memory_id` argument is a URN or a raw ID.
156    #[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    /// Extracts just the memory ID from a string that might be a URN.
166    ///
167    /// If the string is a URN, returns the `memory_id` component (last path segment).
168    /// If the string is not a URN, returns it as-is (it's already a raw ID).
169    #[must_use]
170    pub fn extract_memory_id(s: &str) -> &str {
171        if !s.starts_with("subcog://") {
172            return s;
173        }
174        // Extract the last path segment as the memory ID
175        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    /// Extracts just the memory ID from a string, returning owned String.
186    ///
187    /// If the string is a URN with a `memory_id`, returns that ID.
188    /// If the string is a URN without a `memory_id` (filter), returns `None`.
189    /// If the string is not a URN, returns it as-is (it's already a raw ID).
190    #[must_use]
191    pub fn extract_memory_id_owned(s: &str) -> Option<String> {
192        if s.starts_with("subcog://") {
193            // Parse as URN
194            Self::parse(s).ok().and_then(|urn| urn.memory_id)
195        } else {
196            // Raw ID
197            Some(s.to_string())
198        }
199    }
200
201    /// Returns `true` if this URN represents a specific memory (has a `memory_id`).
202    #[must_use]
203    pub const fn is_specific(&self) -> bool {
204        self.memory_id.is_some()
205    }
206
207    /// Returns `true` if this URN is a filter pattern (no `memory_id`, or has wildcards).
208    #[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    /// Returns the domain component.
214    #[must_use]
215    pub const fn domain(&self) -> &UrnComponent {
216        &self.domain
217    }
218
219    /// Returns the domain as a string, if not a wildcard.
220    #[must_use]
221    pub fn domain_str(&self) -> Option<&str> {
222        self.domain.as_str()
223    }
224
225    /// Returns the namespace component.
226    #[must_use]
227    pub const fn namespace(&self) -> &UrnComponent {
228        &self.namespace
229    }
230
231    /// Returns the namespace as a string, if not a wildcard.
232    #[must_use]
233    pub fn namespace_str(&self) -> Option<&str> {
234        self.namespace.as_str()
235    }
236
237    /// Returns the memory ID, if this URN specifies one.
238    #[must_use]
239    pub fn memory_id(&self) -> Option<&str> {
240        self.memory_id.as_deref()
241    }
242
243    /// Converts the domain component to a `Domain`, if not a wildcard.
244    ///
245    /// # Errors
246    ///
247    /// Returns an error if the domain string is invalid.
248    pub fn to_domain(&self) -> Result<Option<Domain>> {
249        match &self.domain {
250            UrnComponent::Wildcard => Ok(None),
251            UrnComponent::Value(s) => {
252                // Map domain strings to Domain enum
253                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    /// Converts the namespace component to a `Namespace`, if not a wildcard.
264    #[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    /// Returns the original URN string.
273    #[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}