subcog/storage/prompt/
filesystem.rs1use super::PromptStorage;
6use crate::current_timestamp;
7use crate::models::PromptTemplate;
8use crate::{Error, Result};
9use std::fs;
10use std::path::{Path, PathBuf};
11
12pub struct FilesystemPromptStorage {
16 base_path: PathBuf,
18}
19
20impl FilesystemPromptStorage {
21 pub fn new(base_path: impl Into<PathBuf>) -> Result<Self> {
31 let path = base_path.into();
32
33 fs::create_dir_all(&path).map_err(|e| Error::OperationFailed {
35 operation: "create_prompt_dir".to_string(),
36 cause: e.to_string(),
37 })?;
38
39 Ok(Self { base_path: path })
40 }
41
42 #[must_use]
46 pub fn default_user_path() -> Option<PathBuf> {
47 directories::BaseDirs::new()
48 .map(|d| d.home_dir().join(".config").join("subcog").join("prompts"))
49 }
50
51 #[must_use]
55 pub fn default_org_path(org: &str) -> Option<PathBuf> {
56 directories::BaseDirs::new().map(|d| {
57 d.home_dir()
58 .join(".config")
59 .join("subcog")
60 .join("orgs")
61 .join(org)
62 .join("prompts")
63 })
64 }
65
66 #[must_use]
68 pub fn base_path(&self) -> &Path {
69 &self.base_path
70 }
71
72 fn validate_prompt_name(name: &str) -> Result<()> {
78 if name.is_empty() {
79 return Err(Error::InvalidInput(
80 "Prompt name cannot be empty".to_string(),
81 ));
82 }
83
84 if name.contains('/') || name.contains('\\') {
86 return Err(Error::InvalidInput(
87 "Prompt name cannot contain path separators".to_string(),
88 ));
89 }
90
91 if name.contains("..") {
93 return Err(Error::InvalidInput(
94 "Prompt name cannot contain parent directory references".to_string(),
95 ));
96 }
97
98 if name.contains('\0') {
100 return Err(Error::InvalidInput(
101 "Prompt name cannot contain null bytes".to_string(),
102 ));
103 }
104
105 if name.starts_with('.') {
107 return Err(Error::InvalidInput(
108 "Prompt name cannot start with a dot".to_string(),
109 ));
110 }
111
112 Ok(())
113 }
114
115 fn prompt_path(&self, name: &str) -> Result<PathBuf> {
121 Self::validate_prompt_name(name)?;
122 Ok(self.base_path.join(format!("{name}.json")))
123 }
124
125 fn read_prompt_file(&self, path: &Path) -> Result<PromptTemplate> {
127 let content = fs::read_to_string(path).map_err(|e| Error::OperationFailed {
128 operation: "read_prompt_file".to_string(),
129 cause: e.to_string(),
130 })?;
131
132 serde_json::from_str(&content).map_err(|e| Error::OperationFailed {
133 operation: "parse_prompt_json".to_string(),
134 cause: e.to_string(),
135 })
136 }
137
138 fn write_prompt_file(&self, path: &Path, template: &PromptTemplate) -> Result<()> {
140 let content =
141 serde_json::to_string_pretty(template).map_err(|e| Error::OperationFailed {
142 operation: "serialize_prompt".to_string(),
143 cause: e.to_string(),
144 })?;
145
146 fs::write(path, content).map_err(|e| Error::OperationFailed {
147 operation: "write_prompt_file".to_string(),
148 cause: e.to_string(),
149 })
150 }
151}
152
153fn matches_glob(pattern: &str, text: &str) -> bool {
155 if !pattern.contains('*') {
156 return pattern == text;
157 }
158
159 let parts: Vec<&str> = pattern.split('*').collect();
160
161 if parts.is_empty() {
162 return true;
163 }
164
165 if !parts[0].is_empty() && !text.starts_with(parts[0]) {
167 return false;
168 }
169
170 let last = parts.last().unwrap_or(&"");
172 if !last.is_empty() && !text.ends_with(last) {
173 return false;
174 }
175
176 let mut remaining = text;
178 for part in &parts {
179 if part.is_empty() {
180 continue;
181 }
182 if let Some(pos) = remaining.find(part) {
183 remaining = &remaining[pos + part.len()..];
184 } else {
185 return false;
186 }
187 }
188
189 true
190}
191
192impl PromptStorage for FilesystemPromptStorage {
193 fn save(&self, template: &PromptTemplate) -> Result<String> {
194 let path = self.prompt_path(&template.name)?;
195
196 let mut template = template.clone();
198 let now = current_timestamp();
199 if template.created_at == 0 {
200 template.created_at = now;
201 }
202 template.updated_at = now;
203
204 self.write_prompt_file(&path, &template)?;
205
206 Ok(format!("prompt_fs_{}", template.name))
207 }
208
209 fn get(&self, name: &str) -> Result<Option<PromptTemplate>> {
210 let path = self.prompt_path(name)?;
211
212 if !path.exists() {
213 return Ok(None);
214 }
215
216 let template = self.read_prompt_file(&path)?;
217 Ok(Some(template))
218 }
219
220 fn list(
221 &self,
222 tags: Option<&[String]>,
223 name_pattern: Option<&str>,
224 ) -> Result<Vec<PromptTemplate>> {
225 let entries = fs::read_dir(&self.base_path).map_err(|e| Error::OperationFailed {
226 operation: "list_prompt_dir".to_string(),
227 cause: e.to_string(),
228 })?;
229
230 let mut results = Vec::new();
231
232 for entry in entries.flatten() {
233 let path = entry.path();
234
235 if path.extension().and_then(|e| e.to_str()) != Some("json") {
237 continue;
238 }
239
240 let template = match self.read_prompt_file(&path) {
242 Ok(t) => t,
243 Err(_) => continue,
244 };
245
246 let has_all_tags = tags.is_none_or(|required_tags| {
248 required_tags.iter().all(|rt| template.tags.contains(rt))
249 });
250 if !has_all_tags {
251 continue;
252 }
253
254 let matches_pattern =
256 name_pattern.is_none_or(|pattern| matches_glob(pattern, &template.name));
257 if !matches_pattern {
258 continue;
259 }
260
261 results.push(template);
262 }
263
264 results.sort_by(|a, b| {
266 b.usage_count
267 .cmp(&a.usage_count)
268 .then_with(|| a.name.cmp(&b.name))
269 });
270
271 Ok(results)
272 }
273
274 fn delete(&self, name: &str) -> Result<bool> {
275 let path = self.prompt_path(name)?;
276
277 if !path.exists() {
278 return Ok(false);
279 }
280
281 fs::remove_file(&path).map_err(|e| Error::OperationFailed {
282 operation: "delete_prompt_file".to_string(),
283 cause: e.to_string(),
284 })?;
285
286 Ok(true)
287 }
288
289 fn increment_usage(&self, name: &str) -> Result<u64> {
290 let path = self.prompt_path(name)?;
291
292 if !path.exists() {
293 return Err(Error::OperationFailed {
294 operation: "increment_usage".to_string(),
295 cause: format!("Prompt not found: {name}"),
296 });
297 }
298
299 let mut template = self.read_prompt_file(&path)?;
300 template.usage_count = template.usage_count.saturating_add(1);
301 template.updated_at = current_timestamp();
302
303 self.write_prompt_file(&path, &template)?;
304
305 Ok(template.usage_count)
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use tempfile::TempDir;
313
314 #[test]
315 fn test_filesystem_prompt_storage_creation() {
316 let dir = TempDir::new().unwrap();
317 let storage = FilesystemPromptStorage::new(dir.path()).unwrap();
318 assert_eq!(storage.base_path(), dir.path());
319 }
320
321 #[test]
322 fn test_save_and_get_prompt() {
323 let dir = TempDir::new().unwrap();
324 let storage = FilesystemPromptStorage::new(dir.path()).unwrap();
325
326 let template =
327 PromptTemplate::new("test-prompt", "Hello {{name}}!").with_description("A test prompt");
328
329 let id = storage.save(&template).unwrap();
330 assert!(id.contains("test-prompt"));
331
332 let retrieved = storage.get("test-prompt").unwrap();
333 assert!(retrieved.is_some());
334 let retrieved = retrieved.unwrap();
335 assert_eq!(retrieved.name, "test-prompt");
336 assert_eq!(retrieved.content, "Hello {{name}}!");
337 }
338
339 #[test]
340 fn test_list_prompts() {
341 let dir = TempDir::new().unwrap();
342 let storage = FilesystemPromptStorage::new(dir.path()).unwrap();
343
344 storage
345 .save(&PromptTemplate::new("alpha", "A").with_tags(vec!["tag1".to_string()]))
346 .unwrap();
347 storage
348 .save(
349 &PromptTemplate::new("beta", "B")
350 .with_tags(vec!["tag1".to_string(), "tag2".to_string()]),
351 )
352 .unwrap();
353 storage.save(&PromptTemplate::new("gamma", "C")).unwrap();
354
355 let all = storage.list(None, None).unwrap();
357 assert_eq!(all.len(), 3);
358
359 let with_tag1 = storage.list(Some(&["tag1".to_string()]), None).unwrap();
361 assert_eq!(with_tag1.len(), 2);
362
363 let alpha_pattern = storage.list(None, Some("a*")).unwrap();
365 assert_eq!(alpha_pattern.len(), 1);
366 assert_eq!(alpha_pattern[0].name, "alpha");
367 }
368
369 #[test]
370 fn test_delete_prompt() {
371 let dir = TempDir::new().unwrap();
372 let storage = FilesystemPromptStorage::new(dir.path()).unwrap();
373
374 storage
375 .save(&PromptTemplate::new("to-delete", "Content"))
376 .unwrap();
377
378 assert!(storage.get("to-delete").unwrap().is_some());
379 assert!(storage.delete("to-delete").unwrap());
380 assert!(storage.get("to-delete").unwrap().is_none());
381 assert!(!storage.delete("to-delete").unwrap()); }
383
384 #[test]
385 fn test_increment_usage() {
386 let dir = TempDir::new().unwrap();
387 let storage = FilesystemPromptStorage::new(dir.path()).unwrap();
388
389 storage
390 .save(&PromptTemplate::new("used-prompt", "Content"))
391 .unwrap();
392
393 let count1 = storage.increment_usage("used-prompt").unwrap();
394 assert_eq!(count1, 1);
395
396 let count2 = storage.increment_usage("used-prompt").unwrap();
397 assert_eq!(count2, 2);
398
399 let prompt = storage.get("used-prompt").unwrap().unwrap();
400 assert_eq!(prompt.usage_count, 2);
401 }
402
403 #[test]
404 fn test_default_user_path() {
405 let path = FilesystemPromptStorage::default_user_path();
406 if let Some(p) = path {
408 assert!(p.to_string_lossy().contains("subcog"));
409 assert!(p.to_string_lossy().ends_with("prompts"));
410 }
411 }
412
413 #[test]
414 fn test_default_org_path() {
415 let path = FilesystemPromptStorage::default_org_path("test-org");
416 if let Some(p) = path {
418 assert!(p.to_string_lossy().contains("subcog"));
419 assert!(p.to_string_lossy().contains("orgs"));
420 assert!(p.to_string_lossy().contains("test-org"));
421 assert!(p.to_string_lossy().ends_with("prompts"));
422 }
423 }
424
425 #[test]
426 fn test_matches_glob() {
427 assert!(matches_glob("test", "test"));
428 assert!(!matches_glob("test", "other"));
429
430 assert!(matches_glob("test-*", "test-prompt"));
431 assert!(!matches_glob("test-*", "other-prompt"));
432
433 assert!(matches_glob("*-prompt", "test-prompt"));
434 assert!(matches_glob("*test*", "my-test-prompt"));
435 }
436
437 #[test]
439 fn test_path_traversal_parent_directory() {
440 let dir = TempDir::new().unwrap();
441 let storage = FilesystemPromptStorage::new(dir.path()).unwrap();
442
443 let result = storage.get("foo..bar");
445 assert!(result.is_err());
446 let err = result.unwrap_err();
447 assert!(err.to_string().contains("parent directory"));
448
449 let result2 = storage.get("../../../etc/passwd");
451 assert!(result2.is_err());
452 }
453
454 #[test]
455 fn test_path_traversal_forward_slash() {
456 let dir = TempDir::new().unwrap();
457 let storage = FilesystemPromptStorage::new(dir.path()).unwrap();
458
459 let result = storage.get("foo/bar");
461 assert!(result.is_err());
462 let err = result.unwrap_err();
463 assert!(err.to_string().contains("path separators"));
464 }
465
466 #[test]
467 fn test_path_traversal_backslash() {
468 let dir = TempDir::new().unwrap();
469 let storage = FilesystemPromptStorage::new(dir.path()).unwrap();
470
471 let result = storage.get("foo\\bar");
473 assert!(result.is_err());
474 let err = result.unwrap_err();
475 assert!(err.to_string().contains("path separators"));
476 }
477
478 #[test]
479 fn test_path_traversal_null_byte() {
480 let dir = TempDir::new().unwrap();
481 let storage = FilesystemPromptStorage::new(dir.path()).unwrap();
482
483 let result = storage.get("foo\0bar");
485 assert!(result.is_err());
486 let err = result.unwrap_err();
487 assert!(err.to_string().contains("null bytes"));
488 }
489
490 #[test]
491 fn test_path_traversal_hidden_file() {
492 let dir = TempDir::new().unwrap();
493 let storage = FilesystemPromptStorage::new(dir.path()).unwrap();
494
495 let result = storage.get(".hidden");
497 assert!(result.is_err());
498 let err = result.unwrap_err();
499 assert!(err.to_string().contains("start with a dot"));
500 }
501
502 #[test]
503 fn test_path_traversal_empty_name() {
504 let dir = TempDir::new().unwrap();
505 let storage = FilesystemPromptStorage::new(dir.path()).unwrap();
506
507 let result = storage.get("");
509 assert!(result.is_err());
510 let err = result.unwrap_err();
511 assert!(err.to_string().contains("cannot be empty"));
512 }
513
514 #[test]
515 fn test_path_traversal_save_blocked() {
516 let dir = TempDir::new().unwrap();
517 let storage = FilesystemPromptStorage::new(dir.path()).unwrap();
518
519 let template = PromptTemplate::new("../evil", "Malicious content");
521 let result = storage.save(&template);
522 assert!(result.is_err());
523 }
524
525 #[test]
526 fn test_path_traversal_delete_blocked() {
527 let dir = TempDir::new().unwrap();
528 let storage = FilesystemPromptStorage::new(dir.path()).unwrap();
529
530 let result = storage.delete("../../../etc/passwd");
532 assert!(result.is_err());
533 }
534
535 #[test]
536 fn test_valid_prompt_name_works() {
537 let dir = TempDir::new().unwrap();
538 let storage = FilesystemPromptStorage::new(dir.path()).unwrap();
539
540 let template = PromptTemplate::new("valid-prompt-name", "Content");
542 let result = storage.save(&template);
543 assert!(result.is_ok());
544
545 let retrieved = storage.get("valid-prompt-name").unwrap();
546 assert!(retrieved.is_some());
547 }
548}