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