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