skill_context/
storage.rs

1//! Context storage and persistence.
2//!
3//! This module provides functionality for persisting execution contexts to the
4//! filesystem and loading them back. Contexts are stored in TOML format.
5//!
6//! # Storage Layout
7//!
8//! ```text
9//! ~/.skill-engine/
10//! ├── contexts/
11//! │   ├── index.json              # Context index for fast listing
12//! │   ├── {context-id}/
13//! │   │   ├── context.toml        # Context definition
14//! │   │   └── .backup/            # Backup versions
15//! │   │       ├── context.toml.1  # Previous version
16//! │   │       └── context.toml.2  # Older version
17//! └── templates/
18//!     └── contexts/
19//!         └── default.toml        # Default context template
20//! ```
21
22use 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
33/// Default number of backup versions to keep.
34const DEFAULT_BACKUP_COUNT: usize = 5;
35
36/// Storage layer for execution contexts.
37pub struct ContextStorage {
38    /// Base directory for context storage.
39    base_dir: PathBuf,
40    /// Number of backup versions to keep.
41    backup_count: usize,
42}
43
44impl ContextStorage {
45    /// Create a new context storage with the default base directory.
46    ///
47    /// Uses `~/.skill-engine/contexts` as the base directory.
48    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    /// Create a new context storage with a custom base directory.
61    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    /// Set the number of backup versions to keep.
71    pub fn with_backup_count(mut self, count: usize) -> Self {
72        self.backup_count = count;
73        self
74    }
75
76    /// Get the base directory.
77    pub fn base_dir(&self) -> &Path {
78        &self.base_dir
79    }
80
81    /// Get the path for a context's directory.
82    fn context_dir(&self, context_id: &str) -> PathBuf {
83        self.base_dir.join(context_id)
84    }
85
86    /// Get the path for a context's TOML file.
87    fn context_file(&self, context_id: &str) -> PathBuf {
88        self.context_dir(context_id).join("context.toml")
89    }
90
91    /// Get the backup directory for a context.
92    fn backup_dir(&self, context_id: &str) -> PathBuf {
93        self.context_dir(context_id).join(".backup")
94    }
95
96    /// Get the index file path.
97    fn index_file(&self) -> PathBuf {
98        self.base_dir.join("index.json")
99    }
100
101    /// Save a context to storage.
102    ///
103    /// This performs an atomic write (write to temp file, then rename) to
104    /// prevent corruption from interrupted writes.
105    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        // Create backup if file already exists
112        if context_file.exists() {
113            self.create_backup(&context.id)?;
114        }
115
116        // Serialize to TOML
117        let toml_content = toml::to_string_pretty(context)?;
118
119        // Atomic write: write to temp file, then rename
120        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        // Update index
130        self.update_index(&context.id, Some(context))?;
131
132        Ok(())
133    }
134
135    /// Load a context from storage.
136    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    /// Delete a context from storage.
150    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        // Update index
160        self.update_index(context_id, None)?;
161
162        Ok(())
163    }
164
165    /// Check if a context exists.
166    pub fn exists(&self, context_id: &str) -> bool {
167        self.context_file(context_id).exists()
168    }
169
170    /// List all context IDs.
171    pub fn list(&self) -> Result<Vec<String>, ContextError> {
172        let index = self.load_index()?;
173        Ok(index.contexts.keys().cloned().collect())
174    }
175
176    /// List all contexts with their metadata.
177    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    /// Get the index entry for a context without loading the full context.
183    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    /// Export a context and all its parent contexts to a directory.
193    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            // Export this context
207            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            // Queue parent for export
214            if let Some(parent_id) = &context.inherits_from {
215                to_export.push(parent_id.clone());
216            }
217        }
218
219        Ok(exported)
220    }
221
222    /// Import a context from a file.
223    ///
224    /// Returns the ID of the imported context.
225    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        // Check for conflicts
230        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    /// Import a context, optionally overwriting if it exists.
240    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    /// Create a backup of a context.
258    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        // Rotate existing backups
269        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        // Create new backup
282        let backup_file = backup_dir.join("context.toml.1");
283        fs::copy(&context_file, &backup_file)?;
284
285        Ok(())
286    }
287
288    /// Restore a context from a specific backup version.
289    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        // Read backup content first (before rotation shifts files)
300        let backup_content = fs::read_to_string(&backup_file)?;
301
302        let context_file = self.context_file(context_id);
303
304        // Create backup of current before restoring
305        self.create_backup(context_id)?;
306
307        // Restore from the previously read backup content
308        fs::write(&context_file, backup_content)?;
309
310        // Update index
311        let context = self.load(context_id)?;
312        self.update_index(context_id, Some(&context))?;
313
314        Ok(())
315    }
316
317    /// List available backup versions for a context.
318    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    /// Load the context index.
357    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    /// Update the context index.
371    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        // Atomic write
399        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    /// Rebuild the index by scanning all contexts.
415    ///
416    /// Useful if the index becomes corrupted or out of sync.
417    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        // Write index
448        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/// Context index for fast listing.
463#[derive(Debug, Clone, Default, Serialize, Deserialize)]
464pub struct ContextIndex {
465    /// Map of context ID to index entry.
466    pub contexts: HashMap<String, ContextIndexEntry>,
467}
468
469/// Entry in the context index.
470#[derive(Debug, Clone, Serialize, Deserialize)]
471pub struct ContextIndexEntry {
472    /// Context ID.
473    pub id: String,
474    /// Context name.
475    pub name: String,
476    /// Context description.
477    pub description: Option<String>,
478    /// Parent context ID.
479    pub inherits_from: Option<String>,
480    /// Tags for categorization.
481    pub tags: Vec<String>,
482    /// Creation timestamp.
483    pub created_at: DateTime<Utc>,
484    /// Last update timestamp.
485    pub updated_at: DateTime<Utc>,
486}
487
488/// Information about a backup version.
489#[derive(Debug, Clone)]
490pub struct BackupInfo {
491    /// Backup version number (1 = most recent).
492    pub version: usize,
493    /// Path to the backup file.
494    pub path: PathBuf,
495    /// When the backup was created.
496    pub modified_at: Option<DateTime<Utc>>,
497    /// Size in bytes.
498    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        // Save initial version
583        let mut context = ExecutionContext::new("backup-test", "Backup Test");
584        storage.save(&context).unwrap();
585
586        // Modify and save again
587        context.description = Some("Modified".to_string());
588        context.touch();
589        storage.save(&context).unwrap();
590
591        // Check backup exists
592        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        // Create 5 versions
605        for i in 0..5 {
606            context.description = Some(format!("Version {}", i));
607            context.touch();
608            storage.save(&context).unwrap();
609        }
610
611        // Should only have 3 backups (backup_count - 1 = 2 older + current doesn't count)
612        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        // Save initial
621        let mut context = ExecutionContext::new("restore-test", "Restore Test");
622        context.description = Some("Original".to_string());
623        storage.save(&context).unwrap();
624
625        // Modify
626        context.description = Some("Modified".to_string());
627        context.touch();
628        storage.save(&context).unwrap();
629
630        // Restore
631        storage.restore_backup("restore-test", 1).unwrap();
632
633        // Check restored value
634        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        // Create parent and child contexts
643        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        // Export child (should include parent)
650        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        // Create new storage and import
658        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 parent first
663        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        // Export
682        let export_dir = _temp.path().join("export");
683        storage.export("conflict", &export_dir).unwrap();
684
685        // Try to import again - should fail
686        let result = storage.import(&export_dir.join("conflict.toml"));
687        assert!(matches!(result, Err(ContextError::AlreadyExists(_))));
688
689        // Import with overwrite should succeed
690        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        // Save some contexts
699        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        // Delete the index file manually
707        fs::remove_file(storage.index_file()).ok();
708
709        // Rebuild
710        let count = storage.rebuild_index().unwrap();
711        assert_eq!(count, 2);
712
713        // Verify list works
714        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}