rig_memory_policy/
scope.rs1#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
24pub struct Scope(String);
25
26impl Scope {
27 #[must_use]
29 pub fn new(value: impl AsRef<str>) -> Self {
30 Self(normalize_scope(value.as_ref()))
31 }
32
33 #[must_use]
35 pub fn as_str(&self) -> &str {
36 &self.0
37 }
38
39 #[must_use]
41 pub fn is_empty(&self) -> bool {
42 self.0.is_empty()
43 }
44
45 #[must_use]
47 pub fn path(&self) -> Vec<&str> {
48 scope_path(&self.0)
49 }
50
51 #[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 #[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#[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#[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#[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}