Skip to main content

feagi_evolutionary/
storage.rs

1// Copyright 2025 Neuraville Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4/*!
5Genome Storage Abstraction
6
7Provides platform-agnostic storage interface for genome persistence:
8- Desktop: File system storage
9- WASM: IndexedDB storage (implemented in feagi-wasm)
10
11This trait allows FEAGI to work seamlessly across platforms without
12hardcoding platform-specific storage mechanisms.
13
14Copyright 2025 Neuraville Inc.
15Licensed under the Apache License, Version 2.0
16*/
17
18use core::future::Future;
19
20/// Storage errors
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum StorageError {
23    /// Genome not found
24    NotFound,
25    /// I/O error (file system, network, etc.)
26    IOError(String),
27    /// Serialization/deserialization error
28    SerializationError(String),
29}
30
31impl std::fmt::Display for StorageError {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        match self {
34            StorageError::NotFound => write!(f, "Genome not found"),
35            StorageError::IOError(msg) => write!(f, "I/O error: {}", msg),
36            StorageError::SerializationError(msg) => write!(f, "Serialization error: {}", msg),
37        }
38    }
39}
40
41impl std::error::Error for StorageError {}
42
43/// Platform-agnostic genome storage trait
44///
45/// This trait abstracts genome persistence across platforms:
46/// - Desktop: File system storage
47/// - WASM: IndexedDB storage
48///
49/// All operations are async to support both blocking (file I/O) and
50/// non-blocking (IndexedDB) storage backends.
51pub trait GenomeStorage: Send + Sync {
52    /// Load a genome by ID
53    ///
54    /// # Arguments
55    ///
56    /// * `genome_id` - Unique identifier for the genome
57    ///
58    /// # Returns
59    ///
60    /// `Ok(String)` with genome JSON if found,
61    /// `Err(StorageError::NotFound)` if genome doesn't exist,
62    /// `Err(StorageError::IOError)` for I/O failures
63    fn load_genome(
64        &self,
65        genome_id: &str,
66    ) -> Pin<Box<dyn Future<Output = Result<String, StorageError>> + Send + '_>>;
67
68    /// Save a genome by ID
69    ///
70    /// # Arguments
71    ///
72    /// * `genome_id` - Unique identifier for the genome
73    /// * `genome_json` - Genome JSON string to save
74    ///
75    /// # Returns
76    ///
77    /// `Ok(())` on success,
78    /// `Err(StorageError::IOError)` for I/O failures,
79    /// `Err(StorageError::SerializationError)` for invalid JSON
80    fn save_genome(
81        &self,
82        genome_id: &str,
83        genome_json: &str,
84    ) -> Pin<Box<dyn Future<Output = Result<(), StorageError>> + Send + '_>>;
85
86    /// List all available genome IDs
87    ///
88    /// # Returns
89    ///
90    /// `Ok(Vec<String>)` with all genome IDs,
91    /// `Err(StorageError::IOError)` for I/O failures
92    fn list_genomes(
93        &self,
94    ) -> Pin<Box<dyn Future<Output = Result<Vec<String>, StorageError>> + Send + '_>>;
95
96    /// Delete a genome by ID
97    ///
98    /// # Arguments
99    ///
100    /// * `genome_id` - Unique identifier for the genome to delete
101    ///
102    /// # Returns
103    ///
104    /// `Ok(())` on success,
105    /// `Err(StorageError::NotFound)` if genome doesn't exist,
106    /// `Err(StorageError::IOError)` for I/O failures
107    fn delete_genome(
108        &self,
109        genome_id: &str,
110    ) -> Pin<Box<dyn Future<Output = Result<(), StorageError>> + Send + '_>>;
111}
112
113// Re-export Pin for convenience
114use core::pin::Pin;
115
116#[cfg(feature = "async-tokio")]
117pub mod fs_storage {
118    //! File system storage implementation for desktop platforms
119    //!
120    //! Uses async file I/O via tokio for non-blocking operations.
121
122    use super::{GenomeStorage, StorageError};
123    use core::future::Future;
124    use core::pin::Pin;
125    use std::path::{Path, PathBuf};
126
127    /// File system-based genome storage
128    ///
129    /// Stores genomes as JSON files in a directory structure:
130    /// ```
131    /// base_path/
132    ///   genome_id_1.json
133    ///   genome_id_2.json
134    ///   ...
135    /// ```
136    pub struct FileSystemStorage {
137        base_path: PathBuf,
138    }
139
140    impl FileSystemStorage {
141        /// Create a new file system storage instance
142        ///
143        /// # Arguments
144        ///
145        /// * `base_path` - Base directory for storing genomes
146        ///
147        /// # Errors
148        ///
149        /// Returns error if base_path doesn't exist or can't be created
150        pub fn new<P: AsRef<Path>>(base_path: P) -> Result<Self, StorageError> {
151            let path = base_path.as_ref().to_path_buf();
152
153            // Create directory if it doesn't exist
154            std::fs::create_dir_all(&path)
155                .map_err(|e| StorageError::IOError(format!("Failed to create directory: {}", e)))?;
156
157            Ok(Self { base_path: path })
158        }
159
160        /// Get the file path for a genome ID
161        fn genome_path(&self, genome_id: &str) -> PathBuf {
162            // Sanitize genome_id to prevent path traversal
163            let sanitized = genome_id
164                .chars()
165                .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
166                .collect::<String>();
167
168            self.base_path.join(format!("{}.json", sanitized))
169        }
170    }
171
172    #[cfg(feature = "async-tokio")]
173    impl GenomeStorage for FileSystemStorage {
174        fn load_genome(
175            &self,
176            genome_id: &str,
177        ) -> Pin<Box<dyn Future<Output = Result<String, StorageError>> + Send + '_>> {
178            let path = self.genome_path(genome_id);
179            Box::pin(async move {
180                tokio::fs::read_to_string(&path).await.map_err(|e| {
181                    if e.kind() == std::io::ErrorKind::NotFound {
182                        StorageError::NotFound
183                    } else {
184                        StorageError::IOError(format!("Failed to read file: {}", e))
185                    }
186                })
187            })
188        }
189
190        fn save_genome(
191            &self,
192            genome_id: &str,
193            genome_json: &str,
194        ) -> Pin<Box<dyn Future<Output = Result<(), StorageError>> + Send + '_>> {
195            let path = self.genome_path(genome_id);
196            let json = genome_json.to_string();
197
198            Box::pin(async move {
199                // Validate JSON before saving
200                serde_json::from_str::<serde_json::Value>(&json).map_err(|e| {
201                    StorageError::SerializationError(format!("Invalid JSON: {}", e))
202                })?;
203
204                tokio::fs::write(&path, json)
205                    .await
206                    .map_err(|e| StorageError::IOError(format!("Failed to write file: {}", e)))?;
207
208                Ok(())
209            })
210        }
211
212        fn list_genomes(
213            &self,
214        ) -> Pin<Box<dyn Future<Output = Result<Vec<String>, StorageError>> + Send + '_>> {
215            let base_path = self.base_path.clone();
216            Box::pin(async move {
217                let mut entries = tokio::fs::read_dir(&base_path).await.map_err(|e| {
218                    StorageError::IOError(format!("Failed to read directory: {}", e))
219                })?;
220
221                let mut genome_ids = Vec::new();
222                while let Some(entry) = entries.next_entry().await.map_err(|e| {
223                    StorageError::IOError(format!("Failed to read directory entry: {}", e))
224                })? {
225                    let path = entry.path();
226                    if path.extension().and_then(|s| s.to_str()) == Some("json") {
227                        if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
228                            genome_ids.push(stem.to_string());
229                        }
230                    }
231                }
232
233                Ok(genome_ids)
234            })
235        }
236
237        fn delete_genome(
238            &self,
239            genome_id: &str,
240        ) -> Pin<Box<dyn Future<Output = Result<(), StorageError>> + Send + '_>> {
241            let path = self.genome_path(genome_id);
242            Box::pin(async move {
243                tokio::fs::remove_file(&path).await.map_err(|e| {
244                    if e.kind() == std::io::ErrorKind::NotFound {
245                        StorageError::NotFound
246                    } else {
247                        StorageError::IOError(format!("Failed to delete file: {}", e))
248                    }
249                })
250            })
251        }
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    #[cfg(feature = "async-tokio")]
258    use super::fs_storage::FileSystemStorage;
259
260    #[cfg(feature = "async-tokio")]
261    #[tokio::test]
262    async fn test_filesystem_storage() {
263        use tempfile::TempDir;
264
265        let temp_dir = TempDir::new().unwrap();
266        let storage = FileSystemStorage::new(temp_dir.path()).unwrap();
267
268        // Test save
269        let genome_id = "test_genome";
270        let genome_json = r#"{"genome_id": "test_genome", "version": "2.1"}"#;
271        storage.save_genome(genome_id, genome_json).await.unwrap();
272
273        // Test load
274        let loaded = storage.load_genome(genome_id).await.unwrap();
275        assert_eq!(loaded, genome_json);
276
277        // Test list
278        let genomes = storage.list_genomes().await.unwrap();
279        assert!(genomes.contains(&genome_id.to_string()));
280
281        // Test delete
282        storage.delete_genome(genome_id).await.unwrap();
283
284        // Verify deleted
285        let result = storage.load_genome(genome_id).await;
286        assert!(matches!(result, Err(StorageError::NotFound)));
287    }
288}