1use crate::error::Error;
4use std::collections::HashMap;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
8#[cfg_attr(feature = "persistence", derive(serde::Serialize, serde::Deserialize))]
9pub struct Resource {
10 id: String,
12 resource_type: String,
14 name: Option<String>,
16 attributes: HashMap<String, String>,
18 path: Option<String>,
20}
21
22impl Resource {
23 pub fn new_checked(
30 id: impl Into<String>,
31 resource_type: impl Into<String>,
32 ) -> Result<Self, Error> {
33 let id = id.into();
34 let resource_type = resource_type.into();
35
36 if id.contains("..") || id.contains('\0') {
38 return Err(Error::ValidationError {
39 field: "id".to_string(),
40 reason: "Resource ID cannot contain path traversal sequences or null characters"
41 .to_string(),
42 invalid_value: Some(id),
43 });
44 }
45
46 if resource_type.contains("..") || resource_type.contains('\0') {
48 return Err(Error::ValidationError {
49 field: "resource_type".to_string(),
50 reason: "Resource type cannot contain path traversal sequences or null characters"
51 .to_string(),
52 invalid_value: Some(resource_type),
53 });
54 }
55
56 Ok(Self {
57 id,
58 resource_type,
59 name: None,
60 attributes: HashMap::new(),
61 path: None,
62 })
63 }
64
65 pub fn new(id: impl Into<String>, resource_type: impl Into<String>) -> Self {
72 match Self::new_checked(id, resource_type) {
73 Ok(resource) => resource,
74 Err(e) => panic!("Resource validation failed: {}", e),
75 }
76 }
77
78 pub fn id(&self) -> &str {
80 &self.id
81 }
82
83 pub fn resource_type(&self) -> &str {
85 &self.resource_type
86 }
87
88 pub fn with_name(mut self, name: impl Into<String>) -> Self {
90 self.name = Some(name.into());
91 self
92 }
93
94 pub fn name(&self) -> Option<&str> {
96 self.name.as_deref()
97 }
98
99 pub fn set_name(&mut self, name: impl Into<String>) {
101 self.name = Some(name.into());
102 }
103
104 pub fn with_path(mut self, path: impl Into<String>) -> Self {
106 self.path = Some(path.into());
107 self
108 }
109
110 pub fn path(&self) -> Option<&str> {
112 self.path.as_deref()
113 }
114
115 pub fn set_path(&mut self, path: impl Into<String>) {
117 self.path = Some(path.into());
118 }
119
120 pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
122 self.attributes.insert(key.into(), value.into());
123 self
124 }
125
126 pub fn set_attribute(&mut self, key: impl Into<String>, value: impl Into<String>) {
128 self.attributes.insert(key.into(), value.into());
129 }
130
131 pub fn attribute(&self, key: &str) -> Option<&str> {
133 self.attributes.get(key).map(|s| s.as_str())
134 }
135
136 pub fn attributes(&self) -> &HashMap<String, String> {
138 &self.attributes
139 }
140
141 pub fn remove_attribute(&mut self, key: &str) -> Option<String> {
143 self.attributes.remove(key)
144 }
145
146 pub fn has_attribute(&self, key: &str) -> bool {
148 self.attributes.contains_key(key)
149 }
150
151 pub fn effective_name(&self) -> &str {
153 self.name.as_deref().unwrap_or(&self.id)
154 }
155
156 pub fn matches_pattern(&self, pattern: &str) -> bool {
159 if pattern == "*" {
160 return true;
161 }
162
163 if pattern == self.id || pattern == self.resource_type {
165 return true;
166 }
167
168 if let Some(type_prefix) = pattern.strip_suffix("/*")
170 && self.resource_type == type_prefix
171 {
172 return true;
173 }
174
175 if let Some(resource_path) = &self.path
177 && self.matches_path_pattern(resource_path, pattern)
178 {
179 return true;
180 }
181
182 false
183 }
184
185 pub fn is_under_path(&self, parent_path: &str) -> bool {
187 if let Some(resource_path) = &self.path {
188 resource_path.starts_with(parent_path)
189 } else {
190 false
191 }
192 }
193
194 pub fn parent_path(&self) -> Option<String> {
196 self.path
197 .as_ref()
198 .and_then(|p| p.rfind('/').map(|i| p[..i].to_string()))
199 }
200
201 fn matches_path_pattern(&self, path: &str, pattern: &str) -> bool {
202 if pattern.contains('*') {
204 let parts: Vec<&str> = pattern.split('*').collect();
205 if parts.len() == 2 {
206 let prefix = parts[0];
207 let suffix = parts[1];
208 return path.starts_with(prefix) && path.ends_with(suffix);
209 }
210 }
211
212 path == pattern
213 }
214}
215
216impl std::fmt::Display for Resource {
217 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218 match (&self.name, &self.path) {
219 (Some(name), Some(path)) => write!(
220 f,
221 "{} ({}:{} at {})",
222 name, self.resource_type, self.id, path
223 ),
224 (Some(name), None) => write!(f, "{} ({}:{})", name, self.resource_type, self.id),
225 (None, Some(path)) => write!(f, "{}:{} at {}", self.resource_type, self.id, path),
226 (None, None) => write!(f, "{}:{}", self.resource_type, self.id),
227 }
228 }
229}
230
231#[derive(Debug, Default)]
233pub struct ResourceBuilder {
234 id: Option<String>,
235 resource_type: Option<String>,
236 name: Option<String>,
237 path: Option<String>,
238 attributes: HashMap<String, String>,
239}
240
241impl ResourceBuilder {
242 pub fn new() -> Self {
244 Self::default()
245 }
246
247 pub fn id(mut self, id: impl Into<String>) -> Self {
249 self.id = Some(id.into());
250 self
251 }
252
253 pub fn resource_type(mut self, resource_type: impl Into<String>) -> Self {
255 self.resource_type = Some(resource_type.into());
256 self
257 }
258
259 pub fn name(mut self, name: impl Into<String>) -> Self {
261 self.name = Some(name.into());
262 self
263 }
264
265 pub fn path(mut self, path: impl Into<String>) -> Self {
267 self.path = Some(path.into());
268 self
269 }
270
271 pub fn attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
273 self.attributes.insert(key.into(), value.into());
274 self
275 }
276
277 pub fn build(self) -> Result<Resource, String> {
279 let id = self.id.ok_or("Resource ID is required")?;
280 let resource_type = self.resource_type.ok_or("Resource type is required")?;
281
282 let mut resource = Resource::new(id, resource_type);
283
284 if let Some(name) = self.name {
285 resource = resource.with_name(name);
286 }
287
288 if let Some(path) = self.path {
289 resource = resource.with_path(path);
290 }
291
292 for (key, value) in self.attributes {
293 resource = resource.with_attribute(key, value);
294 }
295
296 Ok(resource)
297 }
298}
299
300pub mod types {
302 use super::Resource;
303
304 pub fn document(id: impl Into<String>) -> Resource {
306 Resource::new(id, "document")
307 }
308
309 pub fn user(id: impl Into<String>) -> Resource {
311 Resource::new(id, "user")
312 }
313
314 pub fn project(id: impl Into<String>) -> Resource {
316 Resource::new(id, "project")
317 }
318
319 pub fn file(id: impl Into<String>) -> Resource {
321 Resource::new(id, "file")
322 }
323
324 pub fn database(id: impl Into<String>) -> Resource {
326 Resource::new(id, "database")
327 }
328
329 pub fn api_endpoint(id: impl Into<String>) -> Resource {
331 Resource::new(id, "api_endpoint")
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use super::types::*;
338 use super::*;
339
340 #[test]
341 fn test_resource_creation() {
342 let resource = Resource::new("doc123", "document")
343 .with_name("My Document")
344 .with_path("/projects/web-app/docs/readme.md")
345 .with_attribute("owner", "john@example.com")
346 .with_attribute("created", "2024-01-01");
347
348 assert_eq!(resource.id(), "doc123");
349 assert_eq!(resource.resource_type(), "document");
350 assert_eq!(resource.name(), Some("My Document"));
351 assert_eq!(resource.path(), Some("/projects/web-app/docs/readme.md"));
352 assert_eq!(resource.attribute("owner"), Some("john@example.com"));
353 assert_eq!(resource.effective_name(), "My Document");
354 }
355
356 #[test]
357 fn test_resource_pattern_matching() {
358 let resource =
359 Resource::new("doc1", "document").with_path("/projects/web-app/docs/readme.md");
360
361 assert!(resource.matches_pattern("*"));
362 assert!(resource.matches_pattern("doc1"));
363 assert!(resource.matches_pattern("document"));
364 assert!(resource.matches_pattern("document/*"));
365 assert!(!resource.matches_pattern("user"));
366 assert!(!resource.matches_pattern("users/*"));
367 }
368
369 #[test]
370 fn test_resource_path_operations() {
371 let resource =
372 Resource::new("doc1", "document").with_path("/projects/web-app/docs/readme.md");
373
374 assert!(resource.is_under_path("/projects"));
375 assert!(resource.is_under_path("/projects/web-app"));
376 assert!(!resource.is_under_path("/other"));
377
378 assert_eq!(
379 resource.parent_path(),
380 Some("/projects/web-app/docs".to_string())
381 );
382 }
383
384 #[test]
385 fn test_resource_builder() {
386 let resource = ResourceBuilder::new()
387 .id("test-id")
388 .resource_type("test-type")
389 .name("Test Resource")
390 .path("/test/path")
391 .attribute("key", "value")
392 .build()
393 .unwrap();
394
395 assert_eq!(resource.id(), "test-id");
396 assert_eq!(resource.resource_type(), "test-type");
397 assert_eq!(resource.name(), Some("Test Resource"));
398 assert_eq!(resource.path(), Some("/test/path"));
399 assert_eq!(resource.attribute("key"), Some("value"));
400 }
401
402 #[test]
403 fn test_common_resource_types() {
404 let doc = document("doc1");
405 let user_res = user("user1");
406 let proj = project("proj1");
407
408 assert_eq!(doc.resource_type(), "document");
409 assert_eq!(user_res.resource_type(), "user");
410 assert_eq!(proj.resource_type(), "project");
411 }
412
413 #[test]
414 fn test_resource_effective_name() {
415 let named_resource = Resource::new("r1", "type").with_name("Named Resource");
416 let unnamed_resource = Resource::new("r2", "type");
417
418 assert_eq!(named_resource.effective_name(), "Named Resource");
419 assert_eq!(unnamed_resource.effective_name(), "r2");
420 }
421}