1use std::collections::HashMap;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
7#[cfg_attr(feature = "persistence", derive(serde::Serialize, serde::Deserialize))]
8pub struct Resource {
9 id: String,
11 resource_type: String,
13 name: Option<String>,
15 attributes: HashMap<String, String>,
17 path: Option<String>,
19}
20
21impl Resource {
22 pub fn new(id: impl Into<String>, resource_type: impl Into<String>) -> Self {
24 let id = id.into();
25 let resource_type = resource_type.into();
26
27 if id.contains("..") || id.contains('\0') {
29 panic!("Resource ID cannot contain path traversal sequences or null characters");
30 }
31
32 if resource_type.contains("..") || resource_type.contains('\0') {
34 panic!("Resource type cannot contain path traversal sequences or null characters");
35 }
36
37 Self {
38 id,
39 resource_type,
40 name: None,
41 attributes: HashMap::new(),
42 path: None,
43 }
44 }
45
46 pub fn id(&self) -> &str {
48 &self.id
49 }
50
51 pub fn resource_type(&self) -> &str {
53 &self.resource_type
54 }
55
56 pub fn with_name(mut self, name: impl Into<String>) -> Self {
58 self.name = Some(name.into());
59 self
60 }
61
62 pub fn name(&self) -> Option<&str> {
64 self.name.as_deref()
65 }
66
67 pub fn set_name(&mut self, name: impl Into<String>) {
69 self.name = Some(name.into());
70 }
71
72 pub fn with_path(mut self, path: impl Into<String>) -> Self {
74 self.path = Some(path.into());
75 self
76 }
77
78 pub fn path(&self) -> Option<&str> {
80 self.path.as_deref()
81 }
82
83 pub fn set_path(&mut self, path: impl Into<String>) {
85 self.path = Some(path.into());
86 }
87
88 pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
90 self.attributes.insert(key.into(), value.into());
91 self
92 }
93
94 pub fn set_attribute(&mut self, key: impl Into<String>, value: impl Into<String>) {
96 self.attributes.insert(key.into(), value.into());
97 }
98
99 pub fn attribute(&self, key: &str) -> Option<&str> {
101 self.attributes.get(key).map(|s| s.as_str())
102 }
103
104 pub fn attributes(&self) -> &HashMap<String, String> {
106 &self.attributes
107 }
108
109 pub fn remove_attribute(&mut self, key: &str) -> Option<String> {
111 self.attributes.remove(key)
112 }
113
114 pub fn has_attribute(&self, key: &str) -> bool {
116 self.attributes.contains_key(key)
117 }
118
119 pub fn effective_name(&self) -> &str {
121 self.name.as_deref().unwrap_or(&self.id)
122 }
123
124 pub fn matches_pattern(&self, pattern: &str) -> bool {
127 if pattern == "*" {
128 return true;
129 }
130
131 if pattern == self.id || pattern == self.resource_type {
133 return true;
134 }
135
136 if let Some(type_prefix) = pattern.strip_suffix("/*") {
138 if self.resource_type == type_prefix {
139 return true;
140 }
141 }
142
143 if let Some(resource_path) = &self.path {
145 if self.matches_path_pattern(resource_path, pattern) {
146 return true;
147 }
148 }
149
150 false
151 }
152
153 pub fn is_under_path(&self, parent_path: &str) -> bool {
155 if let Some(resource_path) = &self.path {
156 resource_path.starts_with(parent_path)
157 } else {
158 false
159 }
160 }
161
162 pub fn parent_path(&self) -> Option<String> {
164 self.path.as_ref().and_then(|p| {
165 p.rfind('/').map(|i| p[..i].to_string())
166 })
167 }
168
169 fn matches_path_pattern(&self, path: &str, pattern: &str) -> bool {
170 if pattern.contains('*') {
172 let parts: Vec<&str> = pattern.split('*').collect();
173 if parts.len() == 2 {
174 let prefix = parts[0];
175 let suffix = parts[1];
176 return path.starts_with(prefix) && path.ends_with(suffix);
177 }
178 }
179
180 path == pattern
181 }
182}
183
184impl std::fmt::Display for Resource {
185 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186 match (&self.name, &self.path) {
187 (Some(name), Some(path)) => write!(f, "{} ({}:{} at {})", name, self.resource_type, self.id, path),
188 (Some(name), None) => write!(f, "{} ({}:{})", name, self.resource_type, self.id),
189 (None, Some(path)) => write!(f, "{}:{} at {}", self.resource_type, self.id, path),
190 (None, None) => write!(f, "{}:{}", self.resource_type, self.id),
191 }
192 }
193}
194
195#[derive(Debug, Default)]
197pub struct ResourceBuilder {
198 id: Option<String>,
199 resource_type: Option<String>,
200 name: Option<String>,
201 path: Option<String>,
202 attributes: HashMap<String, String>,
203}
204
205impl ResourceBuilder {
206 pub fn new() -> Self {
208 Self::default()
209 }
210
211 pub fn id(mut self, id: impl Into<String>) -> Self {
213 self.id = Some(id.into());
214 self
215 }
216
217 pub fn resource_type(mut self, resource_type: impl Into<String>) -> Self {
219 self.resource_type = Some(resource_type.into());
220 self
221 }
222
223 pub fn name(mut self, name: impl Into<String>) -> Self {
225 self.name = Some(name.into());
226 self
227 }
228
229 pub fn path(mut self, path: impl Into<String>) -> Self {
231 self.path = Some(path.into());
232 self
233 }
234
235 pub fn attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
237 self.attributes.insert(key.into(), value.into());
238 self
239 }
240
241 pub fn build(self) -> Result<Resource, String> {
243 let id = self.id.ok_or("Resource ID is required")?;
244 let resource_type = self.resource_type.ok_or("Resource type is required")?;
245
246 let mut resource = Resource::new(id, resource_type);
247
248 if let Some(name) = self.name {
249 resource = resource.with_name(name);
250 }
251
252 if let Some(path) = self.path {
253 resource = resource.with_path(path);
254 }
255
256 for (key, value) in self.attributes {
257 resource = resource.with_attribute(key, value);
258 }
259
260 Ok(resource)
261 }
262}
263
264pub mod types {
266 use super::Resource;
267
268 pub fn document(id: impl Into<String>) -> Resource {
270 Resource::new(id, "document")
271 }
272
273 pub fn user(id: impl Into<String>) -> Resource {
275 Resource::new(id, "user")
276 }
277
278 pub fn project(id: impl Into<String>) -> Resource {
280 Resource::new(id, "project")
281 }
282
283 pub fn file(id: impl Into<String>) -> Resource {
285 Resource::new(id, "file")
286 }
287
288 pub fn database(id: impl Into<String>) -> Resource {
290 Resource::new(id, "database")
291 }
292
293 pub fn api_endpoint(id: impl Into<String>) -> Resource {
295 Resource::new(id, "api_endpoint")
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use super::types::*;
303
304 #[test]
305 fn test_resource_creation() {
306 let resource = Resource::new("doc123", "document")
307 .with_name("My Document")
308 .with_path("/projects/web-app/docs/readme.md")
309 .with_attribute("owner", "john@example.com")
310 .with_attribute("created", "2024-01-01");
311
312 assert_eq!(resource.id(), "doc123");
313 assert_eq!(resource.resource_type(), "document");
314 assert_eq!(resource.name(), Some("My Document"));
315 assert_eq!(resource.path(), Some("/projects/web-app/docs/readme.md"));
316 assert_eq!(resource.attribute("owner"), Some("john@example.com"));
317 assert_eq!(resource.effective_name(), "My Document");
318 }
319
320 #[test]
321 fn test_resource_pattern_matching() {
322 let resource = Resource::new("doc1", "document")
323 .with_path("/projects/web-app/docs/readme.md");
324
325 assert!(resource.matches_pattern("*"));
326 assert!(resource.matches_pattern("doc1"));
327 assert!(resource.matches_pattern("document"));
328 assert!(resource.matches_pattern("document/*"));
329 assert!(!resource.matches_pattern("user"));
330 assert!(!resource.matches_pattern("users/*"));
331 }
332
333 #[test]
334 fn test_resource_path_operations() {
335 let resource = Resource::new("doc1", "document")
336 .with_path("/projects/web-app/docs/readme.md");
337
338 assert!(resource.is_under_path("/projects"));
339 assert!(resource.is_under_path("/projects/web-app"));
340 assert!(!resource.is_under_path("/other"));
341
342 assert_eq!(resource.parent_path(), Some("/projects/web-app/docs".to_string()));
343 }
344
345 #[test]
346 fn test_resource_builder() {
347 let resource = ResourceBuilder::new()
348 .id("test-id")
349 .resource_type("test-type")
350 .name("Test Resource")
351 .path("/test/path")
352 .attribute("key", "value")
353 .build()
354 .unwrap();
355
356 assert_eq!(resource.id(), "test-id");
357 assert_eq!(resource.resource_type(), "test-type");
358 assert_eq!(resource.name(), Some("Test Resource"));
359 assert_eq!(resource.path(), Some("/test/path"));
360 assert_eq!(resource.attribute("key"), Some("value"));
361 }
362
363 #[test]
364 fn test_common_resource_types() {
365 let doc = document("doc1");
366 let user_res = user("user1");
367 let proj = project("proj1");
368
369 assert_eq!(doc.resource_type(), "document");
370 assert_eq!(user_res.resource_type(), "user");
371 assert_eq!(proj.resource_type(), "project");
372 }
373
374 #[test]
375 fn test_resource_effective_name() {
376 let named_resource = Resource::new("r1", "type").with_name("Named Resource");
377 let unnamed_resource = Resource::new("r2", "type");
378
379 assert_eq!(named_resource.effective_name(), "Named Resource");
380 assert_eq!(unnamed_resource.effective_name(), "r2");
381 }
382}