version_migrate/
dir_storage.rs

1//! Directory-based storage layer for managing multiple entity files.
2//!
3//! Provides per-entity file storage with ACID guarantees, automatic migrations,
4//! and flexible file naming strategies. Unlike `FileStorage` which stores multiple
5//! entities in a single file, `DirStorage` creates one file per entity.
6//!
7//! # Use Cases
8//!
9//! - Session management: `sessions/session-123.json`
10//! - Task management: `tasks/task-456.json`
11//! - User data: `users/user-789.json`
12//!
13//! # Example
14//!
15//! ```ignore
16//! use version_migrate::{AppPaths, Migrator, DirStorage, DirStorageStrategy};
17//!
18//! // Setup migrator with entity paths
19//! let mut migrator = Migrator::new();
20//! let session_path = Migrator::define("session")
21//!     .from::<SessionV1_0_0>()
22//!     .step::<SessionV1_1_0>()
23//!     .into_with_save::<SessionEntity>();
24//! migrator.register(session_path)?;
25//!
26//! // Create DirStorage
27//! let paths = AppPaths::new("myapp");
28//! let storage = DirStorage::new(
29//!     paths,
30//!     "sessions",
31//!     migrator,
32//!     DirStorageStrategy::default(),
33//! )?;
34//!
35//! // Save and load entities
36//! let session = SessionEntity { /* ... */ };
37//! storage.save("session", "session-123", session)?;
38//! let loaded: SessionEntity = storage.load("session", "session-123")?;
39//! ```
40
41use crate::{AppPaths, MigrationError, Migrator};
42use base64::engine::general_purpose::URL_SAFE_NO_PAD;
43use base64::Engine;
44use std::fs::{self, File};
45use std::io::Write;
46use std::path::{Path, PathBuf};
47
48// Re-export shared types from storage module
49pub use crate::storage::{AtomicWriteConfig, FormatStrategy};
50
51/// File naming encoding strategy for entity IDs.
52///
53/// Determines how entity IDs are encoded into filesystem-safe filenames.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum FilenameEncoding {
56    /// Use ID directly as filename (safe characters only: alphanumeric, `-`, `_`)
57    Direct,
58    /// URL-encode the ID (for IDs with special characters)
59    UrlEncode,
60    /// Base64-encode the ID (for maximum safety)
61    Base64,
62}
63
64impl Default for FilenameEncoding {
65    fn default() -> Self {
66        Self::Direct
67    }
68}
69
70/// Strategy configuration for directory-based storage operations.
71#[derive(Debug, Clone)]
72pub struct DirStorageStrategy {
73    /// File format to use (JSON or TOML)
74    pub format: FormatStrategy,
75    /// Atomic write configuration
76    pub atomic_write: AtomicWriteConfig,
77    /// Custom file extension (if None, derived from format)
78    pub extension: Option<String>,
79    /// File naming encoding strategy
80    pub filename_encoding: FilenameEncoding,
81}
82
83impl Default for DirStorageStrategy {
84    fn default() -> Self {
85        Self {
86            format: FormatStrategy::Json,
87            atomic_write: AtomicWriteConfig::default(),
88            extension: None,
89            filename_encoding: FilenameEncoding::default(),
90        }
91    }
92}
93
94impl DirStorageStrategy {
95    /// Create a new strategy with default values.
96    #[allow(dead_code)]
97    pub fn new() -> Self {
98        Self::default()
99    }
100
101    /// Set the file format.
102    #[allow(dead_code)]
103    pub fn with_format(mut self, format: FormatStrategy) -> Self {
104        self.format = format;
105        self
106    }
107
108    /// Set a custom file extension.
109    #[allow(dead_code)]
110    pub fn with_extension(mut self, ext: impl Into<String>) -> Self {
111        self.extension = Some(ext.into());
112        self
113    }
114
115    /// Set the filename encoding strategy.
116    #[allow(dead_code)]
117    pub fn with_filename_encoding(mut self, encoding: FilenameEncoding) -> Self {
118        self.filename_encoding = encoding;
119        self
120    }
121
122    /// Set the retry count for atomic writes.
123    #[allow(dead_code)]
124    pub fn with_retry_count(mut self, count: usize) -> Self {
125        self.atomic_write.retry_count = count;
126        self
127    }
128
129    /// Set whether to cleanup temporary files.
130    #[allow(dead_code)]
131    pub fn with_cleanup(mut self, cleanup: bool) -> Self {
132        self.atomic_write.cleanup_tmp_files = cleanup;
133        self
134    }
135
136    /// Get the file extension (derived from format if not explicitly set).
137    fn get_extension(&self) -> String {
138        self.extension.clone().unwrap_or_else(|| match self.format {
139            FormatStrategy::Json => "json".to_string(),
140            FormatStrategy::Toml => "toml".to_string(),
141        })
142    }
143}
144
145/// Directory-based entity storage with ACID guarantees and automatic migrations.
146///
147/// Manages one file per entity, providing:
148/// - **Atomicity**: Updates are all-or-nothing via tmp file + atomic rename
149/// - **Consistency**: Format validation on load/save
150/// - **Isolation**: Each entity has its own file
151/// - **Durability**: Explicit fsync before rename
152pub struct DirStorage {
153    /// Resolved base directory path
154    base_path: PathBuf,
155    /// Migrator instance for handling version migrations
156    migrator: Migrator,
157    /// Storage strategy configuration
158    strategy: DirStorageStrategy,
159}
160
161impl DirStorage {
162    /// Create a new DirStorage instance.
163    ///
164    /// # Arguments
165    ///
166    /// * `paths` - Application paths manager
167    /// * `domain_name` - Domain-specific subdirectory name (e.g., "sessions", "tasks")
168    /// * `migrator` - Migrator instance with registered migration paths
169    /// * `strategy` - Storage strategy configuration
170    ///
171    /// # Behavior
172    ///
173    /// - Resolves the base path using `paths.data_dir()?.join(domain_name)`
174    /// - Creates the directory if it doesn't exist
175    /// - Does not load existing files (lazy loading)
176    ///
177    /// # Errors
178    ///
179    /// Returns `MigrationError::IoError` if directory creation fails.
180    ///
181    /// # Example
182    ///
183    /// ```ignore
184    /// let paths = AppPaths::new("myapp");
185    /// let storage = DirStorage::new(
186    ///     paths,
187    ///     "sessions",
188    ///     migrator,
189    ///     DirStorageStrategy::default(),
190    /// )?;
191    /// ```
192    pub fn new(
193        paths: AppPaths,
194        domain_name: &str,
195        migrator: Migrator,
196        strategy: DirStorageStrategy,
197    ) -> Result<Self, MigrationError> {
198        // Resolve base path: data_dir/domain_name
199        let base_path = paths.data_dir()?.join(domain_name);
200
201        // Create directory if it doesn't exist
202        if !base_path.exists() {
203            std::fs::create_dir_all(&base_path).map_err(|e| MigrationError::IoError {
204                path: base_path.display().to_string(),
205                error: e.to_string(),
206            })?;
207        }
208
209        Ok(Self {
210            base_path,
211            migrator,
212            strategy,
213        })
214    }
215
216    /// Save an entity to a file.
217    ///
218    /// # Arguments
219    ///
220    /// * `entity_name` - The entity name registered in the migrator
221    /// * `id` - The unique identifier for this entity (used as filename)
222    /// * `entity` - The entity to save
223    ///
224    /// # Process
225    ///
226    /// 1. Converts the entity to its latest versioned DTO
227    /// 2. Serializes to the configured format (JSON/TOML)
228    /// 3. Writes atomically using temporary file + rename
229    ///
230    /// # Errors
231    ///
232    /// Returns error if:
233    /// - Entity name not registered in migrator
234    /// - ID contains invalid characters (for Direct encoding)
235    /// - Serialization fails
236    /// - File write fails
237    ///
238    /// # Example
239    ///
240    /// ```ignore
241    /// let session = SessionEntity {
242    ///     id: "session-123".to_string(),
243    ///     user_id: "user-456".to_string(),
244    /// };
245    /// storage.save("session", "session-123", session)?;
246    /// ```
247    pub fn save<T>(&self, entity_name: &str, id: &str, entity: T) -> Result<(), MigrationError>
248    where
249        T: serde::Serialize,
250    {
251        // Convert entity to latest versioned DTO and get JSON string
252        let json_string = self.migrator.save_domain_flat(entity_name, entity)?;
253
254        // Parse back to JSON value for format conversion
255        let versioned_value: serde_json::Value = serde_json::from_str(&json_string)
256            .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
257
258        // Serialize to target format (JSON or TOML)
259        let content = self.serialize_content(&versioned_value)?;
260
261        // Get target file path
262        let file_path = self.id_to_path(id)?;
263
264        // Write atomically
265        self.atomic_write(&file_path, &content)?;
266
267        Ok(())
268    }
269
270    /// Convert an entity ID to a file path.
271    ///
272    /// Encodes the ID according to the configured filename encoding strategy
273    /// and appends the appropriate file extension.
274    ///
275    /// # Arguments
276    ///
277    /// * `id` - The entity ID
278    ///
279    /// # Returns
280    ///
281    /// Full path: `base_path/encoded_id.extension`
282    ///
283    /// # Errors
284    ///
285    /// Returns error if ID encoding fails (e.g., invalid characters for Direct encoding).
286    fn id_to_path(&self, id: &str) -> Result<PathBuf, MigrationError> {
287        let encoded_id = self.encode_id(id)?;
288        let extension = self.strategy.get_extension();
289        let filename = format!("{}.{}", encoded_id, extension);
290        Ok(self.base_path.join(filename))
291    }
292
293    /// Encode an entity ID to a filesystem-safe filename.
294    ///
295    /// # Arguments
296    ///
297    /// * `id` - The entity ID to encode
298    ///
299    /// # Encoding Strategies
300    ///
301    /// - **Direct**: Use ID as-is (validates alphanumeric, `-`, `_` only)
302    /// - **UrlEncode**: URL-encode special characters (not yet implemented)
303    /// - **Base64**: Base64-encode the ID (not yet implemented)
304    ///
305    /// # Errors
306    ///
307    /// Returns `MigrationError::FilenameEncoding` if:
308    /// - Direct encoding with invalid characters
309    /// - Encoding strategy not yet implemented
310    fn encode_id(&self, id: &str) -> Result<String, MigrationError> {
311        match self.strategy.filename_encoding {
312            FilenameEncoding::Direct => {
313                // Validate that ID contains only safe characters
314                if id
315                    .chars()
316                    .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
317                {
318                    Ok(id.to_string())
319                } else {
320                    Err(MigrationError::FilenameEncoding {
321                        id: id.to_string(),
322                        reason: "ID contains invalid characters for Direct encoding. Only alphanumeric, '-', and '_' are allowed.".to_string(),
323                    })
324                }
325            }
326            FilenameEncoding::UrlEncode => {
327                // URL-encode the ID for filesystem safety
328                Ok(urlencoding::encode(id).into_owned())
329            }
330            FilenameEncoding::Base64 => {
331                // Base64-encode the ID using URL-safe encoding without padding
332                Ok(URL_SAFE_NO_PAD.encode(id.as_bytes()))
333            }
334        }
335    }
336
337    /// Serialize a JSON value to string based on the configured format.
338    ///
339    /// # Arguments
340    ///
341    /// * `value` - The JSON value to serialize
342    ///
343    /// # Returns
344    ///
345    /// Pretty-printed string in the configured format (JSON or TOML).
346    ///
347    /// # Errors
348    ///
349    /// Returns error if serialization or format conversion fails.
350    fn serialize_content(&self, value: &serde_json::Value) -> Result<String, MigrationError> {
351        match self.strategy.format {
352            FormatStrategy::Json => serde_json::to_string_pretty(value)
353                .map_err(|e| MigrationError::SerializationError(e.to_string())),
354            FormatStrategy::Toml => {
355                let toml_value = json_to_toml(value)?;
356                toml::to_string_pretty(&toml_value)
357                    .map_err(|e| MigrationError::TomlSerializeError(e.to_string()))
358            }
359        }
360    }
361
362    /// Write content to a file atomically.
363    ///
364    /// Uses the "temporary file + fsync + atomic rename" pattern to ensure
365    /// durability and atomicity.
366    ///
367    /// # Process
368    ///
369    /// 1. Create temporary file with unique name (`.filename.tmp.{pid}`)
370    /// 2. Write content to temporary file
371    /// 3. Sync to disk (fsync)
372    /// 4. Atomically rename to target path
373    /// 5. Retry on failure (configured retry count)
374    /// 6. Clean up old temporary files (best effort)
375    ///
376    /// # Arguments
377    ///
378    /// * `path` - Target file path
379    /// * `content` - Content to write
380    ///
381    /// # Errors
382    ///
383    /// Returns error if file creation, write, sync, or rename fails.
384    fn atomic_write(&self, path: &Path, content: &str) -> Result<(), MigrationError> {
385        // Ensure parent directory exists
386        if let Some(parent) = path.parent() {
387            if !parent.exists() {
388                fs::create_dir_all(parent).map_err(|e| MigrationError::IoError {
389                    path: parent.display().to_string(),
390                    error: e.to_string(),
391                })?;
392            }
393        }
394
395        // Create temporary file path
396        let tmp_path = self.get_temp_path(path)?;
397
398        // Write to temporary file
399        let mut tmp_file = File::create(&tmp_path).map_err(|e| MigrationError::IoError {
400            path: tmp_path.display().to_string(),
401            error: e.to_string(),
402        })?;
403
404        tmp_file
405            .write_all(content.as_bytes())
406            .map_err(|e| MigrationError::IoError {
407                path: tmp_path.display().to_string(),
408                error: e.to_string(),
409            })?;
410
411        // Ensure data is written to disk
412        tmp_file.sync_all().map_err(|e| MigrationError::IoError {
413            path: tmp_path.display().to_string(),
414            error: e.to_string(),
415        })?;
416
417        drop(tmp_file);
418
419        // Atomic rename with retry
420        self.atomic_rename(&tmp_path, path)?;
421
422        // Cleanup old temp files (best effort)
423        if self.strategy.atomic_write.cleanup_tmp_files {
424            let _ = self.cleanup_temp_files(path);
425        }
426
427        Ok(())
428    }
429
430    /// Get path to temporary file for atomic writes.
431    ///
432    /// Creates a unique temporary filename in the same directory as the target file.
433    /// Format: `.{filename}.tmp.{process_id}`
434    ///
435    /// # Arguments
436    ///
437    /// * `target_path` - The target file path
438    ///
439    /// # Returns
440    ///
441    /// Path to temporary file in the same directory.
442    ///
443    /// # Errors
444    ///
445    /// Returns error if the path has no parent directory or filename.
446    fn get_temp_path(&self, target_path: &Path) -> Result<PathBuf, MigrationError> {
447        let parent = target_path.parent().ok_or_else(|| {
448            MigrationError::PathResolution("Path has no parent directory".to_string())
449        })?;
450
451        let file_name = target_path
452            .file_name()
453            .ok_or_else(|| MigrationError::PathResolution("Path has no file name".to_string()))?;
454
455        let tmp_name = format!(
456            ".{}.tmp.{}",
457            file_name.to_string_lossy(),
458            std::process::id()
459        );
460        Ok(parent.join(tmp_name))
461    }
462
463    /// Atomically rename temporary file to target path with retry.
464    ///
465    /// Retries the rename operation according to the configured retry count,
466    /// with a small delay between attempts.
467    ///
468    /// # Arguments
469    ///
470    /// * `tmp_path` - Path to temporary file
471    /// * `target_path` - Target file path
472    ///
473    /// # Errors
474    ///
475    /// Returns error if all retry attempts fail.
476    fn atomic_rename(&self, tmp_path: &Path, target_path: &Path) -> Result<(), MigrationError> {
477        let mut last_error = None;
478
479        for attempt in 0..self.strategy.atomic_write.retry_count {
480            match fs::rename(tmp_path, target_path) {
481                Ok(()) => return Ok(()),
482                Err(e) => {
483                    last_error = Some(e);
484                    if attempt + 1 < self.strategy.atomic_write.retry_count {
485                        // Small delay before retry
486                        std::thread::sleep(std::time::Duration::from_millis(10));
487                    }
488                }
489            }
490        }
491
492        Err(MigrationError::IoError {
493            path: target_path.display().to_string(),
494            error: format!(
495                "Failed to rename after {} attempts: {}",
496                self.strategy.atomic_write.retry_count,
497                last_error.unwrap()
498            ),
499        })
500    }
501
502    /// Clean up old temporary files (best effort).
503    ///
504    /// Attempts to remove old temporary files that may have been left behind
505    /// from previous failed operations. Errors are silently ignored.
506    ///
507    /// # Arguments
508    ///
509    /// * `target_path` - The target file path (used to find related temp files)
510    fn cleanup_temp_files(&self, target_path: &Path) -> std::io::Result<()> {
511        let parent = match target_path.parent() {
512            Some(p) => p,
513            None => return Ok(()),
514        };
515
516        let file_name = match target_path.file_name() {
517            Some(f) => f.to_string_lossy(),
518            None => return Ok(()),
519        };
520
521        let prefix = format!(".{}.tmp.", file_name);
522
523        if let Ok(entries) = fs::read_dir(parent) {
524            for entry in entries.flatten() {
525                if let Ok(name) = entry.file_name().into_string() {
526                    if name.starts_with(&prefix) {
527                        // Try to remove, but ignore errors (best effort)
528                        let _ = fs::remove_file(entry.path());
529                    }
530                }
531            }
532        }
533
534        Ok(())
535    }
536
537    /// Load an entity from a file.
538    ///
539    /// # Arguments
540    ///
541    /// * `entity_name` - The entity name registered in the migrator
542    /// * `id` - The unique identifier for the entity
543    ///
544    /// # Process
545    ///
546    /// 1. Gets the file path using `id_to_path`
547    /// 2. Reads the file content to a string
548    /// 3. Deserializes the content to a `serde_json::Value`
549    /// 4. Migrates the `Value` to the target domain type
550    ///
551    /// # Errors
552    ///
553    /// Returns error if:
554    /// - Entity name not registered in migrator
555    /// - File not found
556    /// - Deserialization fails
557    /// - Migration fails
558    ///
559    /// # Example
560    ///
561    /// ```ignore
562    /// let session: SessionEntity = storage.load("session", "session-123")?;
563    /// ```
564    pub fn load<D>(&self, entity_name: &str, id: &str) -> Result<D, MigrationError>
565    where
566        D: serde::de::DeserializeOwned,
567    {
568        // Get file path
569        let file_path = self.id_to_path(id)?;
570
571        // Check if file exists
572        if !file_path.exists() {
573            return Err(MigrationError::IoError {
574                path: file_path.display().to_string(),
575                error: "File not found".to_string(),
576            });
577        }
578
579        // Read file content
580        let content = fs::read_to_string(&file_path).map_err(|e| MigrationError::IoError {
581            path: file_path.display().to_string(),
582            error: e.to_string(),
583        })?;
584
585        // Deserialize content to JSON value
586        let value = self.deserialize_content(&content)?;
587
588        // Migrate to domain type using load_flat_from
589        self.migrator.load_flat_from(entity_name, value)
590    }
591
592    /// List all entity IDs in the storage directory.
593    ///
594    /// # Returns
595    ///
596    /// A sorted vector of entity IDs (decoded from filenames).
597    ///
598    /// # Errors
599    ///
600    /// Returns error if:
601    /// - Directory read fails
602    /// - Filename decoding fails
603    ///
604    /// # Example
605    ///
606    /// ```ignore
607    /// let ids = storage.list_ids()?;
608    /// for id in ids {
609    ///     println!("Found entity: {}", id);
610    /// }
611    /// ```
612    pub fn list_ids(&self) -> Result<Vec<String>, MigrationError> {
613        // Read directory
614        let entries = fs::read_dir(&self.base_path).map_err(|e| MigrationError::IoError {
615            path: self.base_path.display().to_string(),
616            error: e.to_string(),
617        })?;
618
619        let extension = self.strategy.get_extension();
620        let mut ids = Vec::new();
621
622        for entry in entries {
623            let entry = entry.map_err(|e| MigrationError::IoError {
624                path: self.base_path.display().to_string(),
625                error: e.to_string(),
626            })?;
627
628            let path = entry.path();
629
630            // Check if it's a file with the correct extension
631            if path.is_file() {
632                if let Some(ext) = path.extension() {
633                    if ext == extension.as_str() {
634                        // Extract ID from filename
635                        if let Some(id) = self.path_to_id(&path)? {
636                            ids.push(id);
637                        }
638                    }
639                }
640            }
641        }
642
643        // Sort IDs for consistent ordering
644        ids.sort();
645        Ok(ids)
646    }
647
648    /// Load all entities from the storage directory.
649    ///
650    /// # Arguments
651    ///
652    /// * `entity_name` - The entity name registered in the migrator
653    ///
654    /// # Returns
655    ///
656    /// A vector of `(id, entity)` tuples.
657    ///
658    /// # Errors
659    ///
660    /// Returns error if any entity fails to load. This operation is atomic:
661    /// if any load fails, the whole operation fails.
662    ///
663    /// # Example
664    ///
665    /// ```ignore
666    /// let sessions: Vec<(String, SessionEntity)> = storage.load_all("session")?;
667    /// for (id, session) in sessions {
668    ///     println!("Loaded session {} for user {}", id, session.user_id);
669    /// }
670    /// ```
671    pub fn load_all<D>(&self, entity_name: &str) -> Result<Vec<(String, D)>, MigrationError>
672    where
673        D: serde::de::DeserializeOwned,
674    {
675        let ids = self.list_ids()?;
676        let mut results = Vec::new();
677
678        for id in ids {
679            let entity = self.load(entity_name, &id)?;
680            results.push((id, entity));
681        }
682
683        Ok(results)
684    }
685
686    /// Check if an entity exists.
687    ///
688    /// # Arguments
689    ///
690    /// * `id` - The entity ID
691    ///
692    /// # Returns
693    ///
694    /// `true` if the file exists and is a file, `false` otherwise.
695    ///
696    /// # Example
697    ///
698    /// ```ignore
699    /// if storage.exists("session-123")? {
700    ///     println!("Session exists");
701    /// }
702    /// ```
703    pub fn exists(&self, id: &str) -> Result<bool, MigrationError> {
704        let file_path = self.id_to_path(id)?;
705        Ok(file_path.exists() && file_path.is_file())
706    }
707
708    /// Delete an entity file.
709    ///
710    /// # Arguments
711    ///
712    /// * `id` - The entity ID
713    ///
714    /// # Behavior
715    ///
716    /// This operation is idempotent: deleting a non-existent file is not an error.
717    ///
718    /// # Errors
719    ///
720    /// Returns error if file deletion fails (but not if file doesn't exist).
721    ///
722    /// # Example
723    ///
724    /// ```ignore
725    /// storage.delete("session-123")?;
726    /// ```
727    pub fn delete(&self, id: &str) -> Result<(), MigrationError> {
728        let file_path = self.id_to_path(id)?;
729
730        if file_path.exists() {
731            fs::remove_file(&file_path).map_err(|e| MigrationError::IoError {
732                path: file_path.display().to_string(),
733                error: e.to_string(),
734            })?;
735        }
736
737        Ok(())
738    }
739
740    /// Deserialize file content to a JSON value.
741    ///
742    /// # Arguments
743    ///
744    /// * `content` - The file content as a string
745    ///
746    /// # Returns
747    ///
748    /// A `serde_json::Value` representing the deserialized content.
749    ///
750    /// # Errors
751    ///
752    /// Returns error if deserialization fails.
753    fn deserialize_content(&self, content: &str) -> Result<serde_json::Value, MigrationError> {
754        match self.strategy.format {
755            FormatStrategy::Json => serde_json::from_str(content)
756                .map_err(|e| MigrationError::DeserializationError(e.to_string())),
757            FormatStrategy::Toml => {
758                let toml_value: toml::Value = toml::from_str(content)
759                    .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
760                toml_to_json(toml_value)
761            }
762        }
763    }
764
765    /// Extract the entity ID from a file path.
766    ///
767    /// # Arguments
768    ///
769    /// * `path` - The file path
770    ///
771    /// # Returns
772    ///
773    /// `Some(id)` if the path is valid, `None` otherwise.
774    ///
775    /// # Errors
776    ///
777    /// Returns error if ID decoding fails.
778    fn path_to_id(&self, path: &Path) -> Result<Option<String>, MigrationError> {
779        // Get file stem (filename without extension)
780        let file_stem = match path.file_stem() {
781            Some(stem) => stem.to_string_lossy(),
782            None => return Ok(None),
783        };
784
785        // Decode ID
786        let id = self.decode_id(&file_stem)?;
787        Ok(Some(id))
788    }
789
790    /// Decode a filename stem to an entity ID.
791    ///
792    /// # Arguments
793    ///
794    /// * `filename_stem` - The filename without extension
795    ///
796    /// # Returns
797    ///
798    /// The decoded entity ID.
799    ///
800    /// # Encoding Strategies
801    ///
802    /// - **Direct**: Use filename as-is (no decoding needed)
803    /// - **UrlEncode**: URL-decode the filename (not yet implemented)
804    /// - **Base64**: Base64-decode the filename (not yet implemented)
805    ///
806    /// # Errors
807    ///
808    /// Returns error if decoding fails or strategy is not yet implemented.
809    fn decode_id(&self, filename_stem: &str) -> Result<String, MigrationError> {
810        match self.strategy.filename_encoding {
811            FilenameEncoding::Direct => {
812                // Direct encoding: filename is the ID
813                Ok(filename_stem.to_string())
814            }
815            FilenameEncoding::UrlEncode => {
816                // URL-decode the filename to get the original ID
817                urlencoding::decode(filename_stem)
818                    .map(|s| s.into_owned())
819                    .map_err(|e| MigrationError::FilenameEncoding {
820                        id: filename_stem.to_string(),
821                        reason: format!("Failed to URL-decode filename: {}", e),
822                    })
823            }
824            FilenameEncoding::Base64 => {
825                // Base64-decode the filename using URL-safe encoding without padding
826                URL_SAFE_NO_PAD
827                    .decode(filename_stem.as_bytes())
828                    .map_err(|e| MigrationError::FilenameEncoding {
829                        id: filename_stem.to_string(),
830                        reason: format!("Failed to Base64-decode filename: {}", e),
831                    })
832                    .and_then(|bytes| {
833                        String::from_utf8(bytes).map_err(|e| MigrationError::FilenameEncoding {
834                            id: filename_stem.to_string(),
835                            reason: format!(
836                                "Failed to convert Base64-decoded bytes to UTF-8: {}",
837                                e
838                            ),
839                        })
840                    })
841            }
842        }
843    }
844}
845
846/// Convert JSON value to TOML value.
847///
848/// Helper function for format conversion during serialization.
849fn json_to_toml(json_value: &serde_json::Value) -> Result<toml::Value, MigrationError> {
850    let json_str = serde_json::to_string(json_value)
851        .map_err(|e| MigrationError::SerializationError(e.to_string()))?;
852    let toml_value: toml::Value = serde_json::from_str(&json_str)
853        .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
854    Ok(toml_value)
855}
856
857/// Convert TOML value to JSON value.
858///
859/// Helper function for format conversion during deserialization.
860fn toml_to_json(toml_value: toml::Value) -> Result<serde_json::Value, MigrationError> {
861    let json_str = serde_json::to_string(&toml_value)
862        .map_err(|e| MigrationError::SerializationError(e.to_string()))?;
863    let json_value: serde_json::Value = serde_json::from_str(&json_str)
864        .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
865    Ok(json_value)
866}
867
868#[cfg(test)]
869mod tests {
870    use super::*;
871    use tempfile::TempDir;
872
873    #[test]
874    fn test_filename_encoding_default() {
875        assert_eq!(FilenameEncoding::default(), FilenameEncoding::Direct);
876    }
877
878    #[test]
879    fn test_dir_storage_strategy_default() {
880        let strategy = DirStorageStrategy::default();
881        assert_eq!(strategy.format, FormatStrategy::Json);
882        assert_eq!(strategy.extension, None);
883        assert_eq!(strategy.filename_encoding, FilenameEncoding::Direct);
884    }
885
886    #[test]
887    fn test_dir_storage_strategy_builder() {
888        let strategy = DirStorageStrategy::new()
889            .with_format(FormatStrategy::Toml)
890            .with_extension("data")
891            .with_filename_encoding(FilenameEncoding::Base64)
892            .with_retry_count(5)
893            .with_cleanup(false);
894
895        assert_eq!(strategy.format, FormatStrategy::Toml);
896        assert_eq!(strategy.extension, Some("data".to_string()));
897        assert_eq!(strategy.filename_encoding, FilenameEncoding::Base64);
898        assert_eq!(strategy.atomic_write.retry_count, 5);
899        assert!(!strategy.atomic_write.cleanup_tmp_files);
900    }
901
902    #[test]
903    fn test_dir_storage_strategy_get_extension() {
904        // Default from JSON format
905        let strategy1 = DirStorageStrategy::default();
906        assert_eq!(strategy1.get_extension(), "json");
907
908        // Default from TOML format
909        let strategy2 = DirStorageStrategy::default().with_format(FormatStrategy::Toml);
910        assert_eq!(strategy2.get_extension(), "toml");
911
912        // Custom extension
913        let strategy3 = DirStorageStrategy::default().with_extension("custom");
914        assert_eq!(strategy3.get_extension(), "custom");
915    }
916
917    #[test]
918    fn test_dir_storage_new_creates_directory() {
919        let temp_dir = TempDir::new().unwrap();
920        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
921            temp_dir.path().to_path_buf(),
922        ));
923
924        let migrator = Migrator::new();
925        let strategy = DirStorageStrategy::default();
926
927        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
928
929        // Verify directory was created
930        assert!(storage.base_path.exists());
931        assert!(storage.base_path.is_dir());
932        assert!(storage.base_path.ends_with("data/testapp/sessions"));
933    }
934
935    #[test]
936    fn test_dir_storage_new_idempotent() {
937        let temp_dir = TempDir::new().unwrap();
938        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
939            temp_dir.path().to_path_buf(),
940        ));
941
942        let migrator1 = Migrator::new();
943        let migrator2 = Migrator::new();
944        let strategy = DirStorageStrategy::default();
945
946        // Create storage twice
947        let storage1 =
948            DirStorage::new(paths.clone(), "sessions", migrator1, strategy.clone()).unwrap();
949        let storage2 = DirStorage::new(paths, "sessions", migrator2, strategy).unwrap();
950
951        // Both should succeed and point to the same directory
952        assert_eq!(storage1.base_path, storage2.base_path);
953    }
954
955    // Test entity types for save tests
956    use crate::{FromDomain, IntoDomain, MigratesTo, Versioned};
957    use serde::{Deserialize, Serialize};
958
959    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
960    struct SessionV1_0_0 {
961        id: String,
962        user_id: String,
963    }
964
965    impl Versioned for SessionV1_0_0 {
966        const VERSION: &'static str = "1.0.0";
967    }
968
969    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
970    struct SessionV1_1_0 {
971        id: String,
972        user_id: String,
973        created_at: Option<String>,
974    }
975
976    impl Versioned for SessionV1_1_0 {
977        const VERSION: &'static str = "1.1.0";
978    }
979
980    impl MigratesTo<SessionV1_1_0> for SessionV1_0_0 {
981        fn migrate(self) -> SessionV1_1_0 {
982            SessionV1_1_0 {
983                id: self.id,
984                user_id: self.user_id,
985                created_at: None,
986            }
987        }
988    }
989
990    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
991    struct SessionEntity {
992        id: String,
993        user_id: String,
994        created_at: Option<String>,
995    }
996
997    impl IntoDomain<SessionEntity> for SessionV1_1_0 {
998        fn into_domain(self) -> SessionEntity {
999            SessionEntity {
1000                id: self.id,
1001                user_id: self.user_id,
1002                created_at: self.created_at,
1003            }
1004        }
1005    }
1006
1007    impl FromDomain<SessionEntity> for SessionV1_1_0 {
1008        fn from_domain(domain: SessionEntity) -> Self {
1009            SessionV1_1_0 {
1010                id: domain.id,
1011                user_id: domain.user_id,
1012                created_at: domain.created_at,
1013            }
1014        }
1015    }
1016
1017    fn setup_session_migrator() -> Migrator {
1018        let path = Migrator::define("session")
1019            .from::<SessionV1_0_0>()
1020            .step::<SessionV1_1_0>()
1021            .into_with_save::<SessionEntity>();
1022
1023        let mut migrator = Migrator::new();
1024        migrator.register(path).unwrap();
1025        migrator
1026    }
1027
1028    #[test]
1029    fn test_dir_storage_save_json() {
1030        let temp_dir = TempDir::new().unwrap();
1031        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1032            temp_dir.path().to_path_buf(),
1033        ));
1034
1035        let migrator = setup_session_migrator();
1036        let strategy = DirStorageStrategy::default().with_format(FormatStrategy::Json);
1037        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1038
1039        // Create a session entity
1040        let session = SessionEntity {
1041            id: "session-123".to_string(),
1042            user_id: "user-456".to_string(),
1043            created_at: Some("2024-01-01T00:00:00Z".to_string()),
1044        };
1045
1046        // Save the entity
1047        storage.save("session", "session-123", session).unwrap();
1048
1049        // Verify file was created
1050        let file_path = storage.base_path.join("session-123.json");
1051        assert!(file_path.exists());
1052
1053        // Verify content is valid JSON with version
1054        let content = std::fs::read_to_string(&file_path).unwrap();
1055        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1056        assert_eq!(json["version"], "1.1.0");
1057        assert_eq!(json["id"], "session-123");
1058        assert_eq!(json["user_id"], "user-456");
1059    }
1060
1061    #[test]
1062    fn test_dir_storage_save_toml() {
1063        let temp_dir = TempDir::new().unwrap();
1064        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1065            temp_dir.path().to_path_buf(),
1066        ));
1067
1068        let migrator = setup_session_migrator();
1069        let strategy = DirStorageStrategy::default().with_format(FormatStrategy::Toml);
1070        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1071
1072        // Create a session entity with Some value (TOML doesn't support None/null)
1073        let session = SessionEntity {
1074            id: "session-789".to_string(),
1075            user_id: "user-101".to_string(),
1076            created_at: Some("2024-01-15T10:30:00Z".to_string()),
1077        };
1078
1079        // Save the entity
1080        storage.save("session", "session-789", session).unwrap();
1081
1082        // Verify file was created
1083        let file_path = storage.base_path.join("session-789.toml");
1084        assert!(file_path.exists());
1085
1086        // Verify content is valid TOML with version
1087        let content = std::fs::read_to_string(&file_path).unwrap();
1088        let toml: toml::Value = toml::from_str(&content).unwrap();
1089        assert_eq!(toml["version"].as_str().unwrap(), "1.1.0");
1090        assert_eq!(toml["id"].as_str().unwrap(), "session-789");
1091        assert_eq!(toml["created_at"].as_str().unwrap(), "2024-01-15T10:30:00Z");
1092    }
1093
1094    #[test]
1095    fn test_dir_storage_save_with_invalid_id() {
1096        let temp_dir = TempDir::new().unwrap();
1097        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1098            temp_dir.path().to_path_buf(),
1099        ));
1100
1101        let migrator = setup_session_migrator();
1102        let strategy = DirStorageStrategy::default();
1103        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1104
1105        let session = SessionEntity {
1106            id: "invalid/id".to_string(),
1107            user_id: "user-456".to_string(),
1108            created_at: None,
1109        };
1110
1111        // Should fail due to invalid characters in ID
1112        let result = storage.save("session", "invalid/id", session);
1113        assert!(result.is_err());
1114        assert!(matches!(
1115            result.unwrap_err(),
1116            crate::MigrationError::FilenameEncoding { .. }
1117        ));
1118    }
1119
1120    #[test]
1121    fn test_dir_storage_save_with_custom_extension() {
1122        let temp_dir = TempDir::new().unwrap();
1123        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1124            temp_dir.path().to_path_buf(),
1125        ));
1126
1127        let migrator = setup_session_migrator();
1128        let strategy = DirStorageStrategy::default()
1129            .with_format(FormatStrategy::Json)
1130            .with_extension("data");
1131        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1132
1133        let session = SessionEntity {
1134            id: "session-custom".to_string(),
1135            user_id: "user-999".to_string(),
1136            created_at: None,
1137        };
1138
1139        storage.save("session", "session-custom", session).unwrap();
1140
1141        // Verify custom extension is used
1142        let file_path = storage.base_path.join("session-custom.data");
1143        assert!(file_path.exists());
1144    }
1145
1146    #[test]
1147    fn test_dir_storage_save_overwrites_existing() {
1148        let temp_dir = TempDir::new().unwrap();
1149        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1150            temp_dir.path().to_path_buf(),
1151        ));
1152
1153        let migrator = setup_session_migrator();
1154        let strategy = DirStorageStrategy::default();
1155        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1156
1157        // Save initial version
1158        let session1 = SessionEntity {
1159            id: "session-overwrite".to_string(),
1160            user_id: "user-111".to_string(),
1161            created_at: Some("2024-01-01".to_string()),
1162        };
1163        storage
1164            .save("session", "session-overwrite", session1)
1165            .unwrap();
1166
1167        // Save updated version
1168        let session2 = SessionEntity {
1169            id: "session-overwrite".to_string(),
1170            user_id: "user-222".to_string(),
1171            created_at: Some("2024-01-02".to_string()),
1172        };
1173        storage
1174            .save("session", "session-overwrite", session2)
1175            .unwrap();
1176
1177        // Verify file was overwritten
1178        let file_path = storage.base_path.join("session-overwrite.json");
1179        let content = std::fs::read_to_string(&file_path).unwrap();
1180        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1181        assert_eq!(json["user_id"], "user-222");
1182        assert_eq!(json["created_at"], "2024-01-02");
1183    }
1184
1185    #[test]
1186    fn test_dir_storage_load_success() {
1187        let temp_dir = TempDir::new().unwrap();
1188        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1189            temp_dir.path().to_path_buf(),
1190        ));
1191
1192        let migrator = setup_session_migrator();
1193        let strategy = DirStorageStrategy::default();
1194        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1195
1196        // Save a session
1197        let session = SessionEntity {
1198            id: "session-load".to_string(),
1199            user_id: "user-999".to_string(),
1200            created_at: Some("2024-02-01".to_string()),
1201        };
1202        storage
1203            .save("session", "session-load", session.clone())
1204            .unwrap();
1205
1206        // Load it back
1207        let loaded: SessionEntity = storage.load("session", "session-load").unwrap();
1208        assert_eq!(loaded.id, session.id);
1209        assert_eq!(loaded.user_id, session.user_id);
1210        assert_eq!(loaded.created_at, session.created_at);
1211    }
1212
1213    #[test]
1214    fn test_dir_storage_load_not_found() {
1215        let temp_dir = TempDir::new().unwrap();
1216        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1217            temp_dir.path().to_path_buf(),
1218        ));
1219
1220        let migrator = setup_session_migrator();
1221        let strategy = DirStorageStrategy::default();
1222        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1223
1224        // Try to load non-existent session
1225        let result: Result<SessionEntity, _> = storage.load("session", "non-existent");
1226        assert!(result.is_err());
1227        assert!(matches!(
1228            result.unwrap_err(),
1229            MigrationError::IoError { .. }
1230        ));
1231    }
1232
1233    #[test]
1234    fn test_dir_storage_save_and_load_roundtrip() {
1235        let temp_dir = TempDir::new().unwrap();
1236        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1237            temp_dir.path().to_path_buf(),
1238        ));
1239
1240        let migrator = setup_session_migrator();
1241        let strategy = DirStorageStrategy::default();
1242        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1243
1244        // Test multiple sessions
1245        let sessions = vec![
1246            SessionEntity {
1247                id: "session-1".to_string(),
1248                user_id: "user-1".to_string(),
1249                created_at: Some("2024-01-01".to_string()),
1250            },
1251            SessionEntity {
1252                id: "session-2".to_string(),
1253                user_id: "user-2".to_string(),
1254                created_at: None,
1255            },
1256            SessionEntity {
1257                id: "session-3".to_string(),
1258                user_id: "user-3".to_string(),
1259                created_at: Some("2024-03-01".to_string()),
1260            },
1261        ];
1262
1263        // Save all sessions
1264        for session in &sessions {
1265            storage
1266                .save("session", &session.id, session.clone())
1267                .unwrap();
1268        }
1269
1270        // Load and verify each session
1271        for session in &sessions {
1272            let loaded: SessionEntity = storage.load("session", &session.id).unwrap();
1273            assert_eq!(loaded.id, session.id);
1274            assert_eq!(loaded.user_id, session.user_id);
1275            assert_eq!(loaded.created_at, session.created_at);
1276        }
1277    }
1278
1279    #[test]
1280    fn test_dir_storage_list_ids_empty() {
1281        let temp_dir = TempDir::new().unwrap();
1282        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1283            temp_dir.path().to_path_buf(),
1284        ));
1285
1286        let migrator = setup_session_migrator();
1287        let strategy = DirStorageStrategy::default();
1288        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1289
1290        // List IDs from empty directory
1291        let ids = storage.list_ids().unwrap();
1292        assert!(ids.is_empty());
1293    }
1294
1295    #[test]
1296    fn test_dir_storage_list_ids() {
1297        let temp_dir = TempDir::new().unwrap();
1298        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1299            temp_dir.path().to_path_buf(),
1300        ));
1301
1302        let migrator = setup_session_migrator();
1303        let strategy = DirStorageStrategy::default();
1304        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1305
1306        // Save multiple sessions
1307        let ids = vec!["session-c", "session-a", "session-b"];
1308        for id in &ids {
1309            let session = SessionEntity {
1310                id: id.to_string(),
1311                user_id: "user".to_string(),
1312                created_at: None,
1313            };
1314            storage.save("session", id, session).unwrap();
1315        }
1316
1317        // List IDs
1318        let listed_ids = storage.list_ids().unwrap();
1319        assert_eq!(listed_ids.len(), 3);
1320        // Should be sorted
1321        assert_eq!(listed_ids, vec!["session-a", "session-b", "session-c"]);
1322    }
1323
1324    #[test]
1325    fn test_dir_storage_load_all_empty() {
1326        let temp_dir = TempDir::new().unwrap();
1327        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1328            temp_dir.path().to_path_buf(),
1329        ));
1330
1331        let migrator = setup_session_migrator();
1332        let strategy = DirStorageStrategy::default();
1333        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1334
1335        // Load all from empty directory
1336        let results: Vec<(String, SessionEntity)> = storage.load_all("session").unwrap();
1337        assert!(results.is_empty());
1338    }
1339
1340    #[test]
1341    fn test_dir_storage_load_all() {
1342        let temp_dir = TempDir::new().unwrap();
1343        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1344            temp_dir.path().to_path_buf(),
1345        ));
1346
1347        let migrator = setup_session_migrator();
1348        let strategy = DirStorageStrategy::default();
1349        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1350
1351        // Save multiple sessions
1352        let sessions = vec![
1353            SessionEntity {
1354                id: "session-x".to_string(),
1355                user_id: "user-x".to_string(),
1356                created_at: Some("2024-01-01".to_string()),
1357            },
1358            SessionEntity {
1359                id: "session-y".to_string(),
1360                user_id: "user-y".to_string(),
1361                created_at: None,
1362            },
1363            SessionEntity {
1364                id: "session-z".to_string(),
1365                user_id: "user-z".to_string(),
1366                created_at: Some("2024-03-01".to_string()),
1367            },
1368        ];
1369
1370        for session in &sessions {
1371            storage
1372                .save("session", &session.id, session.clone())
1373                .unwrap();
1374        }
1375
1376        // Load all
1377        let results: Vec<(String, SessionEntity)> = storage.load_all("session").unwrap();
1378        assert_eq!(results.len(), 3);
1379
1380        // Verify all sessions are loaded
1381        for (id, loaded) in &results {
1382            let original = sessions.iter().find(|s| &s.id == id).unwrap();
1383            assert_eq!(loaded.id, original.id);
1384            assert_eq!(loaded.user_id, original.user_id);
1385            assert_eq!(loaded.created_at, original.created_at);
1386        }
1387    }
1388
1389    #[test]
1390    fn test_dir_storage_exists() {
1391        let temp_dir = TempDir::new().unwrap();
1392        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1393            temp_dir.path().to_path_buf(),
1394        ));
1395
1396        let migrator = setup_session_migrator();
1397        let strategy = DirStorageStrategy::default();
1398        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1399
1400        // Non-existent file
1401        assert!(!storage.exists("session-exists").unwrap());
1402
1403        // Save a session
1404        let session = SessionEntity {
1405            id: "session-exists".to_string(),
1406            user_id: "user-exists".to_string(),
1407            created_at: None,
1408        };
1409        storage.save("session", "session-exists", session).unwrap();
1410
1411        // Should exist now
1412        assert!(storage.exists("session-exists").unwrap());
1413    }
1414
1415    #[test]
1416    fn test_dir_storage_delete() {
1417        let temp_dir = TempDir::new().unwrap();
1418        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1419            temp_dir.path().to_path_buf(),
1420        ));
1421
1422        let migrator = setup_session_migrator();
1423        let strategy = DirStorageStrategy::default();
1424        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1425
1426        // Save a session
1427        let session = SessionEntity {
1428            id: "session-delete".to_string(),
1429            user_id: "user-delete".to_string(),
1430            created_at: None,
1431        };
1432        storage.save("session", "session-delete", session).unwrap();
1433
1434        // Verify it exists
1435        assert!(storage.exists("session-delete").unwrap());
1436
1437        // Delete it
1438        storage.delete("session-delete").unwrap();
1439
1440        // Verify it doesn't exist
1441        assert!(!storage.exists("session-delete").unwrap());
1442    }
1443
1444    #[test]
1445    fn test_dir_storage_delete_idempotent() {
1446        let temp_dir = TempDir::new().unwrap();
1447        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1448            temp_dir.path().to_path_buf(),
1449        ));
1450
1451        let migrator = setup_session_migrator();
1452        let strategy = DirStorageStrategy::default();
1453        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1454
1455        // Delete non-existent file (should not error)
1456        storage.delete("non-existent").unwrap();
1457
1458        // Delete again (should still not error)
1459        storage.delete("non-existent").unwrap();
1460    }
1461
1462    #[test]
1463    fn test_dir_storage_load_toml() {
1464        let temp_dir = TempDir::new().unwrap();
1465        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1466            temp_dir.path().to_path_buf(),
1467        ));
1468
1469        let migrator = setup_session_migrator();
1470        let strategy = DirStorageStrategy::default().with_format(FormatStrategy::Toml);
1471        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1472
1473        // Save a session
1474        let session = SessionEntity {
1475            id: "session-toml".to_string(),
1476            user_id: "user-toml".to_string(),
1477            created_at: Some("2024-04-01".to_string()),
1478        };
1479        storage
1480            .save("session", "session-toml", session.clone())
1481            .unwrap();
1482
1483        // Load it back
1484        let loaded: SessionEntity = storage.load("session", "session-toml").unwrap();
1485        assert_eq!(loaded.id, session.id);
1486        assert_eq!(loaded.user_id, session.user_id);
1487        assert_eq!(loaded.created_at, session.created_at);
1488    }
1489
1490    #[test]
1491    fn test_dir_storage_list_ids_with_custom_extension() {
1492        let temp_dir = TempDir::new().unwrap();
1493        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1494            temp_dir.path().to_path_buf(),
1495        ));
1496
1497        let migrator = setup_session_migrator();
1498        let strategy = DirStorageStrategy::default().with_extension("data");
1499        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1500
1501        // Save sessions with custom extension
1502        let session = SessionEntity {
1503            id: "session-ext".to_string(),
1504            user_id: "user-ext".to_string(),
1505            created_at: None,
1506        };
1507        storage.save("session", "session-ext", session).unwrap();
1508
1509        // List IDs should find the file
1510        let ids = storage.list_ids().unwrap();
1511        assert_eq!(ids.len(), 1);
1512        assert_eq!(ids[0], "session-ext");
1513    }
1514
1515    #[test]
1516    fn test_dir_storage_load_all_atomic_failure() {
1517        let temp_dir = TempDir::new().unwrap();
1518        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1519            temp_dir.path().to_path_buf(),
1520        ));
1521
1522        let migrator = setup_session_migrator();
1523        let strategy = DirStorageStrategy::default();
1524        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1525
1526        // Save valid sessions
1527        let session1 = SessionEntity {
1528            id: "session-1".to_string(),
1529            user_id: "user-1".to_string(),
1530            created_at: None,
1531        };
1532        storage.save("session", "session-1", session1).unwrap();
1533
1534        // Manually create a corrupted file
1535        let corrupted_path = storage.base_path.join("session-corrupted.json");
1536        std::fs::write(&corrupted_path, "invalid json {{{").unwrap();
1537
1538        // load_all should fail
1539        let result: Result<Vec<(String, SessionEntity)>, _> = storage.load_all("session");
1540        assert!(result.is_err());
1541    }
1542
1543    #[test]
1544    fn test_dir_storage_filename_encoding_url_roundtrip() {
1545        let temp_dir = TempDir::new().unwrap();
1546        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1547            temp_dir.path().to_path_buf(),
1548        ));
1549
1550        let migrator = setup_session_migrator();
1551        let strategy =
1552            DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
1553        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1554
1555        // Use an ID with special characters that need URL encoding
1556        let complex_id = "user@example.com/path?query=1";
1557        let session = SessionEntity {
1558            id: complex_id.to_string(),
1559            user_id: "user-special".to_string(),
1560            created_at: Some("2024-05-01".to_string()),
1561        };
1562
1563        // Save the entity
1564        storage
1565            .save("session", complex_id, session.clone())
1566            .unwrap();
1567
1568        // Verify the file was created with encoded filename
1569        let encoded_id = urlencoding::encode(complex_id);
1570        let file_path = storage.base_path.join(format!("{}.json", encoded_id));
1571        assert!(file_path.exists());
1572
1573        // Load it back
1574        let loaded: SessionEntity = storage.load("session", complex_id).unwrap();
1575        assert_eq!(loaded.id, session.id);
1576        assert_eq!(loaded.user_id, session.user_id);
1577        assert_eq!(loaded.created_at, session.created_at);
1578
1579        // Verify list_ids works correctly
1580        let ids = storage.list_ids().unwrap();
1581        assert_eq!(ids.len(), 1);
1582        assert_eq!(ids[0], complex_id);
1583    }
1584
1585    #[test]
1586    fn test_dir_storage_filename_encoding_base64_roundtrip() {
1587        let temp_dir = TempDir::new().unwrap();
1588        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1589            temp_dir.path().to_path_buf(),
1590        ));
1591
1592        let migrator = setup_session_migrator();
1593        let strategy =
1594            DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
1595        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1596
1597        // Use a complex ID with various special characters
1598        let complex_id = "user@example.com/path?query=1&special=!@#$%";
1599        let session = SessionEntity {
1600            id: complex_id.to_string(),
1601            user_id: "user-base64".to_string(),
1602            created_at: Some("2024-06-01".to_string()),
1603        };
1604
1605        // Save the entity
1606        storage
1607            .save("session", complex_id, session.clone())
1608            .unwrap();
1609
1610        // Verify the file was created with Base64-encoded filename
1611        let encoded_id = URL_SAFE_NO_PAD.encode(complex_id.as_bytes());
1612        let file_path = storage.base_path.join(format!("{}.json", encoded_id));
1613        assert!(file_path.exists());
1614
1615        // Load it back
1616        let loaded: SessionEntity = storage.load("session", complex_id).unwrap();
1617        assert_eq!(loaded.id, session.id);
1618        assert_eq!(loaded.user_id, session.user_id);
1619        assert_eq!(loaded.created_at, session.created_at);
1620
1621        // Verify list_ids works correctly
1622        let ids = storage.list_ids().unwrap();
1623        assert_eq!(ids.len(), 1);
1624        assert_eq!(ids[0], complex_id);
1625    }
1626
1627    #[test]
1628    fn test_decode_id_error_handling() {
1629        let temp_dir = TempDir::new().unwrap();
1630        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1631            temp_dir.path().to_path_buf(),
1632        ));
1633
1634        // Test UrlEncode decoding with invalid percent encoding
1635        // The urlencoding crate handles most cases gracefully, but we can test
1636        // that it properly decodes even with partial sequences (which it does)
1637        let migrator_url = setup_session_migrator();
1638        let strategy_url =
1639            DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
1640        let storage_url =
1641            DirStorage::new(paths.clone(), "sessions_url", migrator_url, strategy_url).unwrap();
1642
1643        // Invalid UTF-8 percent encoding - this should fail
1644        let invalid_url_encoded = "%C0%C1"; // Invalid UTF-8 sequence
1645        let result = storage_url.decode_id(invalid_url_encoded);
1646        assert!(result.is_err());
1647        if let Err(MigrationError::FilenameEncoding { id, reason }) = result {
1648            assert_eq!(id, invalid_url_encoded);
1649            assert!(reason.contains("Failed to URL-decode filename"));
1650        }
1651
1652        // Test Base64 decoding with invalid input
1653        let migrator_base64 = setup_session_migrator();
1654        let strategy_base64 =
1655            DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
1656        let storage_base64 =
1657            DirStorage::new(paths, "sessions_base64", migrator_base64, strategy_base64).unwrap();
1658
1659        // Invalid Base64 string (contains invalid characters)
1660        let invalid_base64 = "!!!invalid@@@";
1661        let result = storage_base64.decode_id(invalid_base64);
1662        assert!(result.is_err());
1663        if let Err(MigrationError::FilenameEncoding { id, reason }) = result {
1664            assert_eq!(id, invalid_base64);
1665            assert!(reason.contains("Failed to Base64-decode filename"));
1666        }
1667
1668        // Test Base64 with valid Base64 but invalid UTF-8
1669        // Create a Base64 string from invalid UTF-8 bytes
1670        let invalid_utf8_bytes = vec![0xFF, 0xFE, 0xFD];
1671        let valid_base64_invalid_utf8 = URL_SAFE_NO_PAD.encode(&invalid_utf8_bytes);
1672        let result = storage_base64.decode_id(&valid_base64_invalid_utf8);
1673        assert!(result.is_err());
1674        if let Err(MigrationError::FilenameEncoding { id, reason }) = result {
1675            assert_eq!(id, valid_base64_invalid_utf8);
1676            assert!(reason.contains("Failed to convert Base64-decoded bytes to UTF-8"));
1677        }
1678    }
1679}