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    /// Returns a reference to the base directory path.
741    ///
742    /// # Returns
743    ///
744    /// A reference to the resolved base directory path where entities are stored.
745    pub fn base_path(&self) -> &Path {
746        &self.base_path
747    }
748
749    /// Deserialize file content to a JSON value.
750    ///
751    /// # Arguments
752    ///
753    /// * `content` - The file content as a string
754    ///
755    /// # Returns
756    ///
757    /// A `serde_json::Value` representing the deserialized content.
758    ///
759    /// # Errors
760    ///
761    /// Returns error if deserialization fails.
762    fn deserialize_content(&self, content: &str) -> Result<serde_json::Value, MigrationError> {
763        match self.strategy.format {
764            FormatStrategy::Json => serde_json::from_str(content)
765                .map_err(|e| MigrationError::DeserializationError(e.to_string())),
766            FormatStrategy::Toml => {
767                let toml_value: toml::Value = toml::from_str(content)
768                    .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
769                toml_to_json(toml_value)
770            }
771        }
772    }
773
774    /// Extract the entity ID from a file path.
775    ///
776    /// # Arguments
777    ///
778    /// * `path` - The file path
779    ///
780    /// # Returns
781    ///
782    /// `Some(id)` if the path is valid, `None` otherwise.
783    ///
784    /// # Errors
785    ///
786    /// Returns error if ID decoding fails.
787    fn path_to_id(&self, path: &Path) -> Result<Option<String>, MigrationError> {
788        // Get file stem (filename without extension)
789        let file_stem = match path.file_stem() {
790            Some(stem) => stem.to_string_lossy(),
791            None => return Ok(None),
792        };
793
794        // Decode ID
795        let id = self.decode_id(&file_stem)?;
796        Ok(Some(id))
797    }
798
799    /// Decode a filename stem to an entity ID.
800    ///
801    /// # Arguments
802    ///
803    /// * `filename_stem` - The filename without extension
804    ///
805    /// # Returns
806    ///
807    /// The decoded entity ID.
808    ///
809    /// # Encoding Strategies
810    ///
811    /// - **Direct**: Use filename as-is (no decoding needed)
812    /// - **UrlEncode**: URL-decode the filename (not yet implemented)
813    /// - **Base64**: Base64-decode the filename (not yet implemented)
814    ///
815    /// # Errors
816    ///
817    /// Returns error if decoding fails or strategy is not yet implemented.
818    fn decode_id(&self, filename_stem: &str) -> Result<String, MigrationError> {
819        match self.strategy.filename_encoding {
820            FilenameEncoding::Direct => {
821                // Direct encoding: filename is the ID
822                Ok(filename_stem.to_string())
823            }
824            FilenameEncoding::UrlEncode => {
825                // URL-decode the filename to get the original ID
826                urlencoding::decode(filename_stem)
827                    .map(|s| s.into_owned())
828                    .map_err(|e| MigrationError::FilenameEncoding {
829                        id: filename_stem.to_string(),
830                        reason: format!("Failed to URL-decode filename: {}", e),
831                    })
832            }
833            FilenameEncoding::Base64 => {
834                // Base64-decode the filename using URL-safe encoding without padding
835                URL_SAFE_NO_PAD
836                    .decode(filename_stem.as_bytes())
837                    .map_err(|e| MigrationError::FilenameEncoding {
838                        id: filename_stem.to_string(),
839                        reason: format!("Failed to Base64-decode filename: {}", e),
840                    })
841                    .and_then(|bytes| {
842                        String::from_utf8(bytes).map_err(|e| MigrationError::FilenameEncoding {
843                            id: filename_stem.to_string(),
844                            reason: format!(
845                                "Failed to convert Base64-decoded bytes to UTF-8: {}",
846                                e
847                            ),
848                        })
849                    })
850            }
851        }
852    }
853}
854
855/// Convert JSON value to TOML value.
856///
857/// Helper function for format conversion during serialization.
858fn json_to_toml(json_value: &serde_json::Value) -> Result<toml::Value, MigrationError> {
859    let json_str = serde_json::to_string(json_value)
860        .map_err(|e| MigrationError::SerializationError(e.to_string()))?;
861    let toml_value: toml::Value = serde_json::from_str(&json_str)
862        .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
863    Ok(toml_value)
864}
865
866/// Convert TOML value to JSON value.
867///
868/// Helper function for format conversion during deserialization.
869fn toml_to_json(toml_value: toml::Value) -> Result<serde_json::Value, MigrationError> {
870    let json_str = serde_json::to_string(&toml_value)
871        .map_err(|e| MigrationError::SerializationError(e.to_string()))?;
872    let json_value: serde_json::Value = serde_json::from_str(&json_str)
873        .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
874    Ok(json_value)
875}
876
877// ============================================================================
878// Async implementation
879// ============================================================================
880
881#[cfg(feature = "async")]
882pub use async_impl::AsyncDirStorage;
883
884#[cfg(feature = "async")]
885mod async_impl {
886    use crate::{AppPaths, MigrationError, Migrator};
887    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
888    use base64::Engine;
889    use std::path::{Path, PathBuf};
890    use tokio::io::AsyncWriteExt;
891
892    use super::{json_to_toml, toml_to_json, DirStorageStrategy, FilenameEncoding, FormatStrategy};
893
894    /// Async version of DirStorage for directory-based entity storage.
895    ///
896    /// Provides the same functionality as `DirStorage` but with async operations
897    /// using `tokio::fs` for non-blocking I/O.
898    pub struct AsyncDirStorage {
899        /// Resolved base directory path
900        base_path: PathBuf,
901        /// Migrator instance for handling version migrations
902        migrator: Migrator,
903        /// Storage strategy configuration
904        strategy: DirStorageStrategy,
905    }
906
907    impl AsyncDirStorage {
908        /// Create a new AsyncDirStorage instance.
909        ///
910        /// # Arguments
911        ///
912        /// * `paths` - Application paths manager
913        /// * `domain_name` - Domain-specific subdirectory name (e.g., "sessions", "tasks")
914        /// * `migrator` - Migrator instance with registered migration paths
915        /// * `strategy` - Storage strategy configuration
916        ///
917        /// # Behavior
918        ///
919        /// - Resolves the base path using `paths.data_dir()?.join(domain_name)`
920        /// - Creates the directory if it doesn't exist (async)
921        /// - Does not load existing files (lazy loading)
922        ///
923        /// # Errors
924        ///
925        /// Returns `MigrationError::IoError` if directory creation fails.
926        pub async fn new(
927            paths: AppPaths,
928            domain_name: &str,
929            migrator: Migrator,
930            strategy: DirStorageStrategy,
931        ) -> Result<Self, MigrationError> {
932            // Resolve base path: data_dir/domain_name
933            let base_path = paths.data_dir()?.join(domain_name);
934
935            // Create directory if it doesn't exist (async)
936            if !tokio::fs::try_exists(&base_path).await.unwrap_or(false) {
937                tokio::fs::create_dir_all(&base_path).await.map_err(|e| {
938                    MigrationError::IoError {
939                        path: base_path.display().to_string(),
940                        error: e.to_string(),
941                    }
942                })?;
943            }
944
945            Ok(Self {
946                base_path,
947                migrator,
948                strategy,
949            })
950        }
951
952        /// Save an entity to a file (async).
953        ///
954        /// # Arguments
955        ///
956        /// * `entity_name` - The entity name registered in the migrator
957        /// * `id` - The unique identifier for this entity (used as filename)
958        /// * `entity` - The entity to save
959        ///
960        /// # Process
961        ///
962        /// 1. Converts the entity to its latest versioned DTO
963        /// 2. Serializes to the configured format (JSON/TOML)
964        /// 3. Writes atomically using temporary file + rename
965        ///
966        /// # Errors
967        ///
968        /// Returns error if:
969        /// - Entity name not registered in migrator
970        /// - ID contains invalid characters (for Direct encoding)
971        /// - Serialization fails
972        /// - File write fails
973        pub async fn save<T>(
974            &self,
975            entity_name: &str,
976            id: &str,
977            entity: T,
978        ) -> Result<(), MigrationError>
979        where
980            T: serde::Serialize,
981        {
982            // Convert entity to latest versioned DTO and get JSON string
983            let json_string = self.migrator.save_domain_flat(entity_name, entity)?;
984
985            // Parse back to JSON value for format conversion
986            let versioned_value: serde_json::Value = serde_json::from_str(&json_string)
987                .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
988
989            // Serialize to target format (JSON or TOML)
990            let content = self.serialize_content(&versioned_value)?;
991
992            // Get target file path
993            let file_path = self.id_to_path(id)?;
994
995            // Write atomically (async)
996            self.atomic_write(&file_path, &content).await?;
997
998            Ok(())
999        }
1000
1001        /// Load an entity from a file (async).
1002        ///
1003        /// # Arguments
1004        ///
1005        /// * `entity_name` - The entity name registered in the migrator
1006        /// * `id` - The unique identifier for the entity
1007        ///
1008        /// # Process
1009        ///
1010        /// 1. Gets the file path using `id_to_path`
1011        /// 2. Reads the file content to a string (async)
1012        /// 3. Deserializes the content to a `serde_json::Value`
1013        /// 4. Migrates the `Value` to the target domain type
1014        ///
1015        /// # Errors
1016        ///
1017        /// Returns error if:
1018        /// - Entity name not registered in migrator
1019        /// - File not found
1020        /// - Deserialization fails
1021        /// - Migration fails
1022        pub async fn load<D>(&self, entity_name: &str, id: &str) -> Result<D, MigrationError>
1023        where
1024            D: serde::de::DeserializeOwned,
1025        {
1026            // Get file path
1027            let file_path = self.id_to_path(id)?;
1028
1029            // Check if file exists (async)
1030            if !tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
1031                return Err(MigrationError::IoError {
1032                    path: file_path.display().to_string(),
1033                    error: "File not found".to_string(),
1034                });
1035            }
1036
1037            // Read file content (async)
1038            let content = tokio::fs::read_to_string(&file_path).await.map_err(|e| {
1039                MigrationError::IoError {
1040                    path: file_path.display().to_string(),
1041                    error: e.to_string(),
1042                }
1043            })?;
1044
1045            // Deserialize content to JSON value
1046            let value = self.deserialize_content(&content)?;
1047
1048            // Migrate to domain type using load_flat_from
1049            self.migrator.load_flat_from(entity_name, value)
1050        }
1051
1052        /// List all entity IDs in the storage directory (async).
1053        ///
1054        /// # Returns
1055        ///
1056        /// A sorted vector of entity IDs (decoded from filenames).
1057        ///
1058        /// # Errors
1059        ///
1060        /// Returns error if:
1061        /// - Directory read fails
1062        /// - Filename decoding fails
1063        pub async fn list_ids(&self) -> Result<Vec<String>, MigrationError> {
1064            // Read directory (async)
1065            let mut entries = tokio::fs::read_dir(&self.base_path).await.map_err(|e| {
1066                MigrationError::IoError {
1067                    path: self.base_path.display().to_string(),
1068                    error: e.to_string(),
1069                }
1070            })?;
1071
1072            let extension = self.strategy.get_extension();
1073            let mut ids = Vec::new();
1074
1075            while let Some(entry) =
1076                entries
1077                    .next_entry()
1078                    .await
1079                    .map_err(|e| MigrationError::IoError {
1080                        path: self.base_path.display().to_string(),
1081                        error: e.to_string(),
1082                    })?
1083            {
1084                let path = entry.path();
1085
1086                // Check if it's a file with the correct extension
1087                let metadata =
1088                    tokio::fs::metadata(&path)
1089                        .await
1090                        .map_err(|e| MigrationError::IoError {
1091                            path: path.display().to_string(),
1092                            error: e.to_string(),
1093                        })?;
1094
1095                if metadata.is_file() {
1096                    if let Some(ext) = path.extension() {
1097                        if ext == extension.as_str() {
1098                            // Extract ID from filename
1099                            if let Some(id) = self.path_to_id(&path)? {
1100                                ids.push(id);
1101                            }
1102                        }
1103                    }
1104                }
1105            }
1106
1107            // Sort IDs for consistent ordering
1108            ids.sort();
1109            Ok(ids)
1110        }
1111
1112        /// Load all entities from the storage directory (async).
1113        ///
1114        /// # Arguments
1115        ///
1116        /// * `entity_name` - The entity name registered in the migrator
1117        ///
1118        /// # Returns
1119        ///
1120        /// A vector of `(id, entity)` tuples.
1121        ///
1122        /// # Errors
1123        ///
1124        /// Returns error if any entity fails to load. This operation is atomic:
1125        /// if any load fails, the whole operation fails.
1126        pub async fn load_all<D>(
1127            &self,
1128            entity_name: &str,
1129        ) -> Result<Vec<(String, D)>, MigrationError>
1130        where
1131            D: serde::de::DeserializeOwned,
1132        {
1133            let ids = self.list_ids().await?;
1134            let mut results = Vec::new();
1135
1136            for id in ids {
1137                let entity = self.load(entity_name, &id).await?;
1138                results.push((id, entity));
1139            }
1140
1141            Ok(results)
1142        }
1143
1144        /// Check if an entity exists (async).
1145        ///
1146        /// # Arguments
1147        ///
1148        /// * `id` - The entity ID
1149        ///
1150        /// # Returns
1151        ///
1152        /// `true` if the file exists and is a file, `false` otherwise.
1153        pub async fn exists(&self, id: &str) -> Result<bool, MigrationError> {
1154            let file_path = self.id_to_path(id)?;
1155
1156            if !tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
1157                return Ok(false);
1158            }
1159
1160            let metadata =
1161                tokio::fs::metadata(&file_path)
1162                    .await
1163                    .map_err(|e| MigrationError::IoError {
1164                        path: file_path.display().to_string(),
1165                        error: e.to_string(),
1166                    })?;
1167
1168            Ok(metadata.is_file())
1169        }
1170
1171        /// Delete an entity file (async).
1172        ///
1173        /// # Arguments
1174        ///
1175        /// * `id` - The entity ID
1176        ///
1177        /// # Behavior
1178        ///
1179        /// This operation is idempotent: deleting a non-existent file is not an error.
1180        ///
1181        /// # Errors
1182        ///
1183        /// Returns error if file deletion fails (but not if file doesn't exist).
1184        pub async fn delete(&self, id: &str) -> Result<(), MigrationError> {
1185            let file_path = self.id_to_path(id)?;
1186
1187            if tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
1188                tokio::fs::remove_file(&file_path)
1189                    .await
1190                    .map_err(|e| MigrationError::IoError {
1191                        path: file_path.display().to_string(),
1192                        error: e.to_string(),
1193                    })?;
1194            }
1195
1196            Ok(())
1197        }
1198
1199        /// Returns a reference to the base directory path.
1200        ///
1201        /// # Returns
1202        ///
1203        /// A reference to the resolved base directory path where entities are stored.
1204        pub fn base_path(&self) -> &Path {
1205            &self.base_path
1206        }
1207
1208        // ====================================================================
1209        // Private helper methods (same as sync version but async where needed)
1210        // ====================================================================
1211
1212        /// Convert an entity ID to a file path.
1213        fn id_to_path(&self, id: &str) -> Result<PathBuf, MigrationError> {
1214            let encoded_id = self.encode_id(id)?;
1215            let extension = self.strategy.get_extension();
1216            let filename = format!("{}.{}", encoded_id, extension);
1217            Ok(self.base_path.join(filename))
1218        }
1219
1220        /// Encode an entity ID to a filesystem-safe filename.
1221        fn encode_id(&self, id: &str) -> Result<String, MigrationError> {
1222            match self.strategy.filename_encoding {
1223                FilenameEncoding::Direct => {
1224                    // Validate that ID contains only safe characters
1225                    if id
1226                        .chars()
1227                        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1228                    {
1229                        Ok(id.to_string())
1230                    } else {
1231                        Err(MigrationError::FilenameEncoding {
1232                            id: id.to_string(),
1233                            reason: "ID contains invalid characters for Direct encoding. Only alphanumeric, '-', and '_' are allowed.".to_string(),
1234                        })
1235                    }
1236                }
1237                FilenameEncoding::UrlEncode => {
1238                    // URL-encode the ID for filesystem safety
1239                    Ok(urlencoding::encode(id).into_owned())
1240                }
1241                FilenameEncoding::Base64 => {
1242                    // Base64-encode the ID using URL-safe encoding without padding
1243                    Ok(URL_SAFE_NO_PAD.encode(id.as_bytes()))
1244                }
1245            }
1246        }
1247
1248        /// Serialize a JSON value to string based on the configured format.
1249        fn serialize_content(&self, value: &serde_json::Value) -> Result<String, MigrationError> {
1250            match self.strategy.format {
1251                FormatStrategy::Json => serde_json::to_string_pretty(value)
1252                    .map_err(|e| MigrationError::SerializationError(e.to_string())),
1253                FormatStrategy::Toml => {
1254                    let toml_value = json_to_toml(value)?;
1255                    toml::to_string_pretty(&toml_value)
1256                        .map_err(|e| MigrationError::TomlSerializeError(e.to_string()))
1257                }
1258            }
1259        }
1260
1261        /// Write content to a file atomically (async).
1262        ///
1263        /// Uses the "temporary file + fsync + atomic rename" pattern to ensure
1264        /// durability and atomicity.
1265        async fn atomic_write(&self, path: &Path, content: &str) -> Result<(), MigrationError> {
1266            // Ensure parent directory exists
1267            if let Some(parent) = path.parent() {
1268                if !tokio::fs::try_exists(parent).await.unwrap_or(false) {
1269                    tokio::fs::create_dir_all(parent).await.map_err(|e| {
1270                        MigrationError::IoError {
1271                            path: parent.display().to_string(),
1272                            error: e.to_string(),
1273                        }
1274                    })?;
1275                }
1276            }
1277
1278            // Create temporary file path
1279            let tmp_path = self.get_temp_path(path)?;
1280
1281            // Write to temporary file (async)
1282            let mut tmp_file =
1283                tokio::fs::File::create(&tmp_path)
1284                    .await
1285                    .map_err(|e| MigrationError::IoError {
1286                        path: tmp_path.display().to_string(),
1287                        error: e.to_string(),
1288                    })?;
1289
1290            tmp_file
1291                .write_all(content.as_bytes())
1292                .await
1293                .map_err(|e| MigrationError::IoError {
1294                    path: tmp_path.display().to_string(),
1295                    error: e.to_string(),
1296                })?;
1297
1298            // Ensure data is written to disk
1299            tmp_file
1300                .sync_all()
1301                .await
1302                .map_err(|e| MigrationError::IoError {
1303                    path: tmp_path.display().to_string(),
1304                    error: e.to_string(),
1305                })?;
1306
1307            drop(tmp_file);
1308
1309            // Atomic rename with retry (async)
1310            self.atomic_rename(&tmp_path, path).await?;
1311
1312            // Cleanup old temp files (best effort)
1313            if self.strategy.atomic_write.cleanup_tmp_files {
1314                let _ = self.cleanup_temp_files(path).await;
1315            }
1316
1317            Ok(())
1318        }
1319
1320        /// Get path to temporary file for atomic writes.
1321        fn get_temp_path(&self, target_path: &Path) -> Result<PathBuf, MigrationError> {
1322            let parent = target_path.parent().ok_or_else(|| {
1323                MigrationError::PathResolution("Path has no parent directory".to_string())
1324            })?;
1325
1326            let file_name = target_path.file_name().ok_or_else(|| {
1327                MigrationError::PathResolution("Path has no file name".to_string())
1328            })?;
1329
1330            let tmp_name = format!(
1331                ".{}.tmp.{}",
1332                file_name.to_string_lossy(),
1333                std::process::id()
1334            );
1335            Ok(parent.join(tmp_name))
1336        }
1337
1338        /// Atomically rename temporary file to target path with retry (async).
1339        async fn atomic_rename(
1340            &self,
1341            tmp_path: &Path,
1342            target_path: &Path,
1343        ) -> Result<(), MigrationError> {
1344            let mut last_error = None;
1345
1346            for attempt in 0..self.strategy.atomic_write.retry_count {
1347                match tokio::fs::rename(tmp_path, target_path).await {
1348                    Ok(()) => return Ok(()),
1349                    Err(e) => {
1350                        last_error = Some(e);
1351                        if attempt + 1 < self.strategy.atomic_write.retry_count {
1352                            // Small delay before retry
1353                            tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
1354                        }
1355                    }
1356                }
1357            }
1358
1359            Err(MigrationError::IoError {
1360                path: target_path.display().to_string(),
1361                error: format!(
1362                    "Failed to rename after {} attempts: {}",
1363                    self.strategy.atomic_write.retry_count,
1364                    last_error.unwrap()
1365                ),
1366            })
1367        }
1368
1369        /// Clean up old temporary files (best effort, async).
1370        async fn cleanup_temp_files(&self, target_path: &Path) -> std::io::Result<()> {
1371            let parent = match target_path.parent() {
1372                Some(p) => p,
1373                None => return Ok(()),
1374            };
1375
1376            let file_name = match target_path.file_name() {
1377                Some(f) => f.to_string_lossy(),
1378                None => return Ok(()),
1379            };
1380
1381            let prefix = format!(".{}.tmp.", file_name);
1382
1383            let mut entries = tokio::fs::read_dir(parent).await?;
1384            while let Some(entry) = entries.next_entry().await? {
1385                if let Ok(name) = entry.file_name().into_string() {
1386                    if name.starts_with(&prefix) {
1387                        // Try to remove, but ignore errors (best effort)
1388                        let _ = tokio::fs::remove_file(entry.path()).await;
1389                    }
1390                }
1391            }
1392
1393            Ok(())
1394        }
1395
1396        /// Deserialize file content to a JSON value.
1397        fn deserialize_content(&self, content: &str) -> Result<serde_json::Value, MigrationError> {
1398            match self.strategy.format {
1399                FormatStrategy::Json => serde_json::from_str(content)
1400                    .map_err(|e| MigrationError::DeserializationError(e.to_string())),
1401                FormatStrategy::Toml => {
1402                    let toml_value: toml::Value = toml::from_str(content)
1403                        .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
1404                    toml_to_json(toml_value)
1405                }
1406            }
1407        }
1408
1409        /// Extract the entity ID from a file path.
1410        fn path_to_id(&self, path: &Path) -> Result<Option<String>, MigrationError> {
1411            // Get file stem (filename without extension)
1412            let file_stem = match path.file_stem() {
1413                Some(stem) => stem.to_string_lossy(),
1414                None => return Ok(None),
1415            };
1416
1417            // Decode ID
1418            let id = self.decode_id(&file_stem)?;
1419            Ok(Some(id))
1420        }
1421
1422        /// Decode a filename stem to an entity ID.
1423        fn decode_id(&self, filename_stem: &str) -> Result<String, MigrationError> {
1424            match self.strategy.filename_encoding {
1425                FilenameEncoding::Direct => {
1426                    // Direct encoding: filename is the ID
1427                    Ok(filename_stem.to_string())
1428                }
1429                FilenameEncoding::UrlEncode => {
1430                    // URL-decode the filename to get the original ID
1431                    urlencoding::decode(filename_stem)
1432                        .map(|s| s.into_owned())
1433                        .map_err(|e| MigrationError::FilenameEncoding {
1434                            id: filename_stem.to_string(),
1435                            reason: format!("Failed to URL-decode filename: {}", e),
1436                        })
1437                }
1438                FilenameEncoding::Base64 => {
1439                    // Base64-decode the filename using URL-safe encoding without padding
1440                    URL_SAFE_NO_PAD
1441                        .decode(filename_stem.as_bytes())
1442                        .map_err(|e| MigrationError::FilenameEncoding {
1443                            id: filename_stem.to_string(),
1444                            reason: format!("Failed to Base64-decode filename: {}", e),
1445                        })
1446                        .and_then(|bytes| {
1447                            String::from_utf8(bytes).map_err(|e| MigrationError::FilenameEncoding {
1448                                id: filename_stem.to_string(),
1449                                reason: format!(
1450                                    "Failed to convert Base64-decoded bytes to UTF-8: {}",
1451                                    e
1452                                ),
1453                            })
1454                        })
1455                }
1456            }
1457        }
1458    }
1459
1460    // Async tests
1461    #[cfg(all(test, feature = "async"))]
1462    mod async_tests {
1463        use super::*;
1464        use crate::{FromDomain, IntoDomain, MigratesTo, Versioned};
1465        use serde::{Deserialize, Serialize};
1466        use tempfile::TempDir;
1467
1468        // Test entity types (reused from sync tests)
1469        #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1470        struct SessionV1_0_0 {
1471            id: String,
1472            user_id: String,
1473        }
1474
1475        impl Versioned for SessionV1_0_0 {
1476            const VERSION: &'static str = "1.0.0";
1477        }
1478
1479        #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1480        struct SessionV1_1_0 {
1481            id: String,
1482            user_id: String,
1483            created_at: Option<String>,
1484        }
1485
1486        impl Versioned for SessionV1_1_0 {
1487            const VERSION: &'static str = "1.1.0";
1488        }
1489
1490        impl MigratesTo<SessionV1_1_0> for SessionV1_0_0 {
1491            fn migrate(self) -> SessionV1_1_0 {
1492                SessionV1_1_0 {
1493                    id: self.id,
1494                    user_id: self.user_id,
1495                    created_at: None,
1496                }
1497            }
1498        }
1499
1500        #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1501        struct SessionEntity {
1502            id: String,
1503            user_id: String,
1504            created_at: Option<String>,
1505        }
1506
1507        impl IntoDomain<SessionEntity> for SessionV1_1_0 {
1508            fn into_domain(self) -> SessionEntity {
1509                SessionEntity {
1510                    id: self.id,
1511                    user_id: self.user_id,
1512                    created_at: self.created_at,
1513                }
1514            }
1515        }
1516
1517        impl FromDomain<SessionEntity> for SessionV1_1_0 {
1518            fn from_domain(domain: SessionEntity) -> Self {
1519                SessionV1_1_0 {
1520                    id: domain.id,
1521                    user_id: domain.user_id,
1522                    created_at: domain.created_at,
1523                }
1524            }
1525        }
1526
1527        fn setup_session_migrator() -> Migrator {
1528            let path = Migrator::define("session")
1529                .from::<SessionV1_0_0>()
1530                .step::<SessionV1_1_0>()
1531                .into_with_save::<SessionEntity>();
1532
1533            let mut migrator = Migrator::new();
1534            migrator.register(path).unwrap();
1535            migrator
1536        }
1537
1538        #[tokio::test]
1539        async fn test_async_dir_storage_new_creates_directory() {
1540            let temp_dir = TempDir::new().unwrap();
1541            let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1542                temp_dir.path().to_path_buf(),
1543            ));
1544
1545            let migrator = Migrator::new();
1546            let strategy = DirStorageStrategy::default();
1547
1548            let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1549                .await
1550                .unwrap();
1551
1552            // Verify directory was created
1553            assert!(storage.base_path.exists());
1554            assert!(storage.base_path.is_dir());
1555            assert!(storage.base_path.ends_with("data/testapp/sessions"));
1556        }
1557
1558        #[tokio::test]
1559        async fn test_async_dir_storage_save_and_load_roundtrip() {
1560            let temp_dir = TempDir::new().unwrap();
1561            let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1562                temp_dir.path().to_path_buf(),
1563            ));
1564
1565            let migrator = setup_session_migrator();
1566            let strategy = DirStorageStrategy::default();
1567            let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1568                .await
1569                .unwrap();
1570
1571            // Test multiple sessions
1572            let sessions = vec![
1573                SessionEntity {
1574                    id: "session-1".to_string(),
1575                    user_id: "user-1".to_string(),
1576                    created_at: Some("2024-01-01".to_string()),
1577                },
1578                SessionEntity {
1579                    id: "session-2".to_string(),
1580                    user_id: "user-2".to_string(),
1581                    created_at: None,
1582                },
1583                SessionEntity {
1584                    id: "session-3".to_string(),
1585                    user_id: "user-3".to_string(),
1586                    created_at: Some("2024-03-01".to_string()),
1587                },
1588            ];
1589
1590            // Save all sessions
1591            for session in &sessions {
1592                storage
1593                    .save("session", &session.id, session.clone())
1594                    .await
1595                    .unwrap();
1596            }
1597
1598            // Load and verify each session
1599            for session in &sessions {
1600                let loaded: SessionEntity = storage.load("session", &session.id).await.unwrap();
1601                assert_eq!(loaded.id, session.id);
1602                assert_eq!(loaded.user_id, session.user_id);
1603                assert_eq!(loaded.created_at, session.created_at);
1604            }
1605        }
1606
1607        #[tokio::test]
1608        async fn test_async_dir_storage_list_ids() {
1609            let temp_dir = TempDir::new().unwrap();
1610            let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1611                temp_dir.path().to_path_buf(),
1612            ));
1613
1614            let migrator = setup_session_migrator();
1615            let strategy = DirStorageStrategy::default();
1616            let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1617                .await
1618                .unwrap();
1619
1620            // Save multiple sessions
1621            let ids = vec!["session-c", "session-a", "session-b"];
1622            for id in &ids {
1623                let session = SessionEntity {
1624                    id: id.to_string(),
1625                    user_id: "user".to_string(),
1626                    created_at: None,
1627                };
1628                storage.save("session", id, session).await.unwrap();
1629            }
1630
1631            // List IDs
1632            let listed_ids = storage.list_ids().await.unwrap();
1633            assert_eq!(listed_ids.len(), 3);
1634            // Should be sorted
1635            assert_eq!(listed_ids, vec!["session-a", "session-b", "session-c"]);
1636        }
1637
1638        #[tokio::test]
1639        async fn test_async_dir_storage_load_all() {
1640            let temp_dir = TempDir::new().unwrap();
1641            let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1642                temp_dir.path().to_path_buf(),
1643            ));
1644
1645            let migrator = setup_session_migrator();
1646            let strategy = DirStorageStrategy::default();
1647            let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1648                .await
1649                .unwrap();
1650
1651            // Save multiple sessions
1652            let sessions = vec![
1653                SessionEntity {
1654                    id: "session-x".to_string(),
1655                    user_id: "user-x".to_string(),
1656                    created_at: Some("2024-01-01".to_string()),
1657                },
1658                SessionEntity {
1659                    id: "session-y".to_string(),
1660                    user_id: "user-y".to_string(),
1661                    created_at: None,
1662                },
1663                SessionEntity {
1664                    id: "session-z".to_string(),
1665                    user_id: "user-z".to_string(),
1666                    created_at: Some("2024-03-01".to_string()),
1667                },
1668            ];
1669
1670            for session in &sessions {
1671                storage
1672                    .save("session", &session.id, session.clone())
1673                    .await
1674                    .unwrap();
1675            }
1676
1677            // Load all
1678            let results: Vec<(String, SessionEntity)> = storage.load_all("session").await.unwrap();
1679            assert_eq!(results.len(), 3);
1680
1681            // Verify all sessions are loaded
1682            for (id, loaded) in &results {
1683                let original = sessions.iter().find(|s| &s.id == id).unwrap();
1684                assert_eq!(loaded.id, original.id);
1685                assert_eq!(loaded.user_id, original.user_id);
1686                assert_eq!(loaded.created_at, original.created_at);
1687            }
1688        }
1689
1690        #[tokio::test]
1691        async fn test_async_dir_storage_delete() {
1692            let temp_dir = TempDir::new().unwrap();
1693            let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1694                temp_dir.path().to_path_buf(),
1695            ));
1696
1697            let migrator = setup_session_migrator();
1698            let strategy = DirStorageStrategy::default();
1699            let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1700                .await
1701                .unwrap();
1702
1703            // Save a session
1704            let session = SessionEntity {
1705                id: "session-delete".to_string(),
1706                user_id: "user-delete".to_string(),
1707                created_at: None,
1708            };
1709            storage
1710                .save("session", "session-delete", session)
1711                .await
1712                .unwrap();
1713
1714            // Verify it exists
1715            assert!(storage.exists("session-delete").await.unwrap());
1716
1717            // Delete it
1718            storage.delete("session-delete").await.unwrap();
1719
1720            // Verify it doesn't exist
1721            assert!(!storage.exists("session-delete").await.unwrap());
1722        }
1723
1724        #[tokio::test]
1725        async fn test_async_dir_storage_filename_encoding_url_roundtrip() {
1726            let temp_dir = TempDir::new().unwrap();
1727            let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1728                temp_dir.path().to_path_buf(),
1729            ));
1730
1731            let migrator = setup_session_migrator();
1732            let strategy =
1733                DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
1734            let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1735                .await
1736                .unwrap();
1737
1738            // Use an ID with special characters that need URL encoding
1739            let complex_id = "user@example.com/path?query=1";
1740            let session = SessionEntity {
1741                id: complex_id.to_string(),
1742                user_id: "user-special".to_string(),
1743                created_at: Some("2024-05-01".to_string()),
1744            };
1745
1746            // Save the entity
1747            storage
1748                .save("session", complex_id, session.clone())
1749                .await
1750                .unwrap();
1751
1752            // Verify the file was created with encoded filename
1753            let encoded_id = urlencoding::encode(complex_id);
1754            let file_path = storage.base_path.join(format!("{}.json", encoded_id));
1755            assert!(file_path.exists());
1756
1757            // Load it back
1758            let loaded: SessionEntity = storage.load("session", complex_id).await.unwrap();
1759            assert_eq!(loaded.id, session.id);
1760            assert_eq!(loaded.user_id, session.user_id);
1761            assert_eq!(loaded.created_at, session.created_at);
1762
1763            // Verify list_ids works correctly
1764            let ids = storage.list_ids().await.unwrap();
1765            assert_eq!(ids.len(), 1);
1766            assert_eq!(ids[0], complex_id);
1767        }
1768
1769        #[tokio::test]
1770        async fn test_async_dir_storage_filename_encoding_base64_roundtrip() {
1771            let temp_dir = TempDir::new().unwrap();
1772            let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1773                temp_dir.path().to_path_buf(),
1774            ));
1775
1776            let migrator = setup_session_migrator();
1777            let strategy =
1778                DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
1779            let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1780                .await
1781                .unwrap();
1782
1783            // Use a complex ID with various special characters
1784            let complex_id = "user@example.com/path?query=1&special=!@#$%";
1785            let session = SessionEntity {
1786                id: complex_id.to_string(),
1787                user_id: "user-base64".to_string(),
1788                created_at: Some("2024-06-01".to_string()),
1789            };
1790
1791            // Save the entity
1792            storage
1793                .save("session", complex_id, session.clone())
1794                .await
1795                .unwrap();
1796
1797            // Verify the file was created with Base64-encoded filename
1798            let encoded_id = URL_SAFE_NO_PAD.encode(complex_id.as_bytes());
1799            let file_path = storage.base_path.join(format!("{}.json", encoded_id));
1800            assert!(file_path.exists());
1801
1802            // Load it back
1803            let loaded: SessionEntity = storage.load("session", complex_id).await.unwrap();
1804            assert_eq!(loaded.id, session.id);
1805            assert_eq!(loaded.user_id, session.user_id);
1806            assert_eq!(loaded.created_at, session.created_at);
1807
1808            // Verify list_ids works correctly
1809            let ids = storage.list_ids().await.unwrap();
1810            assert_eq!(ids.len(), 1);
1811            assert_eq!(ids[0], complex_id);
1812        }
1813    }
1814}
1815
1816#[cfg(test)]
1817mod tests {
1818    use super::*;
1819    use tempfile::TempDir;
1820
1821    #[test]
1822    fn test_filename_encoding_default() {
1823        assert_eq!(FilenameEncoding::default(), FilenameEncoding::Direct);
1824    }
1825
1826    #[test]
1827    fn test_dir_storage_strategy_default() {
1828        let strategy = DirStorageStrategy::default();
1829        assert_eq!(strategy.format, FormatStrategy::Json);
1830        assert_eq!(strategy.extension, None);
1831        assert_eq!(strategy.filename_encoding, FilenameEncoding::Direct);
1832    }
1833
1834    #[test]
1835    fn test_dir_storage_strategy_builder() {
1836        let strategy = DirStorageStrategy::new()
1837            .with_format(FormatStrategy::Toml)
1838            .with_extension("data")
1839            .with_filename_encoding(FilenameEncoding::Base64)
1840            .with_retry_count(5)
1841            .with_cleanup(false);
1842
1843        assert_eq!(strategy.format, FormatStrategy::Toml);
1844        assert_eq!(strategy.extension, Some("data".to_string()));
1845        assert_eq!(strategy.filename_encoding, FilenameEncoding::Base64);
1846        assert_eq!(strategy.atomic_write.retry_count, 5);
1847        assert!(!strategy.atomic_write.cleanup_tmp_files);
1848    }
1849
1850    #[test]
1851    fn test_dir_storage_strategy_get_extension() {
1852        // Default from JSON format
1853        let strategy1 = DirStorageStrategy::default();
1854        assert_eq!(strategy1.get_extension(), "json");
1855
1856        // Default from TOML format
1857        let strategy2 = DirStorageStrategy::default().with_format(FormatStrategy::Toml);
1858        assert_eq!(strategy2.get_extension(), "toml");
1859
1860        // Custom extension
1861        let strategy3 = DirStorageStrategy::default().with_extension("custom");
1862        assert_eq!(strategy3.get_extension(), "custom");
1863    }
1864
1865    #[test]
1866    fn test_dir_storage_new_creates_directory() {
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 migrator = Migrator::new();
1873        let strategy = DirStorageStrategy::default();
1874
1875        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1876
1877        // Verify directory was created
1878        assert!(storage.base_path.exists());
1879        assert!(storage.base_path.is_dir());
1880        assert!(storage.base_path.ends_with("data/testapp/sessions"));
1881    }
1882
1883    #[test]
1884    fn test_dir_storage_new_idempotent() {
1885        let temp_dir = TempDir::new().unwrap();
1886        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1887            temp_dir.path().to_path_buf(),
1888        ));
1889
1890        let migrator1 = Migrator::new();
1891        let migrator2 = Migrator::new();
1892        let strategy = DirStorageStrategy::default();
1893
1894        // Create storage twice
1895        let storage1 =
1896            DirStorage::new(paths.clone(), "sessions", migrator1, strategy.clone()).unwrap();
1897        let storage2 = DirStorage::new(paths, "sessions", migrator2, strategy).unwrap();
1898
1899        // Both should succeed and point to the same directory
1900        assert_eq!(storage1.base_path, storage2.base_path);
1901    }
1902
1903    // Test entity types for save tests
1904    use crate::{FromDomain, IntoDomain, MigratesTo, Versioned};
1905    use serde::{Deserialize, Serialize};
1906
1907    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1908    struct SessionV1_0_0 {
1909        id: String,
1910        user_id: String,
1911    }
1912
1913    impl Versioned for SessionV1_0_0 {
1914        const VERSION: &'static str = "1.0.0";
1915    }
1916
1917    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1918    struct SessionV1_1_0 {
1919        id: String,
1920        user_id: String,
1921        created_at: Option<String>,
1922    }
1923
1924    impl Versioned for SessionV1_1_0 {
1925        const VERSION: &'static str = "1.1.0";
1926    }
1927
1928    impl MigratesTo<SessionV1_1_0> for SessionV1_0_0 {
1929        fn migrate(self) -> SessionV1_1_0 {
1930            SessionV1_1_0 {
1931                id: self.id,
1932                user_id: self.user_id,
1933                created_at: None,
1934            }
1935        }
1936    }
1937
1938    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1939    struct SessionEntity {
1940        id: String,
1941        user_id: String,
1942        created_at: Option<String>,
1943    }
1944
1945    impl IntoDomain<SessionEntity> for SessionV1_1_0 {
1946        fn into_domain(self) -> SessionEntity {
1947            SessionEntity {
1948                id: self.id,
1949                user_id: self.user_id,
1950                created_at: self.created_at,
1951            }
1952        }
1953    }
1954
1955    impl FromDomain<SessionEntity> for SessionV1_1_0 {
1956        fn from_domain(domain: SessionEntity) -> Self {
1957            SessionV1_1_0 {
1958                id: domain.id,
1959                user_id: domain.user_id,
1960                created_at: domain.created_at,
1961            }
1962        }
1963    }
1964
1965    fn setup_session_migrator() -> Migrator {
1966        let path = Migrator::define("session")
1967            .from::<SessionV1_0_0>()
1968            .step::<SessionV1_1_0>()
1969            .into_with_save::<SessionEntity>();
1970
1971        let mut migrator = Migrator::new();
1972        migrator.register(path).unwrap();
1973        migrator
1974    }
1975
1976    #[test]
1977    fn test_dir_storage_save_json() {
1978        let temp_dir = TempDir::new().unwrap();
1979        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1980            temp_dir.path().to_path_buf(),
1981        ));
1982
1983        let migrator = setup_session_migrator();
1984        let strategy = DirStorageStrategy::default().with_format(FormatStrategy::Json);
1985        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1986
1987        // Create a session entity
1988        let session = SessionEntity {
1989            id: "session-123".to_string(),
1990            user_id: "user-456".to_string(),
1991            created_at: Some("2024-01-01T00:00:00Z".to_string()),
1992        };
1993
1994        // Save the entity
1995        storage.save("session", "session-123", session).unwrap();
1996
1997        // Verify file was created
1998        let file_path = storage.base_path.join("session-123.json");
1999        assert!(file_path.exists());
2000
2001        // Verify content is valid JSON with version
2002        let content = std::fs::read_to_string(&file_path).unwrap();
2003        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
2004        assert_eq!(json["version"], "1.1.0");
2005        assert_eq!(json["id"], "session-123");
2006        assert_eq!(json["user_id"], "user-456");
2007    }
2008
2009    #[test]
2010    fn test_dir_storage_save_toml() {
2011        let temp_dir = TempDir::new().unwrap();
2012        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2013            temp_dir.path().to_path_buf(),
2014        ));
2015
2016        let migrator = setup_session_migrator();
2017        let strategy = DirStorageStrategy::default().with_format(FormatStrategy::Toml);
2018        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2019
2020        // Create a session entity with Some value (TOML doesn't support None/null)
2021        let session = SessionEntity {
2022            id: "session-789".to_string(),
2023            user_id: "user-101".to_string(),
2024            created_at: Some("2024-01-15T10:30:00Z".to_string()),
2025        };
2026
2027        // Save the entity
2028        storage.save("session", "session-789", session).unwrap();
2029
2030        // Verify file was created
2031        let file_path = storage.base_path.join("session-789.toml");
2032        assert!(file_path.exists());
2033
2034        // Verify content is valid TOML with version
2035        let content = std::fs::read_to_string(&file_path).unwrap();
2036        let toml: toml::Value = toml::from_str(&content).unwrap();
2037        assert_eq!(toml["version"].as_str().unwrap(), "1.1.0");
2038        assert_eq!(toml["id"].as_str().unwrap(), "session-789");
2039        assert_eq!(toml["created_at"].as_str().unwrap(), "2024-01-15T10:30:00Z");
2040    }
2041
2042    #[test]
2043    fn test_dir_storage_save_with_invalid_id() {
2044        let temp_dir = TempDir::new().unwrap();
2045        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2046            temp_dir.path().to_path_buf(),
2047        ));
2048
2049        let migrator = setup_session_migrator();
2050        let strategy = DirStorageStrategy::default();
2051        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2052
2053        let session = SessionEntity {
2054            id: "invalid/id".to_string(),
2055            user_id: "user-456".to_string(),
2056            created_at: None,
2057        };
2058
2059        // Should fail due to invalid characters in ID
2060        let result = storage.save("session", "invalid/id", session);
2061        assert!(result.is_err());
2062        assert!(matches!(
2063            result.unwrap_err(),
2064            crate::MigrationError::FilenameEncoding { .. }
2065        ));
2066    }
2067
2068    #[test]
2069    fn test_dir_storage_save_with_custom_extension() {
2070        let temp_dir = TempDir::new().unwrap();
2071        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2072            temp_dir.path().to_path_buf(),
2073        ));
2074
2075        let migrator = setup_session_migrator();
2076        let strategy = DirStorageStrategy::default()
2077            .with_format(FormatStrategy::Json)
2078            .with_extension("data");
2079        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2080
2081        let session = SessionEntity {
2082            id: "session-custom".to_string(),
2083            user_id: "user-999".to_string(),
2084            created_at: None,
2085        };
2086
2087        storage.save("session", "session-custom", session).unwrap();
2088
2089        // Verify custom extension is used
2090        let file_path = storage.base_path.join("session-custom.data");
2091        assert!(file_path.exists());
2092    }
2093
2094    #[test]
2095    fn test_dir_storage_save_overwrites_existing() {
2096        let temp_dir = TempDir::new().unwrap();
2097        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2098            temp_dir.path().to_path_buf(),
2099        ));
2100
2101        let migrator = setup_session_migrator();
2102        let strategy = DirStorageStrategy::default();
2103        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2104
2105        // Save initial version
2106        let session1 = SessionEntity {
2107            id: "session-overwrite".to_string(),
2108            user_id: "user-111".to_string(),
2109            created_at: Some("2024-01-01".to_string()),
2110        };
2111        storage
2112            .save("session", "session-overwrite", session1)
2113            .unwrap();
2114
2115        // Save updated version
2116        let session2 = SessionEntity {
2117            id: "session-overwrite".to_string(),
2118            user_id: "user-222".to_string(),
2119            created_at: Some("2024-01-02".to_string()),
2120        };
2121        storage
2122            .save("session", "session-overwrite", session2)
2123            .unwrap();
2124
2125        // Verify file was overwritten
2126        let file_path = storage.base_path.join("session-overwrite.json");
2127        let content = std::fs::read_to_string(&file_path).unwrap();
2128        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
2129        assert_eq!(json["user_id"], "user-222");
2130        assert_eq!(json["created_at"], "2024-01-02");
2131    }
2132
2133    #[test]
2134    fn test_dir_storage_load_success() {
2135        let temp_dir = TempDir::new().unwrap();
2136        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2137            temp_dir.path().to_path_buf(),
2138        ));
2139
2140        let migrator = setup_session_migrator();
2141        let strategy = DirStorageStrategy::default();
2142        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2143
2144        // Save a session
2145        let session = SessionEntity {
2146            id: "session-load".to_string(),
2147            user_id: "user-999".to_string(),
2148            created_at: Some("2024-02-01".to_string()),
2149        };
2150        storage
2151            .save("session", "session-load", session.clone())
2152            .unwrap();
2153
2154        // Load it back
2155        let loaded: SessionEntity = storage.load("session", "session-load").unwrap();
2156        assert_eq!(loaded.id, session.id);
2157        assert_eq!(loaded.user_id, session.user_id);
2158        assert_eq!(loaded.created_at, session.created_at);
2159    }
2160
2161    #[test]
2162    fn test_dir_storage_load_not_found() {
2163        let temp_dir = TempDir::new().unwrap();
2164        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2165            temp_dir.path().to_path_buf(),
2166        ));
2167
2168        let migrator = setup_session_migrator();
2169        let strategy = DirStorageStrategy::default();
2170        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2171
2172        // Try to load non-existent session
2173        let result: Result<SessionEntity, _> = storage.load("session", "non-existent");
2174        assert!(result.is_err());
2175        assert!(matches!(
2176            result.unwrap_err(),
2177            MigrationError::IoError { .. }
2178        ));
2179    }
2180
2181    #[test]
2182    fn test_dir_storage_save_and_load_roundtrip() {
2183        let temp_dir = TempDir::new().unwrap();
2184        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2185            temp_dir.path().to_path_buf(),
2186        ));
2187
2188        let migrator = setup_session_migrator();
2189        let strategy = DirStorageStrategy::default();
2190        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2191
2192        // Test multiple sessions
2193        let sessions = vec![
2194            SessionEntity {
2195                id: "session-1".to_string(),
2196                user_id: "user-1".to_string(),
2197                created_at: Some("2024-01-01".to_string()),
2198            },
2199            SessionEntity {
2200                id: "session-2".to_string(),
2201                user_id: "user-2".to_string(),
2202                created_at: None,
2203            },
2204            SessionEntity {
2205                id: "session-3".to_string(),
2206                user_id: "user-3".to_string(),
2207                created_at: Some("2024-03-01".to_string()),
2208            },
2209        ];
2210
2211        // Save all sessions
2212        for session in &sessions {
2213            storage
2214                .save("session", &session.id, session.clone())
2215                .unwrap();
2216        }
2217
2218        // Load and verify each session
2219        for session in &sessions {
2220            let loaded: SessionEntity = storage.load("session", &session.id).unwrap();
2221            assert_eq!(loaded.id, session.id);
2222            assert_eq!(loaded.user_id, session.user_id);
2223            assert_eq!(loaded.created_at, session.created_at);
2224        }
2225    }
2226
2227    #[test]
2228    fn test_dir_storage_list_ids_empty() {
2229        let temp_dir = TempDir::new().unwrap();
2230        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2231            temp_dir.path().to_path_buf(),
2232        ));
2233
2234        let migrator = setup_session_migrator();
2235        let strategy = DirStorageStrategy::default();
2236        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2237
2238        // List IDs from empty directory
2239        let ids = storage.list_ids().unwrap();
2240        assert!(ids.is_empty());
2241    }
2242
2243    #[test]
2244    fn test_dir_storage_list_ids() {
2245        let temp_dir = TempDir::new().unwrap();
2246        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2247            temp_dir.path().to_path_buf(),
2248        ));
2249
2250        let migrator = setup_session_migrator();
2251        let strategy = DirStorageStrategy::default();
2252        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2253
2254        // Save multiple sessions
2255        let ids = vec!["session-c", "session-a", "session-b"];
2256        for id in &ids {
2257            let session = SessionEntity {
2258                id: id.to_string(),
2259                user_id: "user".to_string(),
2260                created_at: None,
2261            };
2262            storage.save("session", id, session).unwrap();
2263        }
2264
2265        // List IDs
2266        let listed_ids = storage.list_ids().unwrap();
2267        assert_eq!(listed_ids.len(), 3);
2268        // Should be sorted
2269        assert_eq!(listed_ids, vec!["session-a", "session-b", "session-c"]);
2270    }
2271
2272    #[test]
2273    fn test_dir_storage_load_all_empty() {
2274        let temp_dir = TempDir::new().unwrap();
2275        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2276            temp_dir.path().to_path_buf(),
2277        ));
2278
2279        let migrator = setup_session_migrator();
2280        let strategy = DirStorageStrategy::default();
2281        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2282
2283        // Load all from empty directory
2284        let results: Vec<(String, SessionEntity)> = storage.load_all("session").unwrap();
2285        assert!(results.is_empty());
2286    }
2287
2288    #[test]
2289    fn test_dir_storage_load_all() {
2290        let temp_dir = TempDir::new().unwrap();
2291        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2292            temp_dir.path().to_path_buf(),
2293        ));
2294
2295        let migrator = setup_session_migrator();
2296        let strategy = DirStorageStrategy::default();
2297        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2298
2299        // Save multiple sessions
2300        let sessions = vec![
2301            SessionEntity {
2302                id: "session-x".to_string(),
2303                user_id: "user-x".to_string(),
2304                created_at: Some("2024-01-01".to_string()),
2305            },
2306            SessionEntity {
2307                id: "session-y".to_string(),
2308                user_id: "user-y".to_string(),
2309                created_at: None,
2310            },
2311            SessionEntity {
2312                id: "session-z".to_string(),
2313                user_id: "user-z".to_string(),
2314                created_at: Some("2024-03-01".to_string()),
2315            },
2316        ];
2317
2318        for session in &sessions {
2319            storage
2320                .save("session", &session.id, session.clone())
2321                .unwrap();
2322        }
2323
2324        // Load all
2325        let results: Vec<(String, SessionEntity)> = storage.load_all("session").unwrap();
2326        assert_eq!(results.len(), 3);
2327
2328        // Verify all sessions are loaded
2329        for (id, loaded) in &results {
2330            let original = sessions.iter().find(|s| &s.id == id).unwrap();
2331            assert_eq!(loaded.id, original.id);
2332            assert_eq!(loaded.user_id, original.user_id);
2333            assert_eq!(loaded.created_at, original.created_at);
2334        }
2335    }
2336
2337    #[test]
2338    fn test_dir_storage_exists() {
2339        let temp_dir = TempDir::new().unwrap();
2340        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2341            temp_dir.path().to_path_buf(),
2342        ));
2343
2344        let migrator = setup_session_migrator();
2345        let strategy = DirStorageStrategy::default();
2346        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2347
2348        // Non-existent file
2349        assert!(!storage.exists("session-exists").unwrap());
2350
2351        // Save a session
2352        let session = SessionEntity {
2353            id: "session-exists".to_string(),
2354            user_id: "user-exists".to_string(),
2355            created_at: None,
2356        };
2357        storage.save("session", "session-exists", session).unwrap();
2358
2359        // Should exist now
2360        assert!(storage.exists("session-exists").unwrap());
2361    }
2362
2363    #[test]
2364    fn test_dir_storage_delete() {
2365        let temp_dir = TempDir::new().unwrap();
2366        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2367            temp_dir.path().to_path_buf(),
2368        ));
2369
2370        let migrator = setup_session_migrator();
2371        let strategy = DirStorageStrategy::default();
2372        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2373
2374        // Save a session
2375        let session = SessionEntity {
2376            id: "session-delete".to_string(),
2377            user_id: "user-delete".to_string(),
2378            created_at: None,
2379        };
2380        storage.save("session", "session-delete", session).unwrap();
2381
2382        // Verify it exists
2383        assert!(storage.exists("session-delete").unwrap());
2384
2385        // Delete it
2386        storage.delete("session-delete").unwrap();
2387
2388        // Verify it doesn't exist
2389        assert!(!storage.exists("session-delete").unwrap());
2390    }
2391
2392    #[test]
2393    fn test_dir_storage_delete_idempotent() {
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();
2401        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2402
2403        // Delete non-existent file (should not error)
2404        storage.delete("non-existent").unwrap();
2405
2406        // Delete again (should still not error)
2407        storage.delete("non-existent").unwrap();
2408    }
2409
2410    #[test]
2411    fn test_dir_storage_load_toml() {
2412        let temp_dir = TempDir::new().unwrap();
2413        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2414            temp_dir.path().to_path_buf(),
2415        ));
2416
2417        let migrator = setup_session_migrator();
2418        let strategy = DirStorageStrategy::default().with_format(FormatStrategy::Toml);
2419        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2420
2421        // Save a session
2422        let session = SessionEntity {
2423            id: "session-toml".to_string(),
2424            user_id: "user-toml".to_string(),
2425            created_at: Some("2024-04-01".to_string()),
2426        };
2427        storage
2428            .save("session", "session-toml", session.clone())
2429            .unwrap();
2430
2431        // Load it back
2432        let loaded: SessionEntity = storage.load("session", "session-toml").unwrap();
2433        assert_eq!(loaded.id, session.id);
2434        assert_eq!(loaded.user_id, session.user_id);
2435        assert_eq!(loaded.created_at, session.created_at);
2436    }
2437
2438    #[test]
2439    fn test_dir_storage_list_ids_with_custom_extension() {
2440        let temp_dir = TempDir::new().unwrap();
2441        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2442            temp_dir.path().to_path_buf(),
2443        ));
2444
2445        let migrator = setup_session_migrator();
2446        let strategy = DirStorageStrategy::default().with_extension("data");
2447        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2448
2449        // Save sessions with custom extension
2450        let session = SessionEntity {
2451            id: "session-ext".to_string(),
2452            user_id: "user-ext".to_string(),
2453            created_at: None,
2454        };
2455        storage.save("session", "session-ext", session).unwrap();
2456
2457        // List IDs should find the file
2458        let ids = storage.list_ids().unwrap();
2459        assert_eq!(ids.len(), 1);
2460        assert_eq!(ids[0], "session-ext");
2461    }
2462
2463    #[test]
2464    fn test_dir_storage_load_all_atomic_failure() {
2465        let temp_dir = TempDir::new().unwrap();
2466        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2467            temp_dir.path().to_path_buf(),
2468        ));
2469
2470        let migrator = setup_session_migrator();
2471        let strategy = DirStorageStrategy::default();
2472        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2473
2474        // Save valid sessions
2475        let session1 = SessionEntity {
2476            id: "session-1".to_string(),
2477            user_id: "user-1".to_string(),
2478            created_at: None,
2479        };
2480        storage.save("session", "session-1", session1).unwrap();
2481
2482        // Manually create a corrupted file
2483        let corrupted_path = storage.base_path.join("session-corrupted.json");
2484        std::fs::write(&corrupted_path, "invalid json {{{").unwrap();
2485
2486        // load_all should fail
2487        let result: Result<Vec<(String, SessionEntity)>, _> = storage.load_all("session");
2488        assert!(result.is_err());
2489    }
2490
2491    #[test]
2492    fn test_dir_storage_filename_encoding_url_roundtrip() {
2493        let temp_dir = TempDir::new().unwrap();
2494        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2495            temp_dir.path().to_path_buf(),
2496        ));
2497
2498        let migrator = setup_session_migrator();
2499        let strategy =
2500            DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
2501        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2502
2503        // Use an ID with special characters that need URL encoding
2504        let complex_id = "user@example.com/path?query=1";
2505        let session = SessionEntity {
2506            id: complex_id.to_string(),
2507            user_id: "user-special".to_string(),
2508            created_at: Some("2024-05-01".to_string()),
2509        };
2510
2511        // Save the entity
2512        storage
2513            .save("session", complex_id, session.clone())
2514            .unwrap();
2515
2516        // Verify the file was created with encoded filename
2517        let encoded_id = urlencoding::encode(complex_id);
2518        let file_path = storage.base_path.join(format!("{}.json", encoded_id));
2519        assert!(file_path.exists());
2520
2521        // Load it back
2522        let loaded: SessionEntity = storage.load("session", complex_id).unwrap();
2523        assert_eq!(loaded.id, session.id);
2524        assert_eq!(loaded.user_id, session.user_id);
2525        assert_eq!(loaded.created_at, session.created_at);
2526
2527        // Verify list_ids works correctly
2528        let ids = storage.list_ids().unwrap();
2529        assert_eq!(ids.len(), 1);
2530        assert_eq!(ids[0], complex_id);
2531    }
2532
2533    #[test]
2534    fn test_dir_storage_filename_encoding_base64_roundtrip() {
2535        let temp_dir = TempDir::new().unwrap();
2536        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2537            temp_dir.path().to_path_buf(),
2538        ));
2539
2540        let migrator = setup_session_migrator();
2541        let strategy =
2542            DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
2543        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2544
2545        // Use a complex ID with various special characters
2546        let complex_id = "user@example.com/path?query=1&special=!@#$%";
2547        let session = SessionEntity {
2548            id: complex_id.to_string(),
2549            user_id: "user-base64".to_string(),
2550            created_at: Some("2024-06-01".to_string()),
2551        };
2552
2553        // Save the entity
2554        storage
2555            .save("session", complex_id, session.clone())
2556            .unwrap();
2557
2558        // Verify the file was created with Base64-encoded filename
2559        let encoded_id = URL_SAFE_NO_PAD.encode(complex_id.as_bytes());
2560        let file_path = storage.base_path.join(format!("{}.json", encoded_id));
2561        assert!(file_path.exists());
2562
2563        // Load it back
2564        let loaded: SessionEntity = storage.load("session", complex_id).unwrap();
2565        assert_eq!(loaded.id, session.id);
2566        assert_eq!(loaded.user_id, session.user_id);
2567        assert_eq!(loaded.created_at, session.created_at);
2568
2569        // Verify list_ids works correctly
2570        let ids = storage.list_ids().unwrap();
2571        assert_eq!(ids.len(), 1);
2572        assert_eq!(ids[0], complex_id);
2573    }
2574
2575    #[test]
2576    fn test_decode_id_error_handling() {
2577        let temp_dir = TempDir::new().unwrap();
2578        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2579            temp_dir.path().to_path_buf(),
2580        ));
2581
2582        // Test UrlEncode decoding with invalid percent encoding
2583        // The urlencoding crate handles most cases gracefully, but we can test
2584        // that it properly decodes even with partial sequences (which it does)
2585        let migrator_url = setup_session_migrator();
2586        let strategy_url =
2587            DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
2588        let storage_url =
2589            DirStorage::new(paths.clone(), "sessions_url", migrator_url, strategy_url).unwrap();
2590
2591        // Invalid UTF-8 percent encoding - this should fail
2592        let invalid_url_encoded = "%C0%C1"; // Invalid UTF-8 sequence
2593        let result = storage_url.decode_id(invalid_url_encoded);
2594        assert!(result.is_err());
2595        if let Err(MigrationError::FilenameEncoding { id, reason }) = result {
2596            assert_eq!(id, invalid_url_encoded);
2597            assert!(reason.contains("Failed to URL-decode filename"));
2598        }
2599
2600        // Test Base64 decoding with invalid input
2601        let migrator_base64 = setup_session_migrator();
2602        let strategy_base64 =
2603            DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
2604        let storage_base64 =
2605            DirStorage::new(paths, "sessions_base64", migrator_base64, strategy_base64).unwrap();
2606
2607        // Invalid Base64 string (contains invalid characters)
2608        let invalid_base64 = "!!!invalid@@@";
2609        let result = storage_base64.decode_id(invalid_base64);
2610        assert!(result.is_err());
2611        if let Err(MigrationError::FilenameEncoding { id, reason }) = result {
2612            assert_eq!(id, invalid_base64);
2613            assert!(reason.contains("Failed to Base64-decode filename"));
2614        }
2615
2616        // Test Base64 with valid Base64 but invalid UTF-8
2617        // Create a Base64 string from invalid UTF-8 bytes
2618        let invalid_utf8_bytes = vec![0xFF, 0xFE, 0xFD];
2619        let valid_base64_invalid_utf8 = URL_SAFE_NO_PAD.encode(&invalid_utf8_bytes);
2620        let result = storage_base64.decode_id(&valid_base64_invalid_utf8);
2621        assert!(result.is_err());
2622        if let Err(MigrationError::FilenameEncoding { id, reason }) = result {
2623            assert_eq!(id, valid_base64_invalid_utf8);
2624            assert!(reason.contains("Failed to convert Base64-decoded bytes to UTF-8"));
2625        }
2626    }
2627
2628    #[test]
2629    fn test_dir_storage_base_path() {
2630        let temp_dir = TempDir::new().unwrap();
2631        let domain_name = "test_sessions";
2632        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2633            temp_dir.path().to_path_buf(),
2634        ));
2635
2636        let migrator = Migrator::new();
2637        let strategy = DirStorageStrategy::default();
2638
2639        let storage = DirStorage::new(paths, domain_name, migrator, strategy).unwrap();
2640
2641        // Verify base_path() returns the expected path
2642        let returned_path = storage.base_path();
2643        assert!(returned_path.ends_with(domain_name));
2644        assert!(returned_path.exists());
2645    }
2646}