1use std::collections::HashMap;
23use std::fs;
24use std::io::{self, Write};
25use std::path::{Path, PathBuf};
26
27use chrono::{DateTime, Utc};
28use serde::{Deserialize, Serialize};
29
30use crate::context::ExecutionContext;
31use crate::ContextError;
32
33const DEFAULT_BACKUP_COUNT: usize = 5;
35
36pub struct ContextStorage {
38 base_dir: PathBuf,
40 backup_count: usize,
42}
43
44impl ContextStorage {
45 pub fn new() -> Result<Self, ContextError> {
49 let base_dir = dirs::home_dir()
50 .ok_or_else(|| ContextError::Io(io::Error::new(
51 io::ErrorKind::NotFound,
52 "Could not determine home directory",
53 )))?
54 .join(".skill-engine")
55 .join("contexts");
56
57 Self::with_base_dir(base_dir)
58 }
59
60 pub fn with_base_dir(base_dir: PathBuf) -> Result<Self, ContextError> {
62 fs::create_dir_all(&base_dir)?;
63
64 Ok(Self {
65 base_dir,
66 backup_count: DEFAULT_BACKUP_COUNT,
67 })
68 }
69
70 pub fn with_backup_count(mut self, count: usize) -> Self {
72 self.backup_count = count;
73 self
74 }
75
76 pub fn base_dir(&self) -> &Path {
78 &self.base_dir
79 }
80
81 fn context_dir(&self, context_id: &str) -> PathBuf {
83 self.base_dir.join(context_id)
84 }
85
86 fn context_file(&self, context_id: &str) -> PathBuf {
88 self.context_dir(context_id).join("context.toml")
89 }
90
91 fn backup_dir(&self, context_id: &str) -> PathBuf {
93 self.context_dir(context_id).join(".backup")
94 }
95
96 fn index_file(&self) -> PathBuf {
98 self.base_dir.join("index.json")
99 }
100
101 pub fn save(&self, context: &ExecutionContext) -> Result<(), ContextError> {
106 let context_dir = self.context_dir(&context.id);
107 fs::create_dir_all(&context_dir)?;
108
109 let context_file = self.context_file(&context.id);
110
111 if context_file.exists() {
113 self.create_backup(&context.id)?;
114 }
115
116 let toml_content = toml::to_string_pretty(context)?;
118
119 let temp_file = context_dir.join(".context.toml.tmp");
121 {
122 let mut file = fs::File::create(&temp_file)?;
123 file.write_all(toml_content.as_bytes())?;
124 file.sync_all()?;
125 }
126
127 fs::rename(&temp_file, &context_file)?;
128
129 self.update_index(&context.id, Some(context))?;
131
132 Ok(())
133 }
134
135 pub fn load(&self, context_id: &str) -> Result<ExecutionContext, ContextError> {
137 let context_file = self.context_file(context_id);
138
139 if !context_file.exists() {
140 return Err(ContextError::NotFound(context_id.to_string()));
141 }
142
143 let content = fs::read_to_string(&context_file)?;
144 let context: ExecutionContext = toml::from_str(&content)?;
145
146 Ok(context)
147 }
148
149 pub fn delete(&self, context_id: &str) -> Result<(), ContextError> {
151 let context_dir = self.context_dir(context_id);
152
153 if !context_dir.exists() {
154 return Err(ContextError::NotFound(context_id.to_string()));
155 }
156
157 fs::remove_dir_all(&context_dir)?;
158
159 self.update_index(context_id, None)?;
161
162 Ok(())
163 }
164
165 pub fn exists(&self, context_id: &str) -> bool {
167 self.context_file(context_id).exists()
168 }
169
170 pub fn list(&self) -> Result<Vec<String>, ContextError> {
172 let index = self.load_index()?;
173 Ok(index.contexts.keys().cloned().collect())
174 }
175
176 pub fn list_with_metadata(&self) -> Result<Vec<ContextIndexEntry>, ContextError> {
178 let index = self.load_index()?;
179 Ok(index.contexts.into_values().collect())
180 }
181
182 pub fn get_metadata(&self, context_id: &str) -> Result<ContextIndexEntry, ContextError> {
184 let index = self.load_index()?;
185 index
186 .contexts
187 .get(context_id)
188 .cloned()
189 .ok_or_else(|| ContextError::NotFound(context_id.to_string()))
190 }
191
192 pub fn export(&self, context_id: &str, output_dir: &Path) -> Result<Vec<String>, ContextError> {
194 fs::create_dir_all(output_dir)?;
195
196 let mut exported = Vec::new();
197 let mut to_export = vec![context_id.to_string()];
198
199 while let Some(id) = to_export.pop() {
200 if exported.contains(&id) {
201 continue;
202 }
203
204 let context = self.load(&id)?;
205
206 let output_file = output_dir.join(format!("{}.toml", id));
208 let toml_content = toml::to_string_pretty(&context)?;
209 fs::write(&output_file, toml_content)?;
210
211 exported.push(id.clone());
212
213 if let Some(parent_id) = &context.inherits_from {
215 to_export.push(parent_id.clone());
216 }
217 }
218
219 Ok(exported)
220 }
221
222 pub fn import(&self, file_path: &Path) -> Result<String, ContextError> {
226 let content = fs::read_to_string(file_path)?;
227 let context: ExecutionContext = toml::from_str(&content)?;
228
229 if self.exists(&context.id) {
231 return Err(ContextError::AlreadyExists(context.id.clone()));
232 }
233
234 self.save(&context)?;
235
236 Ok(context.id)
237 }
238
239 pub fn import_with_overwrite(
241 &self,
242 file_path: &Path,
243 overwrite: bool,
244 ) -> Result<String, ContextError> {
245 let content = fs::read_to_string(file_path)?;
246 let context: ExecutionContext = toml::from_str(&content)?;
247
248 if self.exists(&context.id) && !overwrite {
249 return Err(ContextError::AlreadyExists(context.id.clone()));
250 }
251
252 self.save(&context)?;
253
254 Ok(context.id)
255 }
256
257 fn create_backup(&self, context_id: &str) -> Result<(), ContextError> {
259 let context_file = self.context_file(context_id);
260 let backup_dir = self.backup_dir(context_id);
261
262 if !context_file.exists() {
263 return Ok(());
264 }
265
266 fs::create_dir_all(&backup_dir)?;
267
268 for i in (1..self.backup_count).rev() {
270 let old = backup_dir.join(format!("context.toml.{}", i));
271 let new = backup_dir.join(format!("context.toml.{}", i + 1));
272 if old.exists() {
273 if i + 1 >= self.backup_count {
274 fs::remove_file(&old)?;
275 } else {
276 fs::rename(&old, &new)?;
277 }
278 }
279 }
280
281 let backup_file = backup_dir.join("context.toml.1");
283 fs::copy(&context_file, &backup_file)?;
284
285 Ok(())
286 }
287
288 pub fn restore_backup(&self, context_id: &str, version: usize) -> Result<(), ContextError> {
290 let backup_file = self.backup_dir(context_id).join(format!("context.toml.{}", version));
291
292 if !backup_file.exists() {
293 return Err(ContextError::NotFound(format!(
294 "Backup version {} for context '{}'",
295 version, context_id
296 )));
297 }
298
299 let backup_content = fs::read_to_string(&backup_file)?;
301
302 let context_file = self.context_file(context_id);
303
304 self.create_backup(context_id)?;
306
307 fs::write(&context_file, backup_content)?;
309
310 let context = self.load(context_id)?;
312 self.update_index(context_id, Some(&context))?;
313
314 Ok(())
315 }
316
317 pub fn list_backups(&self, context_id: &str) -> Result<Vec<BackupInfo>, ContextError> {
319 let backup_dir = self.backup_dir(context_id);
320
321 if !backup_dir.exists() {
322 return Ok(Vec::new());
323 }
324
325 let mut backups = Vec::new();
326
327 for entry in fs::read_dir(&backup_dir)? {
328 let entry = entry?;
329 let path = entry.path();
330
331 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
332 if let Some(version_str) = name.strip_prefix("context.toml.") {
333 if let Ok(version) = version_str.parse::<usize>() {
334 let metadata = fs::metadata(&path)?;
335 let modified = metadata
336 .modified()
337 .ok()
338 .and_then(|t| DateTime::<Utc>::from(t).into());
339
340 backups.push(BackupInfo {
341 version,
342 path,
343 modified_at: modified,
344 size_bytes: metadata.len(),
345 });
346 }
347 }
348 }
349 }
350
351 backups.sort_by_key(|b| b.version);
352
353 Ok(backups)
354 }
355
356 fn load_index(&self) -> Result<ContextIndex, ContextError> {
358 let index_file = self.index_file();
359
360 if !index_file.exists() {
361 return Ok(ContextIndex::default());
362 }
363
364 let content = fs::read_to_string(&index_file)?;
365 let index: ContextIndex = serde_json::from_str(&content)?;
366
367 Ok(index)
368 }
369
370 fn update_index(
372 &self,
373 context_id: &str,
374 context: Option<&ExecutionContext>,
375 ) -> Result<(), ContextError> {
376 let mut index = self.load_index()?;
377
378 match context {
379 Some(ctx) => {
380 index.contexts.insert(
381 context_id.to_string(),
382 ContextIndexEntry {
383 id: ctx.id.clone(),
384 name: ctx.name.clone(),
385 description: ctx.description.clone(),
386 inherits_from: ctx.inherits_from.clone(),
387 tags: ctx.metadata.tags.clone(),
388 created_at: ctx.metadata.created_at,
389 updated_at: ctx.metadata.updated_at,
390 },
391 );
392 }
393 None => {
394 index.contexts.remove(context_id);
395 }
396 }
397
398 let index_file = self.index_file();
400 let temp_file = self.base_dir.join(".index.json.tmp");
401
402 let content = serde_json::to_string_pretty(&index)?;
403 {
404 let mut file = fs::File::create(&temp_file)?;
405 file.write_all(content.as_bytes())?;
406 file.sync_all()?;
407 }
408
409 fs::rename(&temp_file, &index_file)?;
410
411 Ok(())
412 }
413
414 pub fn rebuild_index(&self) -> Result<usize, ContextError> {
418 let mut index = ContextIndex::default();
419 let mut count = 0;
420
421 for entry in fs::read_dir(&self.base_dir)? {
422 let entry = entry?;
423 let path = entry.path();
424
425 if path.is_dir() {
426 let context_file = path.join("context.toml");
427 if context_file.exists() {
428 if let Ok(context) = self.load(entry.file_name().to_str().unwrap_or_default()) {
429 index.contexts.insert(
430 context.id.clone(),
431 ContextIndexEntry {
432 id: context.id.clone(),
433 name: context.name.clone(),
434 description: context.description.clone(),
435 inherits_from: context.inherits_from.clone(),
436 tags: context.metadata.tags.clone(),
437 created_at: context.metadata.created_at,
438 updated_at: context.metadata.updated_at,
439 },
440 );
441 count += 1;
442 }
443 }
444 }
445 }
446
447 let index_file = self.index_file();
449 let content = serde_json::to_string_pretty(&index)?;
450 fs::write(&index_file, content)?;
451
452 Ok(count)
453 }
454}
455
456impl Default for ContextStorage {
457 fn default() -> Self {
458 Self::new().expect("Failed to create default context storage")
459 }
460}
461
462#[derive(Debug, Clone, Default, Serialize, Deserialize)]
464pub struct ContextIndex {
465 pub contexts: HashMap<String, ContextIndexEntry>,
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize)]
471pub struct ContextIndexEntry {
472 pub id: String,
474 pub name: String,
476 pub description: Option<String>,
478 pub inherits_from: Option<String>,
480 pub tags: Vec<String>,
482 pub created_at: DateTime<Utc>,
484 pub updated_at: DateTime<Utc>,
486}
487
488#[derive(Debug, Clone)]
490pub struct BackupInfo {
491 pub version: usize,
493 pub path: PathBuf,
495 pub modified_at: Option<DateTime<Utc>>,
497 pub size_bytes: u64,
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504 use tempfile::TempDir;
505
506 fn create_test_storage() -> (ContextStorage, TempDir) {
507 let temp_dir = TempDir::new().unwrap();
508 let storage = ContextStorage::with_base_dir(temp_dir.path().to_path_buf()).unwrap();
509 (storage, temp_dir)
510 }
511
512 #[test]
513 fn test_save_and_load() {
514 let (storage, _temp) = create_test_storage();
515
516 let context = ExecutionContext::new("test-context", "Test Context")
517 .with_description("A test context")
518 .with_tag("test");
519
520 storage.save(&context).unwrap();
521 assert!(storage.exists("test-context"));
522
523 let loaded = storage.load("test-context").unwrap();
524 assert_eq!(loaded.id, "test-context");
525 assert_eq!(loaded.name, "Test Context");
526 assert_eq!(loaded.description, Some("A test context".to_string()));
527 }
528
529 #[test]
530 fn test_delete() {
531 let (storage, _temp) = create_test_storage();
532
533 let context = ExecutionContext::new("to-delete", "To Delete");
534 storage.save(&context).unwrap();
535 assert!(storage.exists("to-delete"));
536
537 storage.delete("to-delete").unwrap();
538 assert!(!storage.exists("to-delete"));
539 }
540
541 #[test]
542 fn test_list() {
543 let (storage, _temp) = create_test_storage();
544
545 storage
546 .save(&ExecutionContext::new("ctx-1", "Context 1"))
547 .unwrap();
548 storage
549 .save(&ExecutionContext::new("ctx-2", "Context 2"))
550 .unwrap();
551 storage
552 .save(&ExecutionContext::new("ctx-3", "Context 3"))
553 .unwrap();
554
555 let list = storage.list().unwrap();
556 assert_eq!(list.len(), 3);
557 assert!(list.contains(&"ctx-1".to_string()));
558 assert!(list.contains(&"ctx-2".to_string()));
559 assert!(list.contains(&"ctx-3".to_string()));
560 }
561
562 #[test]
563 fn test_index_metadata() {
564 let (storage, _temp) = create_test_storage();
565
566 let context = ExecutionContext::new("indexed", "Indexed Context")
567 .with_description("Has metadata")
568 .with_tag("important")
569 .with_tag("production");
570
571 storage.save(&context).unwrap();
572
573 let metadata = storage.get_metadata("indexed").unwrap();
574 assert_eq!(metadata.name, "Indexed Context");
575 assert_eq!(metadata.tags.len(), 2);
576 }
577
578 #[test]
579 fn test_backup_creation() {
580 let (storage, _temp) = create_test_storage();
581
582 let mut context = ExecutionContext::new("backup-test", "Backup Test");
584 storage.save(&context).unwrap();
585
586 context.description = Some("Modified".to_string());
588 context.touch();
589 storage.save(&context).unwrap();
590
591 let backups = storage.list_backups("backup-test").unwrap();
593 assert_eq!(backups.len(), 1);
594 assert_eq!(backups[0].version, 1);
595 }
596
597 #[test]
598 fn test_backup_rotation() {
599 let (storage, _temp) = create_test_storage();
600 let storage = storage.with_backup_count(3);
601
602 let mut context = ExecutionContext::new("rotation-test", "Rotation Test");
603
604 for i in 0..5 {
606 context.description = Some(format!("Version {}", i));
607 context.touch();
608 storage.save(&context).unwrap();
609 }
610
611 let backups = storage.list_backups("rotation-test").unwrap();
613 assert!(backups.len() <= 3);
614 }
615
616 #[test]
617 fn test_restore_backup() {
618 let (storage, _temp) = create_test_storage();
619
620 let mut context = ExecutionContext::new("restore-test", "Restore Test");
622 context.description = Some("Original".to_string());
623 storage.save(&context).unwrap();
624
625 context.description = Some("Modified".to_string());
627 context.touch();
628 storage.save(&context).unwrap();
629
630 storage.restore_backup("restore-test", 1).unwrap();
632
633 let restored = storage.load("restore-test").unwrap();
635 assert_eq!(restored.description, Some("Original".to_string()));
636 }
637
638 #[test]
639 fn test_export_import() {
640 let (storage, _temp) = create_test_storage();
641
642 let parent = ExecutionContext::new("parent", "Parent Context");
644 let child = ExecutionContext::inheriting("child", "Child Context", "parent");
645
646 storage.save(&parent).unwrap();
647 storage.save(&child).unwrap();
648
649 let export_dir = _temp.path().join("export");
651 let exported = storage.export("child", &export_dir).unwrap();
652
653 assert_eq!(exported.len(), 2);
654 assert!(exported.contains(&"parent".to_string()));
655 assert!(exported.contains(&"child".to_string()));
656
657 let import_dir = _temp.path().join("import");
659 fs::create_dir_all(&import_dir).unwrap();
660 let import_storage = ContextStorage::with_base_dir(import_dir).unwrap();
661
662 import_storage
664 .import(&export_dir.join("parent.toml"))
665 .unwrap();
666 import_storage
667 .import(&export_dir.join("child.toml"))
668 .unwrap();
669
670 assert!(import_storage.exists("parent"));
671 assert!(import_storage.exists("child"));
672 }
673
674 #[test]
675 fn test_import_conflict() {
676 let (storage, _temp) = create_test_storage();
677
678 let context = ExecutionContext::new("conflict", "Conflict Test");
679 storage.save(&context).unwrap();
680
681 let export_dir = _temp.path().join("export");
683 storage.export("conflict", &export_dir).unwrap();
684
685 let result = storage.import(&export_dir.join("conflict.toml"));
687 assert!(matches!(result, Err(ContextError::AlreadyExists(_))));
688
689 let result = storage.import_with_overwrite(&export_dir.join("conflict.toml"), true);
691 assert!(result.is_ok());
692 }
693
694 #[test]
695 fn test_rebuild_index() {
696 let (storage, _temp) = create_test_storage();
697
698 storage
700 .save(&ExecutionContext::new("ctx-1", "Context 1"))
701 .unwrap();
702 storage
703 .save(&ExecutionContext::new("ctx-2", "Context 2"))
704 .unwrap();
705
706 fs::remove_file(storage.index_file()).ok();
708
709 let count = storage.rebuild_index().unwrap();
711 assert_eq!(count, 2);
712
713 let list = storage.list().unwrap();
715 assert_eq!(list.len(), 2);
716 }
717
718 #[test]
719 fn test_not_found() {
720 let (storage, _temp) = create_test_storage();
721
722 let result = storage.load("nonexistent");
723 assert!(matches!(result, Err(ContextError::NotFound(_))));
724
725 let result = storage.delete("nonexistent");
726 assert!(matches!(result, Err(ContextError::NotFound(_))));
727 }
728}