Skip to main content

rig_memory_policy/
scope.rs

1//! Backend-neutral helpers for matching and projecting memory scopes.
2//!
3//! Scopes are deliberately represented as normalized strings. Backends may
4//! store them in URI fields, metadata maps, tags, or sidecar indexes, but this
5//! crate only needs the policy-level question: does a frame belong to a scope,
6//! and what hierarchical path can a caller expose for provenance?
7
8/// A normalized memory scope used for isolation and retention decisions.
9///
10/// The value is slash-delimited text. Empty path segments are removed and
11/// leading/trailing slashes are ignored, so `"/tenant/a/"` and `"tenant/a"`
12/// normalize to the same scope.
13///
14/// # Example
15///
16/// ```
17/// use rig_memory_policy::Scope;
18///
19/// let scope = Scope::new("/tenant-a/project-1/");
20/// assert_eq!(scope.as_str(), "tenant-a/project-1");
21/// assert_eq!(scope.path(), vec!["tenant-a", "project-1"]);
22/// ```
23#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
24pub struct Scope(String);
25
26impl Scope {
27    /// Normalize `value` into a scope.
28    #[must_use]
29    pub fn new(value: impl AsRef<str>) -> Self {
30        Self(normalize_scope(value.as_ref()))
31    }
32
33    /// Return the normalized scope string.
34    #[must_use]
35    pub fn as_str(&self) -> &str {
36        &self.0
37    }
38
39    /// Return `true` when this scope has no path segments.
40    #[must_use]
41    pub fn is_empty(&self) -> bool {
42        self.0.is_empty()
43    }
44
45    /// Split the scope into normalized path segments.
46    #[must_use]
47    pub fn path(&self) -> Vec<&str> {
48        scope_path(&self.0)
49    }
50
51    /// Return `true` when `candidate` is exactly this scope after
52    /// normalization.
53    #[must_use]
54    pub fn matches(&self, candidate: Option<&str>) -> bool {
55        candidate
56            .map(|candidate| normalize_scope(candidate) == self.0)
57            .unwrap_or(false)
58    }
59
60    /// Return `true` when `candidate` is this scope or a descendant scope.
61    ///
62    /// For example, `tenant-a` contains `tenant-a/project-1`, but does not
63    /// contain `tenant-ab`.
64    #[must_use]
65    pub fn contains(&self, candidate: Option<&str>) -> bool {
66        let Some(candidate) = candidate else {
67            return false;
68        };
69        let candidate = normalize_scope(candidate);
70        if self.0.is_empty() {
71            return candidate.is_empty();
72        }
73        candidate == self.0
74            || candidate
75                .strip_prefix(self.0.as_str())
76                .map(|suffix| suffix.starts_with('/'))
77                .unwrap_or(false)
78    }
79}
80
81impl From<&str> for Scope {
82    fn from(value: &str) -> Self {
83        Self::new(value)
84    }
85}
86
87impl From<String> for Scope {
88    fn from(value: String) -> Self {
89        Self::new(value)
90    }
91}
92
93impl std::fmt::Display for Scope {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        f.write_str(&self.0)
96    }
97}
98
99/// Normalize a slash-delimited scope string.
100#[must_use]
101pub fn normalize_scope(scope: &str) -> String {
102    scope
103        .split('/')
104        .map(str::trim)
105        .filter(|segment| !segment.is_empty())
106        .collect::<Vec<_>>()
107        .join("/")
108}
109
110/// Split a slash-delimited scope string into normalized path segments.
111#[must_use]
112pub fn scope_path(scope: &str) -> Vec<&str> {
113    scope
114        .split('/')
115        .map(str::trim)
116        .filter(|segment| !segment.is_empty())
117        .collect()
118}
119
120/// Match an optional candidate scope against an optional required scope.
121///
122/// `None` requires an unscoped candidate. `Some(scope)` requires exact scope
123/// equality after normalization.
124#[must_use]
125pub fn scope_matches(required: Option<&str>, candidate: Option<&str>) -> bool {
126    match required {
127        Some(required) => Scope::new(required).matches(candidate),
128        None => candidate
129            .map(|candidate| normalize_scope(candidate).is_empty())
130            .unwrap_or(true),
131    }
132}
133
134#[cfg(test)]
135#[allow(clippy::unwrap_used, clippy::panic, clippy::indexing_slicing)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn normalizes_scope_segments() {
141        let scope = Scope::new(" /tenant-a//project-1/ ");
142        assert_eq!(scope.as_str(), "tenant-a/project-1");
143        assert_eq!(scope.path(), vec!["tenant-a", "project-1"]);
144    }
145
146    #[test]
147    fn exact_match_uses_normalized_values() {
148        let scope = Scope::new("tenant-a/project-1");
149        assert!(scope.matches(Some("/tenant-a/project-1/")));
150        assert!(!scope.matches(Some("tenant-a/project-2")));
151        assert!(!scope.matches(None));
152    }
153
154    #[test]
155    fn contains_accepts_descendants_only_on_segment_boundaries() {
156        let scope = Scope::new("tenant-a");
157        assert!(scope.contains(Some("tenant-a")));
158        assert!(scope.contains(Some("tenant-a/project-1")));
159        assert!(!scope.contains(Some("tenant-ab/project-1")));
160    }
161
162    #[test]
163    fn optional_scope_matching_treats_none_as_unscoped() {
164        assert!(scope_matches(None, None));
165        assert!(scope_matches(None, Some("/")));
166        assert!(!scope_matches(None, Some("tenant-a")));
167        assert!(scope_matches(Some("tenant-a"), Some("/tenant-a/")));
168    }
169}