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 && self.resource_type == type_prefix
139 {
140 return true;
141 }
142
143 if let Some(resource_path) = &self.path
145 && self.matches_path_pattern(resource_path, pattern)
146 {
147 return true;
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
165 .as_ref()
166 .and_then(|p| p.rfind('/').map(|i| p[..i].to_string()))
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!(
188 f,
189 "{} ({}:{} at {})",
190 name, self.resource_type, self.id, path
191 ),
192 (Some(name), None) => write!(f, "{} ({}:{})", name, self.resource_type, self.id),
193 (None, Some(path)) => write!(f, "{}:{} at {}", self.resource_type, self.id, path),
194 (None, None) => write!(f, "{}:{}", self.resource_type, self.id),
195 }
196 }
197}
198
199#[derive(Debug, Default)]
201pub struct ResourceBuilder {
202 id: Option<String>,
203 resource_type: Option<String>,
204 name: Option<String>,
205 path: Option<String>,
206 attributes: HashMap<String, String>,
207}
208
209impl ResourceBuilder {
210 pub fn new() -> Self {
212 Self::default()
213 }
214
215 pub fn id(mut self, id: impl Into<String>) -> Self {
217 self.id = Some(id.into());
218 self
219 }
220
221 pub fn resource_type(mut self, resource_type: impl Into<String>) -> Self {
223 self.resource_type = Some(resource_type.into());
224 self
225 }
226
227 pub fn name(mut self, name: impl Into<String>) -> Self {
229 self.name = Some(name.into());
230 self
231 }
232
233 pub fn path(mut self, path: impl Into<String>) -> Self {
235 self.path = Some(path.into());
236 self
237 }
238
239 pub fn attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
241 self.attributes.insert(key.into(), value.into());
242 self
243 }
244
245 pub fn build(self) -> Result<Resource, String> {
247 let id = self.id.ok_or("Resource ID is required")?;
248 let resource_type = self.resource_type.ok_or("Resource type is required")?;
249
250 let mut resource = Resource::new(id, resource_type);
251
252 if let Some(name) = self.name {
253 resource = resource.with_name(name);
254 }
255
256 if let Some(path) = self.path {
257 resource = resource.with_path(path);
258 }
259
260 for (key, value) in self.attributes {
261 resource = resource.with_attribute(key, value);
262 }
263
264 Ok(resource)
265 }
266}
267
268pub mod types {
270 use super::Resource;
271
272 pub fn document(id: impl Into<String>) -> Resource {
274 Resource::new(id, "document")
275 }
276
277 pub fn user(id: impl Into<String>) -> Resource {
279 Resource::new(id, "user")
280 }
281
282 pub fn project(id: impl Into<String>) -> Resource {
284 Resource::new(id, "project")
285 }
286
287 pub fn file(id: impl Into<String>) -> Resource {
289 Resource::new(id, "file")
290 }
291
292 pub fn database(id: impl Into<String>) -> Resource {
294 Resource::new(id, "database")
295 }
296
297 pub fn api_endpoint(id: impl Into<String>) -> Resource {
299 Resource::new(id, "api_endpoint")
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::types::*;
306 use super::*;
307
308 #[test]
309 fn test_resource_creation() {
310 let resource = Resource::new("doc123", "document")
311 .with_name("My Document")
312 .with_path("/projects/web-app/docs/readme.md")
313 .with_attribute("owner", "john@example.com")
314 .with_attribute("created", "2024-01-01");
315
316 assert_eq!(resource.id(), "doc123");
317 assert_eq!(resource.resource_type(), "document");
318 assert_eq!(resource.name(), Some("My Document"));
319 assert_eq!(resource.path(), Some("/projects/web-app/docs/readme.md"));
320 assert_eq!(resource.attribute("owner"), Some("john@example.com"));
321 assert_eq!(resource.effective_name(), "My Document");
322 }
323
324 #[test]
325 fn test_resource_pattern_matching() {
326 let resource =
327 Resource::new("doc1", "document").with_path("/projects/web-app/docs/readme.md");
328
329 assert!(resource.matches_pattern("*"));
330 assert!(resource.matches_pattern("doc1"));
331 assert!(resource.matches_pattern("document"));
332 assert!(resource.matches_pattern("document/*"));
333 assert!(!resource.matches_pattern("user"));
334 assert!(!resource.matches_pattern("users/*"));
335 }
336
337 #[test]
338 fn test_resource_path_operations() {
339 let resource =
340 Resource::new("doc1", "document").with_path("/projects/web-app/docs/readme.md");
341
342 assert!(resource.is_under_path("/projects"));
343 assert!(resource.is_under_path("/projects/web-app"));
344 assert!(!resource.is_under_path("/other"));
345
346 assert_eq!(
347 resource.parent_path(),
348 Some("/projects/web-app/docs".to_string())
349 );
350 }
351
352 #[test]
353 fn test_resource_builder() {
354 let resource = ResourceBuilder::new()
355 .id("test-id")
356 .resource_type("test-type")
357 .name("Test Resource")
358 .path("/test/path")
359 .attribute("key", "value")
360 .build()
361 .unwrap();
362
363 assert_eq!(resource.id(), "test-id");
364 assert_eq!(resource.resource_type(), "test-type");
365 assert_eq!(resource.name(), Some("Test Resource"));
366 assert_eq!(resource.path(), Some("/test/path"));
367 assert_eq!(resource.attribute("key"), Some("value"));
368 }
369
370 #[test]
371 fn test_common_resource_types() {
372 let doc = document("doc1");
373 let user_res = user("user1");
374 let proj = project("proj1");
375
376 assert_eq!(doc.resource_type(), "document");
377 assert_eq!(user_res.resource_type(), "user");
378 assert_eq!(proj.resource_type(), "project");
379 }
380
381 #[test]
382 fn test_resource_effective_name() {
383 let named_resource = Resource::new("r1", "type").with_name("Named Resource");
384 let unnamed_resource = Resource::new("r2", "type");
385
386 assert_eq!(named_resource.effective_name(), "Named Resource");
387 assert_eq!(unnamed_resource.effective_name(), "r2");
388 }
389}