version_migrate/
dir_storage.rs

1//! Directory-based storage layer for managing multiple entity files.
2//!
3//! Provides per-entity file storage with ACID guarantees, automatic migrations,
4//! and flexible file naming strategies. Unlike `FileStorage` which stores multiple
5//! entities in a single file, `DirStorage` creates one file per entity.
6//!
7//! # Use Cases
8//!
9//! - Session management: `sessions/session-123.json`
10//! - Task management: `tasks/task-456.json`
11//! - User data: `users/user-789.json`
12//!
13//! # Example
14//!
15//! ```ignore
16//! use version_migrate::{AppPaths, Migrator, DirStorage, DirStorageStrategy};
17//!
18//! // Setup migrator with entity paths
19//! let mut migrator = Migrator::new();
20//! let session_path = Migrator::define("session")
21//!     .from::<SessionV1_0_0>()
22//!     .step::<SessionV1_1_0>()
23//!     .into_with_save::<SessionEntity>();
24//! migrator.register(session_path)?;
25//!
26//! // Create DirStorage
27//! let paths = AppPaths::new("myapp");
28//! let storage = DirStorage::new(
29//!     paths,
30//!     "sessions",
31//!     migrator,
32//!     DirStorageStrategy::default(),
33//! )?;
34//!
35//! // Save and load entities
36//! let session = SessionEntity { /* ... */ };
37//! storage.save("session", "session-123", session)?;
38//! let loaded: SessionEntity = storage.load("session", "session-123")?;
39//! ```
40
41use crate::{errors::IoOperationKind, AppPaths, MigrationError, Migrator};
42use base64::engine::general_purpose::URL_SAFE_NO_PAD;
43use base64::Engine;
44use std::fs::{self, File};
45use std::io::Write;
46use std::path::{Path, PathBuf};
47
48// Re-export shared types from storage module
49pub use crate::storage::{AtomicWriteConfig, FormatStrategy};
50
51/// File naming encoding strategy for entity IDs.
52///
53/// Determines how entity IDs are encoded into filesystem-safe filenames.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
55pub enum FilenameEncoding {
56    /// Use ID directly as filename (safe characters only: alphanumeric, `-`, `_`)
57    #[default]
58    Direct,
59    /// URL-encode the ID (for IDs with special characters)
60    UrlEncode,
61    /// Base64-encode the ID (for maximum safety)
62    Base64,
63}
64
65/// Strategy configuration for directory-based storage operations.
66#[derive(Debug, Clone)]
67pub struct DirStorageStrategy {
68    /// File format to use (JSON or TOML)
69    pub format: FormatStrategy,
70    /// Atomic write configuration
71    pub atomic_write: AtomicWriteConfig,
72    /// Custom file extension (if None, derived from format)
73    pub extension: Option<String>,
74    /// File naming encoding strategy
75    pub filename_encoding: FilenameEncoding,
76}
77
78impl Default for DirStorageStrategy {
79    fn default() -> Self {
80        Self {
81            format: FormatStrategy::Json,
82            atomic_write: AtomicWriteConfig::default(),
83            extension: None,
84            filename_encoding: FilenameEncoding::default(),
85        }
86    }
87}
88
89impl DirStorageStrategy {
90    /// Create a new strategy with default values.
91    #[allow(dead_code)]
92    pub fn new() -> Self {
93        Self::default()
94    }
95
96    /// Set the file format.
97    #[allow(dead_code)]
98    pub fn with_format(mut self, format: FormatStrategy) -> Self {
99        self.format = format;
100        self
101    }
102
103    /// Set a custom file extension.
104    #[allow(dead_code)]
105    pub fn with_extension(mut self, ext: impl Into<String>) -> Self {
106        self.extension = Some(ext.into());
107        self
108    }
109
110    /// Set the filename encoding strategy.
111    #[allow(dead_code)]
112    pub fn with_filename_encoding(mut self, encoding: FilenameEncoding) -> Self {
113        self.filename_encoding = encoding;
114        self
115    }
116
117    /// Set the retry count for atomic writes.
118    #[allow(dead_code)]
119    pub fn with_retry_count(mut self, count: usize) -> Self {
120        self.atomic_write.retry_count = count;
121        self
122    }
123
124    /// Set whether to cleanup temporary files.
125    #[allow(dead_code)]
126    pub fn with_cleanup(mut self, cleanup: bool) -> Self {
127        self.atomic_write.cleanup_tmp_files = cleanup;
128        self
129    }
130
131    /// Get the file extension (derived from format if not explicitly set).
132    fn get_extension(&self) -> String {
133        self.extension.clone().unwrap_or_else(|| match self.format {
134            FormatStrategy::Json => "json".to_string(),
135            FormatStrategy::Toml => "toml".to_string(),
136        })
137    }
138}
139
140/// Directory-based entity storage with ACID guarantees and automatic migrations.
141///
142/// Manages one file per entity, providing:
143/// - **Atomicity**: Updates are all-or-nothing via tmp file + atomic rename
144/// - **Consistency**: Format validation on load/save
145/// - **Isolation**: Each entity has its own file
146/// - **Durability**: Explicit fsync before rename
147pub struct DirStorage {
148    /// Resolved base directory path
149    base_path: PathBuf,
150    /// Migrator instance for handling version migrations
151    migrator: Migrator,
152    /// Storage strategy configuration
153    strategy: DirStorageStrategy,
154}
155
156impl DirStorage {
157    /// Create a new DirStorage instance.
158    ///
159    /// # Arguments
160    ///
161    /// * `paths` - Application paths manager
162    /// * `domain_name` - Domain-specific subdirectory name (e.g., "sessions", "tasks")
163    /// * `migrator` - Migrator instance with registered migration paths
164    /// * `strategy` - Storage strategy configuration
165    ///
166    /// # Behavior
167    ///
168    /// - Resolves the base path using `paths.data_dir()?.join(domain_name)`
169    /// - Creates the directory if it doesn't exist
170    /// - Does not load existing files (lazy loading)
171    ///
172    /// # Errors
173    ///
174    /// Returns `MigrationError::IoError` if directory creation fails.
175    ///
176    /// # Example
177    ///
178    /// ```ignore
179    /// let paths = AppPaths::new("myapp");
180    /// let storage = DirStorage::new(
181    ///     paths,
182    ///     "sessions",
183    ///     migrator,
184    ///     DirStorageStrategy::default(),
185    /// )?;
186    /// ```
187    pub fn new(
188        paths: AppPaths,
189        domain_name: &str,
190        migrator: Migrator,
191        strategy: DirStorageStrategy,
192    ) -> Result<Self, MigrationError> {
193        // Resolve base path: data_dir/domain_name
194        let base_path = paths.data_dir()?.join(domain_name);
195
196        // Create directory if it doesn't exist
197        if !base_path.exists() {
198            std::fs::create_dir_all(&base_path).map_err(|e| MigrationError::IoError {
199                operation: IoOperationKind::CreateDir,
200                path: base_path.display().to_string(),
201                context: Some("storage base directory".to_string()),
202                error: e.to_string(),
203            })?;
204        }
205
206        Ok(Self {
207            base_path,
208            migrator,
209            strategy,
210        })
211    }
212
213    /// Save an entity to a file.
214    ///
215    /// # Arguments
216    ///
217    /// * `entity_name` - The entity name registered in the migrator
218    /// * `id` - The unique identifier for this entity (used as filename)
219    /// * `entity` - The entity to save
220    ///
221    /// # Process
222    ///
223    /// 1. Converts the entity to its latest versioned DTO
224    /// 2. Serializes to the configured format (JSON/TOML)
225    /// 3. Writes atomically using temporary file + rename
226    ///
227    /// # Errors
228    ///
229    /// Returns error if:
230    /// - Entity name not registered in migrator
231    /// - ID contains invalid characters (for Direct encoding)
232    /// - Serialization fails
233    /// - File write fails
234    ///
235    /// # Example
236    ///
237    /// ```ignore
238    /// let session = SessionEntity {
239    ///     id: "session-123".to_string(),
240    ///     user_id: "user-456".to_string(),
241    /// };
242    /// storage.save("session", "session-123", session)?;
243    /// ```
244    pub fn save<T>(&self, entity_name: &str, id: &str, entity: T) -> Result<(), MigrationError>
245    where
246        T: serde::Serialize,
247    {
248        // Convert entity to latest versioned DTO and get JSON string
249        let json_string = self.migrator.save_domain_flat(entity_name, entity)?;
250
251        // Parse back to JSON value for format conversion
252        let versioned_value: serde_json::Value = serde_json::from_str(&json_string)
253            .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
254
255        // Serialize to target format (JSON or TOML)
256        let content = self.serialize_content(&versioned_value)?;
257
258        // Get target file path
259        let file_path = self.id_to_path(id)?;
260
261        // Write atomically
262        self.atomic_write(&file_path, &content)?;
263
264        Ok(())
265    }
266
267    /// Convert an entity ID to a file path.
268    ///
269    /// Encodes the ID according to the configured filename encoding strategy
270    /// and appends the appropriate file extension.
271    ///
272    /// # Arguments
273    ///
274    /// * `id` - The entity ID
275    ///
276    /// # Returns
277    ///
278    /// Full path: `base_path/encoded_id.extension`
279    ///
280    /// # Errors
281    ///
282    /// Returns error if ID encoding fails (e.g., invalid characters for Direct encoding).
283    fn id_to_path(&self, id: &str) -> Result<PathBuf, MigrationError> {
284        let encoded_id = self.encode_id(id)?;
285        let extension = self.strategy.get_extension();
286        let filename = format!("{}.{}", encoded_id, extension);
287        Ok(self.base_path.join(filename))
288    }
289
290    /// Encode an entity ID to a filesystem-safe filename.
291    ///
292    /// # Arguments
293    ///
294    /// * `id` - The entity ID to encode
295    ///
296    /// # Encoding Strategies
297    ///
298    /// - **Direct**: Use ID as-is (validates alphanumeric, `-`, `_` only)
299    /// - **UrlEncode**: URL-encode special characters (not yet implemented)
300    /// - **Base64**: Base64-encode the ID (not yet implemented)
301    ///
302    /// # Errors
303    ///
304    /// Returns `MigrationError::FilenameEncoding` if:
305    /// - Direct encoding with invalid characters
306    /// - Encoding strategy not yet implemented
307    fn encode_id(&self, id: &str) -> Result<String, MigrationError> {
308        match self.strategy.filename_encoding {
309            FilenameEncoding::Direct => {
310                // Validate that ID contains only safe characters
311                if id
312                    .chars()
313                    .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
314                {
315                    Ok(id.to_string())
316                } else {
317                    Err(MigrationError::FilenameEncoding {
318                        id: id.to_string(),
319                        reason: "ID contains invalid characters for Direct encoding. Only alphanumeric, '-', and '_' are allowed.".to_string(),
320                    })
321                }
322            }
323            FilenameEncoding::UrlEncode => {
324                // URL-encode the ID for filesystem safety
325                Ok(urlencoding::encode(id).into_owned())
326            }
327            FilenameEncoding::Base64 => {
328                // Base64-encode the ID using URL-safe encoding without padding
329                Ok(URL_SAFE_NO_PAD.encode(id.as_bytes()))
330            }
331        }
332    }
333
334    /// Serialize a JSON value to string based on the configured format.
335    ///
336    /// # Arguments
337    ///
338    /// * `value` - The JSON value to serialize
339    ///
340    /// # Returns
341    ///
342    /// Pretty-printed string in the configured format (JSON or TOML).
343    ///
344    /// # Errors
345    ///
346    /// Returns error if serialization or format conversion fails.
347    fn serialize_content(&self, value: &serde_json::Value) -> Result<String, MigrationError> {
348        match self.strategy.format {
349            FormatStrategy::Json => serde_json::to_string_pretty(value)
350                .map_err(|e| MigrationError::SerializationError(e.to_string())),
351            FormatStrategy::Toml => {
352                let toml_value = json_to_toml(value)?;
353                toml::to_string_pretty(&toml_value)
354                    .map_err(|e| MigrationError::TomlSerializeError(e.to_string()))
355            }
356        }
357    }
358
359    /// Write content to a file atomically.
360    ///
361    /// Uses the "temporary file + fsync + atomic rename" pattern to ensure
362    /// durability and atomicity.
363    ///
364    /// # Process
365    ///
366    /// 1. Create temporary file with unique name (`.filename.tmp.{pid}`)
367    /// 2. Write content to temporary file
368    /// 3. Sync to disk (fsync)
369    /// 4. Atomically rename to target path
370    /// 5. Retry on failure (configured retry count)
371    /// 6. Clean up old temporary files (best effort)
372    ///
373    /// # Arguments
374    ///
375    /// * `path` - Target file path
376    /// * `content` - Content to write
377    ///
378    /// # Errors
379    ///
380    /// Returns error if file creation, write, sync, or rename fails.
381    fn atomic_write(&self, path: &Path, content: &str) -> Result<(), MigrationError> {
382        // Ensure parent directory exists
383        if let Some(parent) = path.parent() {
384            if !parent.exists() {
385                fs::create_dir_all(parent).map_err(|e| MigrationError::IoError {
386                    operation: IoOperationKind::CreateDir,
387                    path: parent.display().to_string(),
388                    context: Some("parent directory".to_string()),
389                    error: e.to_string(),
390                })?;
391            }
392        }
393
394        // Create temporary file path
395        let tmp_path = self.get_temp_path(path)?;
396
397        // Write to temporary file
398        let mut tmp_file = File::create(&tmp_path).map_err(|e| MigrationError::IoError {
399            operation: IoOperationKind::Create,
400            path: tmp_path.display().to_string(),
401            context: Some("temporary file".to_string()),
402            error: e.to_string(),
403        })?;
404
405        tmp_file
406            .write_all(content.as_bytes())
407            .map_err(|e| MigrationError::IoError {
408                operation: IoOperationKind::Write,
409                path: tmp_path.display().to_string(),
410                context: Some("temporary file".to_string()),
411                error: e.to_string(),
412            })?;
413
414        // Ensure data is written to disk
415        tmp_file.sync_all().map_err(|e| MigrationError::IoError {
416            operation: IoOperationKind::Sync,
417            path: tmp_path.display().to_string(),
418            context: Some("temporary file".to_string()),
419            error: e.to_string(),
420        })?;
421
422        drop(tmp_file);
423
424        // Atomic rename with retry
425        self.atomic_rename(&tmp_path, path)?;
426
427        // Cleanup old temp files (best effort)
428        if self.strategy.atomic_write.cleanup_tmp_files {
429            let _ = self.cleanup_temp_files(path);
430        }
431
432        Ok(())
433    }
434
435    /// Get path to temporary file for atomic writes.
436    ///
437    /// Creates a unique temporary filename in the same directory as the target file.
438    /// Format: `.{filename}.tmp.{process_id}`
439    ///
440    /// # Arguments
441    ///
442    /// * `target_path` - The target file path
443    ///
444    /// # Returns
445    ///
446    /// Path to temporary file in the same directory.
447    ///
448    /// # Errors
449    ///
450    /// Returns error if the path has no parent directory or filename.
451    fn get_temp_path(&self, target_path: &Path) -> Result<PathBuf, MigrationError> {
452        let parent = target_path.parent().ok_or_else(|| {
453            MigrationError::PathResolution("Path has no parent directory".to_string())
454        })?;
455
456        let file_name = target_path
457            .file_name()
458            .ok_or_else(|| MigrationError::PathResolution("Path has no file name".to_string()))?;
459
460        let tmp_name = format!(
461            ".{}.tmp.{}",
462            file_name.to_string_lossy(),
463            std::process::id()
464        );
465        Ok(parent.join(tmp_name))
466    }
467
468    /// Atomically rename temporary file to target path with retry.
469    ///
470    /// Retries the rename operation according to the configured retry count,
471    /// with a small delay between attempts.
472    ///
473    /// # Arguments
474    ///
475    /// * `tmp_path` - Path to temporary file
476    /// * `target_path` - Target file path
477    ///
478    /// # Errors
479    ///
480    /// Returns error if all retry attempts fail.
481    fn atomic_rename(&self, tmp_path: &Path, target_path: &Path) -> Result<(), MigrationError> {
482        let mut last_error = None;
483
484        for attempt in 0..self.strategy.atomic_write.retry_count {
485            match fs::rename(tmp_path, target_path) {
486                Ok(()) => return Ok(()),
487                Err(e) => {
488                    last_error = Some(e);
489                    if attempt + 1 < self.strategy.atomic_write.retry_count {
490                        // Small delay before retry
491                        std::thread::sleep(std::time::Duration::from_millis(10));
492                    }
493                }
494            }
495        }
496
497        Err(MigrationError::IoError {
498            operation: IoOperationKind::Rename,
499            path: target_path.display().to_string(),
500            context: Some(format!(
501                "after {} retries",
502                self.strategy.atomic_write.retry_count
503            )),
504            error: last_error.unwrap().to_string(),
505        })
506    }
507
508    /// Clean up old temporary files (best effort).
509    ///
510    /// Attempts to remove old temporary files that may have been left behind
511    /// from previous failed operations. Errors are silently ignored.
512    ///
513    /// # Arguments
514    ///
515    /// * `target_path` - The target file path (used to find related temp files)
516    fn cleanup_temp_files(&self, target_path: &Path) -> std::io::Result<()> {
517        let parent = match target_path.parent() {
518            Some(p) => p,
519            None => return Ok(()),
520        };
521
522        let file_name = match target_path.file_name() {
523            Some(f) => f.to_string_lossy(),
524            None => return Ok(()),
525        };
526
527        let prefix = format!(".{}.tmp.", file_name);
528
529        if let Ok(entries) = fs::read_dir(parent) {
530            for entry in entries.flatten() {
531                if let Ok(name) = entry.file_name().into_string() {
532                    if name.starts_with(&prefix) {
533                        // Try to remove, but ignore errors (best effort)
534                        let _ = fs::remove_file(entry.path());
535                    }
536                }
537            }
538        }
539
540        Ok(())
541    }
542
543    /// Load an entity from a file.
544    ///
545    /// # Arguments
546    ///
547    /// * `entity_name` - The entity name registered in the migrator
548    /// * `id` - The unique identifier for the entity
549    ///
550    /// # Process
551    ///
552    /// 1. Gets the file path using `id_to_path`
553    /// 2. Reads the file content to a string
554    /// 3. Deserializes the content to a `serde_json::Value`
555    /// 4. Migrates the `Value` to the target domain type
556    ///
557    /// # Errors
558    ///
559    /// Returns error if:
560    /// - Entity name not registered in migrator
561    /// - File not found
562    /// - Deserialization fails
563    /// - Migration fails
564    ///
565    /// # Example
566    ///
567    /// ```ignore
568    /// let session: SessionEntity = storage.load("session", "session-123")?;
569    /// ```
570    pub fn load<D>(&self, entity_name: &str, id: &str) -> Result<D, MigrationError>
571    where
572        D: serde::de::DeserializeOwned,
573    {
574        // Get file path
575        let file_path = self.id_to_path(id)?;
576
577        // Check if file exists
578        if !file_path.exists() {
579            return Err(MigrationError::IoError {
580                operation: IoOperationKind::Read,
581                path: file_path.display().to_string(),
582                context: None,
583                error: "File not found".to_string(),
584            });
585        }
586
587        // Read file content
588        let content = fs::read_to_string(&file_path).map_err(|e| MigrationError::IoError {
589            operation: IoOperationKind::Read,
590            path: file_path.display().to_string(),
591            context: None,
592            error: e.to_string(),
593        })?;
594
595        // Deserialize content to JSON value
596        let value = self.deserialize_content(&content)?;
597
598        // Migrate to domain type using load_flat_from
599        self.migrator.load_flat_from(entity_name, value)
600    }
601
602    /// List all entity IDs in the storage directory.
603    ///
604    /// # Returns
605    ///
606    /// A sorted vector of entity IDs (decoded from filenames).
607    ///
608    /// # Errors
609    ///
610    /// Returns error if:
611    /// - Directory read fails
612    /// - Filename decoding fails
613    ///
614    /// # Example
615    ///
616    /// ```ignore
617    /// let ids = storage.list_ids()?;
618    /// for id in ids {
619    ///     println!("Found entity: {}", id);
620    /// }
621    /// ```
622    pub fn list_ids(&self) -> Result<Vec<String>, MigrationError> {
623        // Read directory
624        let entries = fs::read_dir(&self.base_path).map_err(|e| MigrationError::IoError {
625            operation: IoOperationKind::ReadDir,
626            path: self.base_path.display().to_string(),
627            context: None,
628            error: e.to_string(),
629        })?;
630
631        let extension = self.strategy.get_extension();
632        let mut ids = Vec::new();
633
634        for entry in entries {
635            let entry = entry.map_err(|e| MigrationError::IoError {
636                operation: IoOperationKind::ReadDir,
637                path: self.base_path.display().to_string(),
638                context: Some("directory entry".to_string()),
639                error: e.to_string(),
640            })?;
641
642            let path = entry.path();
643
644            // Check if it's a file with the correct extension
645            if path.is_file() {
646                if let Some(ext) = path.extension() {
647                    if ext == extension.as_str() {
648                        // Extract ID from filename
649                        if let Some(id) = self.path_to_id(&path)? {
650                            ids.push(id);
651                        }
652                    }
653                }
654            }
655        }
656
657        // Sort IDs for consistent ordering
658        ids.sort();
659        Ok(ids)
660    }
661
662    /// Load all entities from the storage directory.
663    ///
664    /// # Arguments
665    ///
666    /// * `entity_name` - The entity name registered in the migrator
667    ///
668    /// # Returns
669    ///
670    /// A vector of `(id, entity)` tuples.
671    ///
672    /// # Errors
673    ///
674    /// Returns error if any entity fails to load. This operation is atomic:
675    /// if any load fails, the whole operation fails.
676    ///
677    /// # Example
678    ///
679    /// ```ignore
680    /// let sessions: Vec<(String, SessionEntity)> = storage.load_all("session")?;
681    /// for (id, session) in sessions {
682    ///     println!("Loaded session {} for user {}", id, session.user_id);
683    /// }
684    /// ```
685    pub fn load_all<D>(&self, entity_name: &str) -> Result<Vec<(String, D)>, MigrationError>
686    where
687        D: serde::de::DeserializeOwned,
688    {
689        let ids = self.list_ids()?;
690        let mut results = Vec::new();
691
692        for id in ids {
693            let entity = self.load(entity_name, &id)?;
694            results.push((id, entity));
695        }
696
697        Ok(results)
698    }
699
700    /// Check if an entity exists.
701    ///
702    /// # Arguments
703    ///
704    /// * `id` - The entity ID
705    ///
706    /// # Returns
707    ///
708    /// `true` if the file exists and is a file, `false` otherwise.
709    ///
710    /// # Example
711    ///
712    /// ```ignore
713    /// if storage.exists("session-123")? {
714    ///     println!("Session exists");
715    /// }
716    /// ```
717    pub fn exists(&self, id: &str) -> Result<bool, MigrationError> {
718        let file_path = self.id_to_path(id)?;
719        Ok(file_path.exists() && file_path.is_file())
720    }
721
722    /// Delete an entity file.
723    ///
724    /// # Arguments
725    ///
726    /// * `id` - The entity ID
727    ///
728    /// # Behavior
729    ///
730    /// This operation is idempotent: deleting a non-existent file is not an error.
731    ///
732    /// # Errors
733    ///
734    /// Returns error if file deletion fails (but not if file doesn't exist).
735    ///
736    /// # Example
737    ///
738    /// ```ignore
739    /// storage.delete("session-123")?;
740    /// ```
741    pub fn delete(&self, id: &str) -> Result<(), MigrationError> {
742        let file_path = self.id_to_path(id)?;
743
744        if file_path.exists() {
745            fs::remove_file(&file_path).map_err(|e| MigrationError::IoError {
746                operation: IoOperationKind::Delete,
747                path: file_path.display().to_string(),
748                context: None,
749                error: e.to_string(),
750            })?;
751        }
752
753        Ok(())
754    }
755
756    /// Returns a reference to the base directory path.
757    ///
758    /// # Returns
759    ///
760    /// A reference to the resolved base directory path where entities are stored.
761    pub fn base_path(&self) -> &Path {
762        &self.base_path
763    }
764
765    /// Deserialize file content to a JSON value.
766    ///
767    /// # Arguments
768    ///
769    /// * `content` - The file content as a string
770    ///
771    /// # Returns
772    ///
773    /// A `serde_json::Value` representing the deserialized content.
774    ///
775    /// # Errors
776    ///
777    /// Returns error if deserialization fails.
778    fn deserialize_content(&self, content: &str) -> Result<serde_json::Value, MigrationError> {
779        match self.strategy.format {
780            FormatStrategy::Json => serde_json::from_str(content)
781                .map_err(|e| MigrationError::DeserializationError(e.to_string())),
782            FormatStrategy::Toml => {
783                let toml_value: toml::Value = toml::from_str(content)
784                    .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
785                toml_to_json(toml_value)
786            }
787        }
788    }
789
790    /// Extract the entity ID from a file path.
791    ///
792    /// # Arguments
793    ///
794    /// * `path` - The file path
795    ///
796    /// # Returns
797    ///
798    /// `Some(id)` if the path is valid, `None` otherwise.
799    ///
800    /// # Errors
801    ///
802    /// Returns error if ID decoding fails.
803    fn path_to_id(&self, path: &Path) -> Result<Option<String>, MigrationError> {
804        // Get file stem (filename without extension)
805        let file_stem = match path.file_stem() {
806            Some(stem) => stem.to_string_lossy(),
807            None => return Ok(None),
808        };
809
810        // Decode ID
811        let id = self.decode_id(&file_stem)?;
812        Ok(Some(id))
813    }
814
815    /// Decode a filename stem to an entity ID.
816    ///
817    /// # Arguments
818    ///
819    /// * `filename_stem` - The filename without extension
820    ///
821    /// # Returns
822    ///
823    /// The decoded entity ID.
824    ///
825    /// # Encoding Strategies
826    ///
827    /// - **Direct**: Use filename as-is (no decoding needed)
828    /// - **UrlEncode**: URL-decode the filename (not yet implemented)
829    /// - **Base64**: Base64-decode the filename (not yet implemented)
830    ///
831    /// # Errors
832    ///
833    /// Returns error if decoding fails or strategy is not yet implemented.
834    fn decode_id(&self, filename_stem: &str) -> Result<String, MigrationError> {
835        match self.strategy.filename_encoding {
836            FilenameEncoding::Direct => {
837                // Direct encoding: filename is the ID
838                Ok(filename_stem.to_string())
839            }
840            FilenameEncoding::UrlEncode => {
841                // URL-decode the filename to get the original ID
842                urlencoding::decode(filename_stem)
843                    .map(|s| s.into_owned())
844                    .map_err(|e| MigrationError::FilenameEncoding {
845                        id: filename_stem.to_string(),
846                        reason: format!("Failed to URL-decode filename: {}", e),
847                    })
848            }
849            FilenameEncoding::Base64 => {
850                // Base64-decode the filename using URL-safe encoding without padding
851                URL_SAFE_NO_PAD
852                    .decode(filename_stem.as_bytes())
853                    .map_err(|e| MigrationError::FilenameEncoding {
854                        id: filename_stem.to_string(),
855                        reason: format!("Failed to Base64-decode filename: {}", e),
856                    })
857                    .and_then(|bytes| {
858                        String::from_utf8(bytes).map_err(|e| MigrationError::FilenameEncoding {
859                            id: filename_stem.to_string(),
860                            reason: format!(
861                                "Failed to convert Base64-decoded bytes to UTF-8: {}",
862                                e
863                            ),
864                        })
865                    })
866            }
867        }
868    }
869}
870
871/// Convert JSON value to TOML value.
872///
873/// Helper function for format conversion during serialization.
874fn json_to_toml(json_value: &serde_json::Value) -> Result<toml::Value, MigrationError> {
875    let json_str = serde_json::to_string(json_value)
876        .map_err(|e| MigrationError::SerializationError(e.to_string()))?;
877    let toml_value: toml::Value = serde_json::from_str(&json_str)
878        .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
879    Ok(toml_value)
880}
881
882/// Convert TOML value to JSON value.
883///
884/// Helper function for format conversion during deserialization.
885fn toml_to_json(toml_value: toml::Value) -> Result<serde_json::Value, MigrationError> {
886    let json_str = serde_json::to_string(&toml_value)
887        .map_err(|e| MigrationError::SerializationError(e.to_string()))?;
888    let json_value: serde_json::Value = serde_json::from_str(&json_str)
889        .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
890    Ok(json_value)
891}
892
893// ============================================================================
894// Async implementation
895// ============================================================================
896
897#[cfg(feature = "async")]
898pub use async_impl::AsyncDirStorage;
899
900#[cfg(feature = "async")]
901mod async_impl {
902    use crate::{errors::IoOperationKind, AppPaths, MigrationError, Migrator};
903    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
904    use base64::Engine;
905    use std::path::{Path, PathBuf};
906    use tokio::io::AsyncWriteExt;
907
908    use super::{json_to_toml, toml_to_json, DirStorageStrategy, FilenameEncoding, FormatStrategy};
909
910    /// Async version of DirStorage for directory-based entity storage.
911    ///
912    /// Provides the same functionality as `DirStorage` but with async operations
913    /// using `tokio::fs` for non-blocking I/O.
914    pub struct AsyncDirStorage {
915        /// Resolved base directory path
916        base_path: PathBuf,
917        /// Migrator instance for handling version migrations
918        migrator: Migrator,
919        /// Storage strategy configuration
920        strategy: DirStorageStrategy,
921    }
922
923    impl AsyncDirStorage {
924        /// Create a new AsyncDirStorage instance.
925        ///
926        /// # Arguments
927        ///
928        /// * `paths` - Application paths manager
929        /// * `domain_name` - Domain-specific subdirectory name (e.g., "sessions", "tasks")
930        /// * `migrator` - Migrator instance with registered migration paths
931        /// * `strategy` - Storage strategy configuration
932        ///
933        /// # Behavior
934        ///
935        /// - Resolves the base path using `paths.data_dir()?.join(domain_name)`
936        /// - Creates the directory if it doesn't exist (async)
937        /// - Does not load existing files (lazy loading)
938        ///
939        /// # Errors
940        ///
941        /// Returns `MigrationError::IoError` if directory creation fails.
942        pub async fn new(
943            paths: AppPaths,
944            domain_name: &str,
945            migrator: Migrator,
946            strategy: DirStorageStrategy,
947        ) -> Result<Self, MigrationError> {
948            // Resolve base path: data_dir/domain_name
949            let base_path = paths.data_dir()?.join(domain_name);
950
951            // Create directory if it doesn't exist (async)
952            if !tokio::fs::try_exists(&base_path).await.unwrap_or(false) {
953                tokio::fs::create_dir_all(&base_path).await.map_err(|e| {
954                    MigrationError::IoError {
955                        operation: IoOperationKind::CreateDir,
956                        path: base_path.display().to_string(),
957                        context: Some("storage base directory (async)".to_string()),
958                        error: e.to_string(),
959                    }
960                })?;
961            }
962
963            Ok(Self {
964                base_path,
965                migrator,
966                strategy,
967            })
968        }
969
970        /// Save an entity to a file (async).
971        ///
972        /// # Arguments
973        ///
974        /// * `entity_name` - The entity name registered in the migrator
975        /// * `id` - The unique identifier for this entity (used as filename)
976        /// * `entity` - The entity to save
977        ///
978        /// # Process
979        ///
980        /// 1. Converts the entity to its latest versioned DTO
981        /// 2. Serializes to the configured format (JSON/TOML)
982        /// 3. Writes atomically using temporary file + rename
983        ///
984        /// # Errors
985        ///
986        /// Returns error if:
987        /// - Entity name not registered in migrator
988        /// - ID contains invalid characters (for Direct encoding)
989        /// - Serialization fails
990        /// - File write fails
991        pub async fn save<T>(
992            &self,
993            entity_name: &str,
994            id: &str,
995            entity: T,
996        ) -> Result<(), MigrationError>
997        where
998            T: serde::Serialize,
999        {
1000            // Convert entity to latest versioned DTO and get JSON string
1001            let json_string = self.migrator.save_domain_flat(entity_name, entity)?;
1002
1003            // Parse back to JSON value for format conversion
1004            let versioned_value: serde_json::Value = serde_json::from_str(&json_string)
1005                .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
1006
1007            // Serialize to target format (JSON or TOML)
1008            let content = self.serialize_content(&versioned_value)?;
1009
1010            // Get target file path
1011            let file_path = self.id_to_path(id)?;
1012
1013            // Write atomically (async)
1014            self.atomic_write(&file_path, &content).await?;
1015
1016            Ok(())
1017        }
1018
1019        /// Load an entity from a file (async).
1020        ///
1021        /// # Arguments
1022        ///
1023        /// * `entity_name` - The entity name registered in the migrator
1024        /// * `id` - The unique identifier for the entity
1025        ///
1026        /// # Process
1027        ///
1028        /// 1. Gets the file path using `id_to_path`
1029        /// 2. Reads the file content to a string (async)
1030        /// 3. Deserializes the content to a `serde_json::Value`
1031        /// 4. Migrates the `Value` to the target domain type
1032        ///
1033        /// # Errors
1034        ///
1035        /// Returns error if:
1036        /// - Entity name not registered in migrator
1037        /// - File not found
1038        /// - Deserialization fails
1039        /// - Migration fails
1040        pub async fn load<D>(&self, entity_name: &str, id: &str) -> Result<D, MigrationError>
1041        where
1042            D: serde::de::DeserializeOwned,
1043        {
1044            // Get file path
1045            let file_path = self.id_to_path(id)?;
1046
1047            // Check if file exists (async)
1048            if !tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
1049                return Err(MigrationError::IoError {
1050                    operation: IoOperationKind::Read,
1051                    path: file_path.display().to_string(),
1052                    context: Some("async".to_string()),
1053                    error: "File not found".to_string(),
1054                });
1055            }
1056
1057            // Read file content (async)
1058            let content = tokio::fs::read_to_string(&file_path).await.map_err(|e| {
1059                MigrationError::IoError {
1060                    operation: IoOperationKind::Read,
1061                    path: file_path.display().to_string(),
1062                    context: Some("async".to_string()),
1063                    error: e.to_string(),
1064                }
1065            })?;
1066
1067            // Deserialize content to JSON value
1068            let value = self.deserialize_content(&content)?;
1069
1070            // Migrate to domain type using load_flat_from
1071            self.migrator.load_flat_from(entity_name, value)
1072        }
1073
1074        /// List all entity IDs in the storage directory (async).
1075        ///
1076        /// # Returns
1077        ///
1078        /// A sorted vector of entity IDs (decoded from filenames).
1079        ///
1080        /// # Errors
1081        ///
1082        /// Returns error if:
1083        /// - Directory read fails
1084        /// - Filename decoding fails
1085        pub async fn list_ids(&self) -> Result<Vec<String>, MigrationError> {
1086            // Read directory (async)
1087            let mut entries = tokio::fs::read_dir(&self.base_path).await.map_err(|e| {
1088                MigrationError::IoError {
1089                    operation: IoOperationKind::ReadDir,
1090                    path: self.base_path.display().to_string(),
1091                    context: Some("async".to_string()),
1092                    error: e.to_string(),
1093                }
1094            })?;
1095
1096            let extension = self.strategy.get_extension();
1097            let mut ids = Vec::new();
1098
1099            while let Some(entry) =
1100                entries
1101                    .next_entry()
1102                    .await
1103                    .map_err(|e| MigrationError::IoError {
1104                        operation: IoOperationKind::ReadDir,
1105                        path: self.base_path.display().to_string(),
1106                        context: Some("directory entry (async)".to_string()),
1107                        error: e.to_string(),
1108                    })?
1109            {
1110                let path = entry.path();
1111
1112                // Check if it's a file with the correct extension
1113                let metadata =
1114                    tokio::fs::metadata(&path)
1115                        .await
1116                        .map_err(|e| MigrationError::IoError {
1117                            operation: IoOperationKind::Read,
1118                            path: path.display().to_string(),
1119                            context: Some("metadata (async)".to_string()),
1120                            error: e.to_string(),
1121                        })?;
1122
1123                if metadata.is_file() {
1124                    if let Some(ext) = path.extension() {
1125                        if ext == extension.as_str() {
1126                            // Extract ID from filename
1127                            if let Some(id) = self.path_to_id(&path)? {
1128                                ids.push(id);
1129                            }
1130                        }
1131                    }
1132                }
1133            }
1134
1135            // Sort IDs for consistent ordering
1136            ids.sort();
1137            Ok(ids)
1138        }
1139
1140        /// Load all entities from the storage directory (async).
1141        ///
1142        /// # Arguments
1143        ///
1144        /// * `entity_name` - The entity name registered in the migrator
1145        ///
1146        /// # Returns
1147        ///
1148        /// A vector of `(id, entity)` tuples.
1149        ///
1150        /// # Errors
1151        ///
1152        /// Returns error if any entity fails to load. This operation is atomic:
1153        /// if any load fails, the whole operation fails.
1154        pub async fn load_all<D>(
1155            &self,
1156            entity_name: &str,
1157        ) -> Result<Vec<(String, D)>, MigrationError>
1158        where
1159            D: serde::de::DeserializeOwned,
1160        {
1161            let ids = self.list_ids().await?;
1162            let mut results = Vec::new();
1163
1164            for id in ids {
1165                let entity = self.load(entity_name, &id).await?;
1166                results.push((id, entity));
1167            }
1168
1169            Ok(results)
1170        }
1171
1172        /// Check if an entity exists (async).
1173        ///
1174        /// # Arguments
1175        ///
1176        /// * `id` - The entity ID
1177        ///
1178        /// # Returns
1179        ///
1180        /// `true` if the file exists and is a file, `false` otherwise.
1181        pub async fn exists(&self, id: &str) -> Result<bool, MigrationError> {
1182            let file_path = self.id_to_path(id)?;
1183
1184            if !tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
1185                return Ok(false);
1186            }
1187
1188            let metadata =
1189                tokio::fs::metadata(&file_path)
1190                    .await
1191                    .map_err(|e| MigrationError::IoError {
1192                        operation: IoOperationKind::Read,
1193                        path: file_path.display().to_string(),
1194                        context: Some("metadata (async)".to_string()),
1195                        error: e.to_string(),
1196                    })?;
1197
1198            Ok(metadata.is_file())
1199        }
1200
1201        /// Delete an entity file (async).
1202        ///
1203        /// # Arguments
1204        ///
1205        /// * `id` - The entity ID
1206        ///
1207        /// # Behavior
1208        ///
1209        /// This operation is idempotent: deleting a non-existent file is not an error.
1210        ///
1211        /// # Errors
1212        ///
1213        /// Returns error if file deletion fails (but not if file doesn't exist).
1214        pub async fn delete(&self, id: &str) -> Result<(), MigrationError> {
1215            let file_path = self.id_to_path(id)?;
1216
1217            if tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
1218                tokio::fs::remove_file(&file_path)
1219                    .await
1220                    .map_err(|e| MigrationError::IoError {
1221                        operation: IoOperationKind::Delete,
1222                        path: file_path.display().to_string(),
1223                        context: Some("async".to_string()),
1224                        error: e.to_string(),
1225                    })?;
1226            }
1227
1228            Ok(())
1229        }
1230
1231        /// Returns a reference to the base directory path.
1232        ///
1233        /// # Returns
1234        ///
1235        /// A reference to the resolved base directory path where entities are stored.
1236        pub fn base_path(&self) -> &Path {
1237            &self.base_path
1238        }
1239
1240        // ====================================================================
1241        // Private helper methods (same as sync version but async where needed)
1242        // ====================================================================
1243
1244        /// Convert an entity ID to a file path.
1245        fn id_to_path(&self, id: &str) -> Result<PathBuf, MigrationError> {
1246            let encoded_id = self.encode_id(id)?;
1247            let extension = self.strategy.get_extension();
1248            let filename = format!("{}.{}", encoded_id, extension);
1249            Ok(self.base_path.join(filename))
1250        }
1251
1252        /// Encode an entity ID to a filesystem-safe filename.
1253        fn encode_id(&self, id: &str) -> Result<String, MigrationError> {
1254            match self.strategy.filename_encoding {
1255                FilenameEncoding::Direct => {
1256                    // Validate that ID contains only safe characters
1257                    if id
1258                        .chars()
1259                        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1260                    {
1261                        Ok(id.to_string())
1262                    } else {
1263                        Err(MigrationError::FilenameEncoding {
1264                            id: id.to_string(),
1265                            reason: "ID contains invalid characters for Direct encoding. Only alphanumeric, '-', and '_' are allowed.".to_string(),
1266                        })
1267                    }
1268                }
1269                FilenameEncoding::UrlEncode => {
1270                    // URL-encode the ID for filesystem safety
1271                    Ok(urlencoding::encode(id).into_owned())
1272                }
1273                FilenameEncoding::Base64 => {
1274                    // Base64-encode the ID using URL-safe encoding without padding
1275                    Ok(URL_SAFE_NO_PAD.encode(id.as_bytes()))
1276                }
1277            }
1278        }
1279
1280        /// Serialize a JSON value to string based on the configured format.
1281        fn serialize_content(&self, value: &serde_json::Value) -> Result<String, MigrationError> {
1282            match self.strategy.format {
1283                FormatStrategy::Json => serde_json::to_string_pretty(value)
1284                    .map_err(|e| MigrationError::SerializationError(e.to_string())),
1285                FormatStrategy::Toml => {
1286                    let toml_value = json_to_toml(value)?;
1287                    toml::to_string_pretty(&toml_value)
1288                        .map_err(|e| MigrationError::TomlSerializeError(e.to_string()))
1289                }
1290            }
1291        }
1292
1293        /// Write content to a file atomically (async).
1294        ///
1295        /// Uses the "temporary file + fsync + atomic rename" pattern to ensure
1296        /// durability and atomicity.
1297        async fn atomic_write(&self, path: &Path, content: &str) -> Result<(), MigrationError> {
1298            // Ensure parent directory exists
1299            if let Some(parent) = path.parent() {
1300                if !tokio::fs::try_exists(parent).await.unwrap_or(false) {
1301                    tokio::fs::create_dir_all(parent).await.map_err(|e| {
1302                        MigrationError::IoError {
1303                            operation: IoOperationKind::CreateDir,
1304                            path: parent.display().to_string(),
1305                            context: Some("parent directory (async)".to_string()),
1306                            error: e.to_string(),
1307                        }
1308                    })?;
1309                }
1310            }
1311
1312            // Create temporary file path
1313            let tmp_path = self.get_temp_path(path)?;
1314
1315            // Write to temporary file (async)
1316            let mut tmp_file =
1317                tokio::fs::File::create(&tmp_path)
1318                    .await
1319                    .map_err(|e| MigrationError::IoError {
1320                        operation: IoOperationKind::Create,
1321                        path: tmp_path.display().to_string(),
1322                        context: Some("temporary file (async)".to_string()),
1323                        error: e.to_string(),
1324                    })?;
1325
1326            tmp_file
1327                .write_all(content.as_bytes())
1328                .await
1329                .map_err(|e| MigrationError::IoError {
1330                    operation: IoOperationKind::Write,
1331                    path: tmp_path.display().to_string(),
1332                    context: Some("temporary file (async)".to_string()),
1333                    error: e.to_string(),
1334                })?;
1335
1336            // Ensure data is written to disk
1337            tmp_file
1338                .sync_all()
1339                .await
1340                .map_err(|e| MigrationError::IoError {
1341                    operation: IoOperationKind::Sync,
1342                    path: tmp_path.display().to_string(),
1343                    context: Some("temporary file (async)".to_string()),
1344                    error: e.to_string(),
1345                })?;
1346
1347            drop(tmp_file);
1348
1349            // Atomic rename with retry (async)
1350            self.atomic_rename(&tmp_path, path).await?;
1351
1352            // Cleanup old temp files (best effort)
1353            if self.strategy.atomic_write.cleanup_tmp_files {
1354                let _ = self.cleanup_temp_files(path).await;
1355            }
1356
1357            Ok(())
1358        }
1359
1360        /// Get path to temporary file for atomic writes.
1361        fn get_temp_path(&self, target_path: &Path) -> Result<PathBuf, MigrationError> {
1362            let parent = target_path.parent().ok_or_else(|| {
1363                MigrationError::PathResolution("Path has no parent directory".to_string())
1364            })?;
1365
1366            let file_name = target_path.file_name().ok_or_else(|| {
1367                MigrationError::PathResolution("Path has no file name".to_string())
1368            })?;
1369
1370            let tmp_name = format!(
1371                ".{}.tmp.{}",
1372                file_name.to_string_lossy(),
1373                std::process::id()
1374            );
1375            Ok(parent.join(tmp_name))
1376        }
1377
1378        /// Atomically rename temporary file to target path with retry (async).
1379        async fn atomic_rename(
1380            &self,
1381            tmp_path: &Path,
1382            target_path: &Path,
1383        ) -> Result<(), MigrationError> {
1384            let mut last_error = None;
1385
1386            for attempt in 0..self.strategy.atomic_write.retry_count {
1387                match tokio::fs::rename(tmp_path, target_path).await {
1388                    Ok(()) => return Ok(()),
1389                    Err(e) => {
1390                        last_error = Some(e);
1391                        if attempt + 1 < self.strategy.atomic_write.retry_count {
1392                            // Small delay before retry
1393                            tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
1394                        }
1395                    }
1396                }
1397            }
1398
1399            Err(MigrationError::IoError {
1400                operation: IoOperationKind::Rename,
1401                path: target_path.display().to_string(),
1402                context: Some(format!(
1403                    "after {} retries (async)",
1404                    self.strategy.atomic_write.retry_count
1405                )),
1406                error: last_error.unwrap().to_string(),
1407            })
1408        }
1409
1410        /// Clean up old temporary files (best effort, async).
1411        async fn cleanup_temp_files(&self, target_path: &Path) -> std::io::Result<()> {
1412            let parent = match target_path.parent() {
1413                Some(p) => p,
1414                None => return Ok(()),
1415            };
1416
1417            let file_name = match target_path.file_name() {
1418                Some(f) => f.to_string_lossy(),
1419                None => return Ok(()),
1420            };
1421
1422            let prefix = format!(".{}.tmp.", file_name);
1423
1424            let mut entries = tokio::fs::read_dir(parent).await?;
1425            while let Some(entry) = entries.next_entry().await? {
1426                if let Ok(name) = entry.file_name().into_string() {
1427                    if name.starts_with(&prefix) {
1428                        // Try to remove, but ignore errors (best effort)
1429                        let _ = tokio::fs::remove_file(entry.path()).await;
1430                    }
1431                }
1432            }
1433
1434            Ok(())
1435        }
1436
1437        /// Deserialize file content to a JSON value.
1438        fn deserialize_content(&self, content: &str) -> Result<serde_json::Value, MigrationError> {
1439            match self.strategy.format {
1440                FormatStrategy::Json => serde_json::from_str(content)
1441                    .map_err(|e| MigrationError::DeserializationError(e.to_string())),
1442                FormatStrategy::Toml => {
1443                    let toml_value: toml::Value = toml::from_str(content)
1444                        .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
1445                    toml_to_json(toml_value)
1446                }
1447            }
1448        }
1449
1450        /// Extract the entity ID from a file path.
1451        fn path_to_id(&self, path: &Path) -> Result<Option<String>, MigrationError> {
1452            // Get file stem (filename without extension)
1453            let file_stem = match path.file_stem() {
1454                Some(stem) => stem.to_string_lossy(),
1455                None => return Ok(None),
1456            };
1457
1458            // Decode ID
1459            let id = self.decode_id(&file_stem)?;
1460            Ok(Some(id))
1461        }
1462
1463        /// Decode a filename stem to an entity ID.
1464        fn decode_id(&self, filename_stem: &str) -> Result<String, MigrationError> {
1465            match self.strategy.filename_encoding {
1466                FilenameEncoding::Direct => {
1467                    // Direct encoding: filename is the ID
1468                    Ok(filename_stem.to_string())
1469                }
1470                FilenameEncoding::UrlEncode => {
1471                    // URL-decode the filename to get the original ID
1472                    urlencoding::decode(filename_stem)
1473                        .map(|s| s.into_owned())
1474                        .map_err(|e| MigrationError::FilenameEncoding {
1475                            id: filename_stem.to_string(),
1476                            reason: format!("Failed to URL-decode filename: {}", e),
1477                        })
1478                }
1479                FilenameEncoding::Base64 => {
1480                    // Base64-decode the filename using URL-safe encoding without padding
1481                    URL_SAFE_NO_PAD
1482                        .decode(filename_stem.as_bytes())
1483                        .map_err(|e| MigrationError::FilenameEncoding {
1484                            id: filename_stem.to_string(),
1485                            reason: format!("Failed to Base64-decode filename: {}", e),
1486                        })
1487                        .and_then(|bytes| {
1488                            String::from_utf8(bytes).map_err(|e| MigrationError::FilenameEncoding {
1489                                id: filename_stem.to_string(),
1490                                reason: format!(
1491                                    "Failed to convert Base64-decoded bytes to UTF-8: {}",
1492                                    e
1493                                ),
1494                            })
1495                        })
1496                }
1497            }
1498        }
1499    }
1500
1501    // Async tests
1502    #[cfg(all(test, feature = "async"))]
1503    mod async_tests {
1504        use super::*;
1505        use crate::{FromDomain, IntoDomain, MigratesTo, Versioned};
1506        use serde::{Deserialize, Serialize};
1507        use tempfile::TempDir;
1508
1509        // Test entity types (reused from sync tests)
1510        #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1511        struct SessionV1_0_0 {
1512            id: String,
1513            user_id: String,
1514        }
1515
1516        impl Versioned for SessionV1_0_0 {
1517            const VERSION: &'static str = "1.0.0";
1518        }
1519
1520        #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1521        struct SessionV1_1_0 {
1522            id: String,
1523            user_id: String,
1524            created_at: Option<String>,
1525        }
1526
1527        impl Versioned for SessionV1_1_0 {
1528            const VERSION: &'static str = "1.1.0";
1529        }
1530
1531        impl MigratesTo<SessionV1_1_0> for SessionV1_0_0 {
1532            fn migrate(self) -> SessionV1_1_0 {
1533                SessionV1_1_0 {
1534                    id: self.id,
1535                    user_id: self.user_id,
1536                    created_at: None,
1537                }
1538            }
1539        }
1540
1541        #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1542        struct SessionEntity {
1543            id: String,
1544            user_id: String,
1545            created_at: Option<String>,
1546        }
1547
1548        impl IntoDomain<SessionEntity> for SessionV1_1_0 {
1549            fn into_domain(self) -> SessionEntity {
1550                SessionEntity {
1551                    id: self.id,
1552                    user_id: self.user_id,
1553                    created_at: self.created_at,
1554                }
1555            }
1556        }
1557
1558        impl FromDomain<SessionEntity> for SessionV1_1_0 {
1559            fn from_domain(domain: SessionEntity) -> Self {
1560                SessionV1_1_0 {
1561                    id: domain.id,
1562                    user_id: domain.user_id,
1563                    created_at: domain.created_at,
1564                }
1565            }
1566        }
1567
1568        fn setup_session_migrator() -> Migrator {
1569            let path = Migrator::define("session")
1570                .from::<SessionV1_0_0>()
1571                .step::<SessionV1_1_0>()
1572                .into_with_save::<SessionEntity>();
1573
1574            let mut migrator = Migrator::new();
1575            migrator.register(path).unwrap();
1576            migrator
1577        }
1578
1579        #[tokio::test]
1580        async fn test_async_dir_storage_new_creates_directory() {
1581            let temp_dir = TempDir::new().unwrap();
1582            let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1583                temp_dir.path().to_path_buf(),
1584            ));
1585
1586            let migrator = Migrator::new();
1587            let strategy = DirStorageStrategy::default();
1588
1589            let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1590                .await
1591                .unwrap();
1592
1593            // Verify directory was created
1594            assert!(storage.base_path.exists());
1595            assert!(storage.base_path.is_dir());
1596            assert!(storage.base_path.ends_with("data/testapp/sessions"));
1597        }
1598
1599        #[tokio::test]
1600        async fn test_async_dir_storage_save_and_load_roundtrip() {
1601            let temp_dir = TempDir::new().unwrap();
1602            let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1603                temp_dir.path().to_path_buf(),
1604            ));
1605
1606            let migrator = setup_session_migrator();
1607            let strategy = DirStorageStrategy::default();
1608            let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1609                .await
1610                .unwrap();
1611
1612            // Test multiple sessions
1613            let sessions = vec![
1614                SessionEntity {
1615                    id: "session-1".to_string(),
1616                    user_id: "user-1".to_string(),
1617                    created_at: Some("2024-01-01".to_string()),
1618                },
1619                SessionEntity {
1620                    id: "session-2".to_string(),
1621                    user_id: "user-2".to_string(),
1622                    created_at: None,
1623                },
1624                SessionEntity {
1625                    id: "session-3".to_string(),
1626                    user_id: "user-3".to_string(),
1627                    created_at: Some("2024-03-01".to_string()),
1628                },
1629            ];
1630
1631            // Save all sessions
1632            for session in &sessions {
1633                storage
1634                    .save("session", &session.id, session.clone())
1635                    .await
1636                    .unwrap();
1637            }
1638
1639            // Load and verify each session
1640            for session in &sessions {
1641                let loaded: SessionEntity = storage.load("session", &session.id).await.unwrap();
1642                assert_eq!(loaded.id, session.id);
1643                assert_eq!(loaded.user_id, session.user_id);
1644                assert_eq!(loaded.created_at, session.created_at);
1645            }
1646        }
1647
1648        #[tokio::test]
1649        async fn test_async_dir_storage_list_ids() {
1650            let temp_dir = TempDir::new().unwrap();
1651            let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1652                temp_dir.path().to_path_buf(),
1653            ));
1654
1655            let migrator = setup_session_migrator();
1656            let strategy = DirStorageStrategy::default();
1657            let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1658                .await
1659                .unwrap();
1660
1661            // Save multiple sessions
1662            let ids = vec!["session-c", "session-a", "session-b"];
1663            for id in &ids {
1664                let session = SessionEntity {
1665                    id: id.to_string(),
1666                    user_id: "user".to_string(),
1667                    created_at: None,
1668                };
1669                storage.save("session", id, session).await.unwrap();
1670            }
1671
1672            // List IDs
1673            let listed_ids = storage.list_ids().await.unwrap();
1674            assert_eq!(listed_ids.len(), 3);
1675            // Should be sorted
1676            assert_eq!(listed_ids, vec!["session-a", "session-b", "session-c"]);
1677        }
1678
1679        #[tokio::test]
1680        async fn test_async_dir_storage_load_all() {
1681            let temp_dir = TempDir::new().unwrap();
1682            let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1683                temp_dir.path().to_path_buf(),
1684            ));
1685
1686            let migrator = setup_session_migrator();
1687            let strategy = DirStorageStrategy::default();
1688            let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1689                .await
1690                .unwrap();
1691
1692            // Save multiple sessions
1693            let sessions = vec![
1694                SessionEntity {
1695                    id: "session-x".to_string(),
1696                    user_id: "user-x".to_string(),
1697                    created_at: Some("2024-01-01".to_string()),
1698                },
1699                SessionEntity {
1700                    id: "session-y".to_string(),
1701                    user_id: "user-y".to_string(),
1702                    created_at: None,
1703                },
1704                SessionEntity {
1705                    id: "session-z".to_string(),
1706                    user_id: "user-z".to_string(),
1707                    created_at: Some("2024-03-01".to_string()),
1708                },
1709            ];
1710
1711            for session in &sessions {
1712                storage
1713                    .save("session", &session.id, session.clone())
1714                    .await
1715                    .unwrap();
1716            }
1717
1718            // Load all
1719            let results: Vec<(String, SessionEntity)> = storage.load_all("session").await.unwrap();
1720            assert_eq!(results.len(), 3);
1721
1722            // Verify all sessions are loaded
1723            for (id, loaded) in &results {
1724                let original = sessions.iter().find(|s| &s.id == id).unwrap();
1725                assert_eq!(loaded.id, original.id);
1726                assert_eq!(loaded.user_id, original.user_id);
1727                assert_eq!(loaded.created_at, original.created_at);
1728            }
1729        }
1730
1731        #[tokio::test]
1732        async fn test_async_dir_storage_delete() {
1733            let temp_dir = TempDir::new().unwrap();
1734            let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1735                temp_dir.path().to_path_buf(),
1736            ));
1737
1738            let migrator = setup_session_migrator();
1739            let strategy = DirStorageStrategy::default();
1740            let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1741                .await
1742                .unwrap();
1743
1744            // Save a session
1745            let session = SessionEntity {
1746                id: "session-delete".to_string(),
1747                user_id: "user-delete".to_string(),
1748                created_at: None,
1749            };
1750            storage
1751                .save("session", "session-delete", session)
1752                .await
1753                .unwrap();
1754
1755            // Verify it exists
1756            assert!(storage.exists("session-delete").await.unwrap());
1757
1758            // Delete it
1759            storage.delete("session-delete").await.unwrap();
1760
1761            // Verify it doesn't exist
1762            assert!(!storage.exists("session-delete").await.unwrap());
1763        }
1764
1765        #[tokio::test]
1766        async fn test_async_dir_storage_filename_encoding_url_roundtrip() {
1767            let temp_dir = TempDir::new().unwrap();
1768            let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1769                temp_dir.path().to_path_buf(),
1770            ));
1771
1772            let migrator = setup_session_migrator();
1773            let strategy =
1774                DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
1775            let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1776                .await
1777                .unwrap();
1778
1779            // Use an ID with special characters that need URL encoding
1780            let complex_id = "user@example.com/path?query=1";
1781            let session = SessionEntity {
1782                id: complex_id.to_string(),
1783                user_id: "user-special".to_string(),
1784                created_at: Some("2024-05-01".to_string()),
1785            };
1786
1787            // Save the entity
1788            storage
1789                .save("session", complex_id, session.clone())
1790                .await
1791                .unwrap();
1792
1793            // Verify the file was created with encoded filename
1794            let encoded_id = urlencoding::encode(complex_id);
1795            let file_path = storage.base_path.join(format!("{}.json", encoded_id));
1796            assert!(file_path.exists());
1797
1798            // Load it back
1799            let loaded: SessionEntity = storage.load("session", complex_id).await.unwrap();
1800            assert_eq!(loaded.id, session.id);
1801            assert_eq!(loaded.user_id, session.user_id);
1802            assert_eq!(loaded.created_at, session.created_at);
1803
1804            // Verify list_ids works correctly
1805            let ids = storage.list_ids().await.unwrap();
1806            assert_eq!(ids.len(), 1);
1807            assert_eq!(ids[0], complex_id);
1808        }
1809
1810        #[tokio::test]
1811        async fn test_async_dir_storage_filename_encoding_base64_roundtrip() {
1812            let temp_dir = TempDir::new().unwrap();
1813            let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1814                temp_dir.path().to_path_buf(),
1815            ));
1816
1817            let migrator = setup_session_migrator();
1818            let strategy =
1819                DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
1820            let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1821                .await
1822                .unwrap();
1823
1824            // Use a complex ID with various special characters
1825            let complex_id = "user@example.com/path?query=1&special=!@#$%";
1826            let session = SessionEntity {
1827                id: complex_id.to_string(),
1828                user_id: "user-base64".to_string(),
1829                created_at: Some("2024-06-01".to_string()),
1830            };
1831
1832            // Save the entity
1833            storage
1834                .save("session", complex_id, session.clone())
1835                .await
1836                .unwrap();
1837
1838            // Verify the file was created with Base64-encoded filename
1839            let encoded_id = URL_SAFE_NO_PAD.encode(complex_id.as_bytes());
1840            let file_path = storage.base_path.join(format!("{}.json", encoded_id));
1841            assert!(file_path.exists());
1842
1843            // Load it back
1844            let loaded: SessionEntity = storage.load("session", complex_id).await.unwrap();
1845            assert_eq!(loaded.id, session.id);
1846            assert_eq!(loaded.user_id, session.user_id);
1847            assert_eq!(loaded.created_at, session.created_at);
1848
1849            // Verify list_ids works correctly
1850            let ids = storage.list_ids().await.unwrap();
1851            assert_eq!(ids.len(), 1);
1852            assert_eq!(ids[0], complex_id);
1853        }
1854    }
1855}
1856
1857#[cfg(test)]
1858mod tests {
1859    use super::*;
1860    use tempfile::TempDir;
1861
1862    #[test]
1863    fn test_filename_encoding_default() {
1864        assert_eq!(FilenameEncoding::default(), FilenameEncoding::Direct);
1865    }
1866
1867    #[test]
1868    fn test_dir_storage_strategy_default() {
1869        let strategy = DirStorageStrategy::default();
1870        assert_eq!(strategy.format, FormatStrategy::Json);
1871        assert_eq!(strategy.extension, None);
1872        assert_eq!(strategy.filename_encoding, FilenameEncoding::Direct);
1873    }
1874
1875    #[test]
1876    fn test_dir_storage_strategy_builder() {
1877        let strategy = DirStorageStrategy::new()
1878            .with_format(FormatStrategy::Toml)
1879            .with_extension("data")
1880            .with_filename_encoding(FilenameEncoding::Base64)
1881            .with_retry_count(5)
1882            .with_cleanup(false);
1883
1884        assert_eq!(strategy.format, FormatStrategy::Toml);
1885        assert_eq!(strategy.extension, Some("data".to_string()));
1886        assert_eq!(strategy.filename_encoding, FilenameEncoding::Base64);
1887        assert_eq!(strategy.atomic_write.retry_count, 5);
1888        assert!(!strategy.atomic_write.cleanup_tmp_files);
1889    }
1890
1891    #[test]
1892    fn test_dir_storage_strategy_get_extension() {
1893        // Default from JSON format
1894        let strategy1 = DirStorageStrategy::default();
1895        assert_eq!(strategy1.get_extension(), "json");
1896
1897        // Default from TOML format
1898        let strategy2 = DirStorageStrategy::default().with_format(FormatStrategy::Toml);
1899        assert_eq!(strategy2.get_extension(), "toml");
1900
1901        // Custom extension
1902        let strategy3 = DirStorageStrategy::default().with_extension("custom");
1903        assert_eq!(strategy3.get_extension(), "custom");
1904    }
1905
1906    #[test]
1907    fn test_dir_storage_new_creates_directory() {
1908        let temp_dir = TempDir::new().unwrap();
1909        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1910            temp_dir.path().to_path_buf(),
1911        ));
1912
1913        let migrator = Migrator::new();
1914        let strategy = DirStorageStrategy::default();
1915
1916        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1917
1918        // Verify directory was created
1919        assert!(storage.base_path.exists());
1920        assert!(storage.base_path.is_dir());
1921        assert!(storage.base_path.ends_with("data/testapp/sessions"));
1922    }
1923
1924    #[test]
1925    fn test_dir_storage_new_idempotent() {
1926        let temp_dir = TempDir::new().unwrap();
1927        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1928            temp_dir.path().to_path_buf(),
1929        ));
1930
1931        let migrator1 = Migrator::new();
1932        let migrator2 = Migrator::new();
1933        let strategy = DirStorageStrategy::default();
1934
1935        // Create storage twice
1936        let storage1 =
1937            DirStorage::new(paths.clone(), "sessions", migrator1, strategy.clone()).unwrap();
1938        let storage2 = DirStorage::new(paths, "sessions", migrator2, strategy).unwrap();
1939
1940        // Both should succeed and point to the same directory
1941        assert_eq!(storage1.base_path, storage2.base_path);
1942    }
1943
1944    // Test entity types for save tests
1945    use crate::{FromDomain, IntoDomain, MigratesTo, Versioned};
1946    use serde::{Deserialize, Serialize};
1947
1948    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1949    struct SessionV1_0_0 {
1950        id: String,
1951        user_id: String,
1952    }
1953
1954    impl Versioned for SessionV1_0_0 {
1955        const VERSION: &'static str = "1.0.0";
1956    }
1957
1958    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1959    struct SessionV1_1_0 {
1960        id: String,
1961        user_id: String,
1962        created_at: Option<String>,
1963    }
1964
1965    impl Versioned for SessionV1_1_0 {
1966        const VERSION: &'static str = "1.1.0";
1967    }
1968
1969    impl MigratesTo<SessionV1_1_0> for SessionV1_0_0 {
1970        fn migrate(self) -> SessionV1_1_0 {
1971            SessionV1_1_0 {
1972                id: self.id,
1973                user_id: self.user_id,
1974                created_at: None,
1975            }
1976        }
1977    }
1978
1979    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1980    struct SessionEntity {
1981        id: String,
1982        user_id: String,
1983        created_at: Option<String>,
1984    }
1985
1986    impl IntoDomain<SessionEntity> for SessionV1_1_0 {
1987        fn into_domain(self) -> SessionEntity {
1988            SessionEntity {
1989                id: self.id,
1990                user_id: self.user_id,
1991                created_at: self.created_at,
1992            }
1993        }
1994    }
1995
1996    impl FromDomain<SessionEntity> for SessionV1_1_0 {
1997        fn from_domain(domain: SessionEntity) -> Self {
1998            SessionV1_1_0 {
1999                id: domain.id,
2000                user_id: domain.user_id,
2001                created_at: domain.created_at,
2002            }
2003        }
2004    }
2005
2006    fn setup_session_migrator() -> Migrator {
2007        let path = Migrator::define("session")
2008            .from::<SessionV1_0_0>()
2009            .step::<SessionV1_1_0>()
2010            .into_with_save::<SessionEntity>();
2011
2012        let mut migrator = Migrator::new();
2013        migrator.register(path).unwrap();
2014        migrator
2015    }
2016
2017    #[test]
2018    fn test_dir_storage_save_json() {
2019        let temp_dir = TempDir::new().unwrap();
2020        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2021            temp_dir.path().to_path_buf(),
2022        ));
2023
2024        let migrator = setup_session_migrator();
2025        let strategy = DirStorageStrategy::default().with_format(FormatStrategy::Json);
2026        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2027
2028        // Create a session entity
2029        let session = SessionEntity {
2030            id: "session-123".to_string(),
2031            user_id: "user-456".to_string(),
2032            created_at: Some("2024-01-01T00:00:00Z".to_string()),
2033        };
2034
2035        // Save the entity
2036        storage.save("session", "session-123", session).unwrap();
2037
2038        // Verify file was created
2039        let file_path = storage.base_path.join("session-123.json");
2040        assert!(file_path.exists());
2041
2042        // Verify content is valid JSON with version
2043        let content = std::fs::read_to_string(&file_path).unwrap();
2044        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
2045        assert_eq!(json["version"], "1.1.0");
2046        assert_eq!(json["id"], "session-123");
2047        assert_eq!(json["user_id"], "user-456");
2048    }
2049
2050    #[test]
2051    fn test_dir_storage_save_toml() {
2052        let temp_dir = TempDir::new().unwrap();
2053        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2054            temp_dir.path().to_path_buf(),
2055        ));
2056
2057        let migrator = setup_session_migrator();
2058        let strategy = DirStorageStrategy::default().with_format(FormatStrategy::Toml);
2059        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2060
2061        // Create a session entity with Some value (TOML doesn't support None/null)
2062        let session = SessionEntity {
2063            id: "session-789".to_string(),
2064            user_id: "user-101".to_string(),
2065            created_at: Some("2024-01-15T10:30:00Z".to_string()),
2066        };
2067
2068        // Save the entity
2069        storage.save("session", "session-789", session).unwrap();
2070
2071        // Verify file was created
2072        let file_path = storage.base_path.join("session-789.toml");
2073        assert!(file_path.exists());
2074
2075        // Verify content is valid TOML with version
2076        let content = std::fs::read_to_string(&file_path).unwrap();
2077        let toml: toml::Value = toml::from_str(&content).unwrap();
2078        assert_eq!(toml["version"].as_str().unwrap(), "1.1.0");
2079        assert_eq!(toml["id"].as_str().unwrap(), "session-789");
2080        assert_eq!(toml["created_at"].as_str().unwrap(), "2024-01-15T10:30:00Z");
2081    }
2082
2083    #[test]
2084    fn test_dir_storage_save_with_invalid_id() {
2085        let temp_dir = TempDir::new().unwrap();
2086        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2087            temp_dir.path().to_path_buf(),
2088        ));
2089
2090        let migrator = setup_session_migrator();
2091        let strategy = DirStorageStrategy::default();
2092        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2093
2094        let session = SessionEntity {
2095            id: "invalid/id".to_string(),
2096            user_id: "user-456".to_string(),
2097            created_at: None,
2098        };
2099
2100        // Should fail due to invalid characters in ID
2101        let result = storage.save("session", "invalid/id", session);
2102        assert!(result.is_err());
2103        assert!(matches!(
2104            result.unwrap_err(),
2105            crate::MigrationError::FilenameEncoding { .. }
2106        ));
2107    }
2108
2109    #[test]
2110    fn test_dir_storage_save_with_custom_extension() {
2111        let temp_dir = TempDir::new().unwrap();
2112        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2113            temp_dir.path().to_path_buf(),
2114        ));
2115
2116        let migrator = setup_session_migrator();
2117        let strategy = DirStorageStrategy::default()
2118            .with_format(FormatStrategy::Json)
2119            .with_extension("data");
2120        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2121
2122        let session = SessionEntity {
2123            id: "session-custom".to_string(),
2124            user_id: "user-999".to_string(),
2125            created_at: None,
2126        };
2127
2128        storage.save("session", "session-custom", session).unwrap();
2129
2130        // Verify custom extension is used
2131        let file_path = storage.base_path.join("session-custom.data");
2132        assert!(file_path.exists());
2133    }
2134
2135    #[test]
2136    fn test_dir_storage_save_overwrites_existing() {
2137        let temp_dir = TempDir::new().unwrap();
2138        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2139            temp_dir.path().to_path_buf(),
2140        ));
2141
2142        let migrator = setup_session_migrator();
2143        let strategy = DirStorageStrategy::default();
2144        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2145
2146        // Save initial version
2147        let session1 = SessionEntity {
2148            id: "session-overwrite".to_string(),
2149            user_id: "user-111".to_string(),
2150            created_at: Some("2024-01-01".to_string()),
2151        };
2152        storage
2153            .save("session", "session-overwrite", session1)
2154            .unwrap();
2155
2156        // Save updated version
2157        let session2 = SessionEntity {
2158            id: "session-overwrite".to_string(),
2159            user_id: "user-222".to_string(),
2160            created_at: Some("2024-01-02".to_string()),
2161        };
2162        storage
2163            .save("session", "session-overwrite", session2)
2164            .unwrap();
2165
2166        // Verify file was overwritten
2167        let file_path = storage.base_path.join("session-overwrite.json");
2168        let content = std::fs::read_to_string(&file_path).unwrap();
2169        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
2170        assert_eq!(json["user_id"], "user-222");
2171        assert_eq!(json["created_at"], "2024-01-02");
2172    }
2173
2174    #[test]
2175    fn test_dir_storage_load_success() {
2176        let temp_dir = TempDir::new().unwrap();
2177        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2178            temp_dir.path().to_path_buf(),
2179        ));
2180
2181        let migrator = setup_session_migrator();
2182        let strategy = DirStorageStrategy::default();
2183        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2184
2185        // Save a session
2186        let session = SessionEntity {
2187            id: "session-load".to_string(),
2188            user_id: "user-999".to_string(),
2189            created_at: Some("2024-02-01".to_string()),
2190        };
2191        storage
2192            .save("session", "session-load", session.clone())
2193            .unwrap();
2194
2195        // Load it back
2196        let loaded: SessionEntity = storage.load("session", "session-load").unwrap();
2197        assert_eq!(loaded.id, session.id);
2198        assert_eq!(loaded.user_id, session.user_id);
2199        assert_eq!(loaded.created_at, session.created_at);
2200    }
2201
2202    #[test]
2203    fn test_dir_storage_load_not_found() {
2204        let temp_dir = TempDir::new().unwrap();
2205        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2206            temp_dir.path().to_path_buf(),
2207        ));
2208
2209        let migrator = setup_session_migrator();
2210        let strategy = DirStorageStrategy::default();
2211        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2212
2213        // Try to load non-existent session
2214        let result: Result<SessionEntity, _> = storage.load("session", "non-existent");
2215        assert!(result.is_err());
2216        assert!(matches!(
2217            result.unwrap_err(),
2218            MigrationError::IoError { .. }
2219        ));
2220    }
2221
2222    #[test]
2223    fn test_dir_storage_save_and_load_roundtrip() {
2224        let temp_dir = TempDir::new().unwrap();
2225        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2226            temp_dir.path().to_path_buf(),
2227        ));
2228
2229        let migrator = setup_session_migrator();
2230        let strategy = DirStorageStrategy::default();
2231        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2232
2233        // Test multiple sessions
2234        let sessions = vec![
2235            SessionEntity {
2236                id: "session-1".to_string(),
2237                user_id: "user-1".to_string(),
2238                created_at: Some("2024-01-01".to_string()),
2239            },
2240            SessionEntity {
2241                id: "session-2".to_string(),
2242                user_id: "user-2".to_string(),
2243                created_at: None,
2244            },
2245            SessionEntity {
2246                id: "session-3".to_string(),
2247                user_id: "user-3".to_string(),
2248                created_at: Some("2024-03-01".to_string()),
2249            },
2250        ];
2251
2252        // Save all sessions
2253        for session in &sessions {
2254            storage
2255                .save("session", &session.id, session.clone())
2256                .unwrap();
2257        }
2258
2259        // Load and verify each session
2260        for session in &sessions {
2261            let loaded: SessionEntity = storage.load("session", &session.id).unwrap();
2262            assert_eq!(loaded.id, session.id);
2263            assert_eq!(loaded.user_id, session.user_id);
2264            assert_eq!(loaded.created_at, session.created_at);
2265        }
2266    }
2267
2268    #[test]
2269    fn test_dir_storage_list_ids_empty() {
2270        let temp_dir = TempDir::new().unwrap();
2271        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2272            temp_dir.path().to_path_buf(),
2273        ));
2274
2275        let migrator = setup_session_migrator();
2276        let strategy = DirStorageStrategy::default();
2277        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2278
2279        // List IDs from empty directory
2280        let ids = storage.list_ids().unwrap();
2281        assert!(ids.is_empty());
2282    }
2283
2284    #[test]
2285    fn test_dir_storage_list_ids() {
2286        let temp_dir = TempDir::new().unwrap();
2287        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2288            temp_dir.path().to_path_buf(),
2289        ));
2290
2291        let migrator = setup_session_migrator();
2292        let strategy = DirStorageStrategy::default();
2293        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2294
2295        // Save multiple sessions
2296        let ids = vec!["session-c", "session-a", "session-b"];
2297        for id in &ids {
2298            let session = SessionEntity {
2299                id: id.to_string(),
2300                user_id: "user".to_string(),
2301                created_at: None,
2302            };
2303            storage.save("session", id, session).unwrap();
2304        }
2305
2306        // List IDs
2307        let listed_ids = storage.list_ids().unwrap();
2308        assert_eq!(listed_ids.len(), 3);
2309        // Should be sorted
2310        assert_eq!(listed_ids, vec!["session-a", "session-b", "session-c"]);
2311    }
2312
2313    #[test]
2314    fn test_dir_storage_load_all_empty() {
2315        let temp_dir = TempDir::new().unwrap();
2316        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2317            temp_dir.path().to_path_buf(),
2318        ));
2319
2320        let migrator = setup_session_migrator();
2321        let strategy = DirStorageStrategy::default();
2322        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2323
2324        // Load all from empty directory
2325        let results: Vec<(String, SessionEntity)> = storage.load_all("session").unwrap();
2326        assert!(results.is_empty());
2327    }
2328
2329    #[test]
2330    fn test_dir_storage_load_all() {
2331        let temp_dir = TempDir::new().unwrap();
2332        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2333            temp_dir.path().to_path_buf(),
2334        ));
2335
2336        let migrator = setup_session_migrator();
2337        let strategy = DirStorageStrategy::default();
2338        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2339
2340        // Save multiple sessions
2341        let sessions = vec![
2342            SessionEntity {
2343                id: "session-x".to_string(),
2344                user_id: "user-x".to_string(),
2345                created_at: Some("2024-01-01".to_string()),
2346            },
2347            SessionEntity {
2348                id: "session-y".to_string(),
2349                user_id: "user-y".to_string(),
2350                created_at: None,
2351            },
2352            SessionEntity {
2353                id: "session-z".to_string(),
2354                user_id: "user-z".to_string(),
2355                created_at: Some("2024-03-01".to_string()),
2356            },
2357        ];
2358
2359        for session in &sessions {
2360            storage
2361                .save("session", &session.id, session.clone())
2362                .unwrap();
2363        }
2364
2365        // Load all
2366        let results: Vec<(String, SessionEntity)> = storage.load_all("session").unwrap();
2367        assert_eq!(results.len(), 3);
2368
2369        // Verify all sessions are loaded
2370        for (id, loaded) in &results {
2371            let original = sessions.iter().find(|s| &s.id == id).unwrap();
2372            assert_eq!(loaded.id, original.id);
2373            assert_eq!(loaded.user_id, original.user_id);
2374            assert_eq!(loaded.created_at, original.created_at);
2375        }
2376    }
2377
2378    #[test]
2379    fn test_dir_storage_exists() {
2380        let temp_dir = TempDir::new().unwrap();
2381        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2382            temp_dir.path().to_path_buf(),
2383        ));
2384
2385        let migrator = setup_session_migrator();
2386        let strategy = DirStorageStrategy::default();
2387        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2388
2389        // Non-existent file
2390        assert!(!storage.exists("session-exists").unwrap());
2391
2392        // Save a session
2393        let session = SessionEntity {
2394            id: "session-exists".to_string(),
2395            user_id: "user-exists".to_string(),
2396            created_at: None,
2397        };
2398        storage.save("session", "session-exists", session).unwrap();
2399
2400        // Should exist now
2401        assert!(storage.exists("session-exists").unwrap());
2402    }
2403
2404    #[test]
2405    fn test_dir_storage_delete() {
2406        let temp_dir = TempDir::new().unwrap();
2407        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2408            temp_dir.path().to_path_buf(),
2409        ));
2410
2411        let migrator = setup_session_migrator();
2412        let strategy = DirStorageStrategy::default();
2413        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2414
2415        // Save a session
2416        let session = SessionEntity {
2417            id: "session-delete".to_string(),
2418            user_id: "user-delete".to_string(),
2419            created_at: None,
2420        };
2421        storage.save("session", "session-delete", session).unwrap();
2422
2423        // Verify it exists
2424        assert!(storage.exists("session-delete").unwrap());
2425
2426        // Delete it
2427        storage.delete("session-delete").unwrap();
2428
2429        // Verify it doesn't exist
2430        assert!(!storage.exists("session-delete").unwrap());
2431    }
2432
2433    #[test]
2434    fn test_dir_storage_delete_idempotent() {
2435        let temp_dir = TempDir::new().unwrap();
2436        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2437            temp_dir.path().to_path_buf(),
2438        ));
2439
2440        let migrator = setup_session_migrator();
2441        let strategy = DirStorageStrategy::default();
2442        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2443
2444        // Delete non-existent file (should not error)
2445        storage.delete("non-existent").unwrap();
2446
2447        // Delete again (should still not error)
2448        storage.delete("non-existent").unwrap();
2449    }
2450
2451    #[test]
2452    fn test_dir_storage_load_toml() {
2453        let temp_dir = TempDir::new().unwrap();
2454        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2455            temp_dir.path().to_path_buf(),
2456        ));
2457
2458        let migrator = setup_session_migrator();
2459        let strategy = DirStorageStrategy::default().with_format(FormatStrategy::Toml);
2460        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2461
2462        // Save a session
2463        let session = SessionEntity {
2464            id: "session-toml".to_string(),
2465            user_id: "user-toml".to_string(),
2466            created_at: Some("2024-04-01".to_string()),
2467        };
2468        storage
2469            .save("session", "session-toml", session.clone())
2470            .unwrap();
2471
2472        // Load it back
2473        let loaded: SessionEntity = storage.load("session", "session-toml").unwrap();
2474        assert_eq!(loaded.id, session.id);
2475        assert_eq!(loaded.user_id, session.user_id);
2476        assert_eq!(loaded.created_at, session.created_at);
2477    }
2478
2479    #[test]
2480    fn test_dir_storage_list_ids_with_custom_extension() {
2481        let temp_dir = TempDir::new().unwrap();
2482        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2483            temp_dir.path().to_path_buf(),
2484        ));
2485
2486        let migrator = setup_session_migrator();
2487        let strategy = DirStorageStrategy::default().with_extension("data");
2488        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2489
2490        // Save sessions with custom extension
2491        let session = SessionEntity {
2492            id: "session-ext".to_string(),
2493            user_id: "user-ext".to_string(),
2494            created_at: None,
2495        };
2496        storage.save("session", "session-ext", session).unwrap();
2497
2498        // List IDs should find the file
2499        let ids = storage.list_ids().unwrap();
2500        assert_eq!(ids.len(), 1);
2501        assert_eq!(ids[0], "session-ext");
2502    }
2503
2504    #[test]
2505    fn test_dir_storage_load_all_atomic_failure() {
2506        let temp_dir = TempDir::new().unwrap();
2507        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2508            temp_dir.path().to_path_buf(),
2509        ));
2510
2511        let migrator = setup_session_migrator();
2512        let strategy = DirStorageStrategy::default();
2513        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2514
2515        // Save valid sessions
2516        let session1 = SessionEntity {
2517            id: "session-1".to_string(),
2518            user_id: "user-1".to_string(),
2519            created_at: None,
2520        };
2521        storage.save("session", "session-1", session1).unwrap();
2522
2523        // Manually create a corrupted file
2524        let corrupted_path = storage.base_path.join("session-corrupted.json");
2525        std::fs::write(&corrupted_path, "invalid json {{{").unwrap();
2526
2527        // load_all should fail
2528        let result: Result<Vec<(String, SessionEntity)>, _> = storage.load_all("session");
2529        assert!(result.is_err());
2530    }
2531
2532    #[test]
2533    fn test_dir_storage_filename_encoding_url_roundtrip() {
2534        let temp_dir = TempDir::new().unwrap();
2535        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2536            temp_dir.path().to_path_buf(),
2537        ));
2538
2539        let migrator = setup_session_migrator();
2540        let strategy =
2541            DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
2542        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2543
2544        // Use an ID with special characters that need URL encoding
2545        let complex_id = "user@example.com/path?query=1";
2546        let session = SessionEntity {
2547            id: complex_id.to_string(),
2548            user_id: "user-special".to_string(),
2549            created_at: Some("2024-05-01".to_string()),
2550        };
2551
2552        // Save the entity
2553        storage
2554            .save("session", complex_id, session.clone())
2555            .unwrap();
2556
2557        // Verify the file was created with encoded filename
2558        let encoded_id = urlencoding::encode(complex_id);
2559        let file_path = storage.base_path.join(format!("{}.json", encoded_id));
2560        assert!(file_path.exists());
2561
2562        // Load it back
2563        let loaded: SessionEntity = storage.load("session", complex_id).unwrap();
2564        assert_eq!(loaded.id, session.id);
2565        assert_eq!(loaded.user_id, session.user_id);
2566        assert_eq!(loaded.created_at, session.created_at);
2567
2568        // Verify list_ids works correctly
2569        let ids = storage.list_ids().unwrap();
2570        assert_eq!(ids.len(), 1);
2571        assert_eq!(ids[0], complex_id);
2572    }
2573
2574    #[test]
2575    fn test_dir_storage_filename_encoding_base64_roundtrip() {
2576        let temp_dir = TempDir::new().unwrap();
2577        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2578            temp_dir.path().to_path_buf(),
2579        ));
2580
2581        let migrator = setup_session_migrator();
2582        let strategy =
2583            DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
2584        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2585
2586        // Use a complex ID with various special characters
2587        let complex_id = "user@example.com/path?query=1&special=!@#$%";
2588        let session = SessionEntity {
2589            id: complex_id.to_string(),
2590            user_id: "user-base64".to_string(),
2591            created_at: Some("2024-06-01".to_string()),
2592        };
2593
2594        // Save the entity
2595        storage
2596            .save("session", complex_id, session.clone())
2597            .unwrap();
2598
2599        // Verify the file was created with Base64-encoded filename
2600        let encoded_id = URL_SAFE_NO_PAD.encode(complex_id.as_bytes());
2601        let file_path = storage.base_path.join(format!("{}.json", encoded_id));
2602        assert!(file_path.exists());
2603
2604        // Load it back
2605        let loaded: SessionEntity = storage.load("session", complex_id).unwrap();
2606        assert_eq!(loaded.id, session.id);
2607        assert_eq!(loaded.user_id, session.user_id);
2608        assert_eq!(loaded.created_at, session.created_at);
2609
2610        // Verify list_ids works correctly
2611        let ids = storage.list_ids().unwrap();
2612        assert_eq!(ids.len(), 1);
2613        assert_eq!(ids[0], complex_id);
2614    }
2615
2616    #[test]
2617    fn test_decode_id_error_handling() {
2618        let temp_dir = TempDir::new().unwrap();
2619        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2620            temp_dir.path().to_path_buf(),
2621        ));
2622
2623        // Test UrlEncode decoding with invalid percent encoding
2624        // The urlencoding crate handles most cases gracefully, but we can test
2625        // that it properly decodes even with partial sequences (which it does)
2626        let migrator_url = setup_session_migrator();
2627        let strategy_url =
2628            DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
2629        let storage_url =
2630            DirStorage::new(paths.clone(), "sessions_url", migrator_url, strategy_url).unwrap();
2631
2632        // Invalid UTF-8 percent encoding - this should fail
2633        let invalid_url_encoded = "%C0%C1"; // Invalid UTF-8 sequence
2634        let result = storage_url.decode_id(invalid_url_encoded);
2635        assert!(result.is_err());
2636        if let Err(MigrationError::FilenameEncoding { id, reason }) = result {
2637            assert_eq!(id, invalid_url_encoded);
2638            assert!(reason.contains("Failed to URL-decode filename"));
2639        }
2640
2641        // Test Base64 decoding with invalid input
2642        let migrator_base64 = setup_session_migrator();
2643        let strategy_base64 =
2644            DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
2645        let storage_base64 =
2646            DirStorage::new(paths, "sessions_base64", migrator_base64, strategy_base64).unwrap();
2647
2648        // Invalid Base64 string (contains invalid characters)
2649        let invalid_base64 = "!!!invalid@@@";
2650        let result = storage_base64.decode_id(invalid_base64);
2651        assert!(result.is_err());
2652        if let Err(MigrationError::FilenameEncoding { id, reason }) = result {
2653            assert_eq!(id, invalid_base64);
2654            assert!(reason.contains("Failed to Base64-decode filename"));
2655        }
2656
2657        // Test Base64 with valid Base64 but invalid UTF-8
2658        // Create a Base64 string from invalid UTF-8 bytes
2659        let invalid_utf8_bytes = vec![0xFF, 0xFE, 0xFD];
2660        let valid_base64_invalid_utf8 = URL_SAFE_NO_PAD.encode(&invalid_utf8_bytes);
2661        let result = storage_base64.decode_id(&valid_base64_invalid_utf8);
2662        assert!(result.is_err());
2663        if let Err(MigrationError::FilenameEncoding { id, reason }) = result {
2664            assert_eq!(id, valid_base64_invalid_utf8);
2665            assert!(reason.contains("Failed to convert Base64-decoded bytes to UTF-8"));
2666        }
2667    }
2668
2669    #[test]
2670    fn test_dir_storage_base_path() {
2671        let temp_dir = TempDir::new().unwrap();
2672        let domain_name = "test_sessions";
2673        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2674            temp_dir.path().to_path_buf(),
2675        ));
2676
2677        let migrator = Migrator::new();
2678        let strategy = DirStorageStrategy::default();
2679
2680        let storage = DirStorage::new(paths, domain_name, migrator, strategy).unwrap();
2681
2682        // Verify base_path() returns the expected path
2683        let returned_path = storage.base_path();
2684        assert!(returned_path.ends_with(domain_name));
2685        assert!(returned_path.exists());
2686    }
2687}