Skip to main content

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 std::path::Path;
43
44// Re-export shared types from local_store.
45pub use local_store::{AtomicWriteConfig, DirStorageStrategy, FilenameEncoding, FormatStrategy};
46
47/// Directory-based entity storage with ACID guarantees and automatic migrations.
48///
49/// Manages one file per entity. Raw IO (atomic rename, fsync, temp-file cleanup,
50/// ID encoding/decoding, directory listing) is fully delegated to
51/// `local_store::DirStorage`.
52///
53/// # Responsibilities
54///
55/// - Serialising/deserialising entities to/from the configured format.
56/// - Delegating all ACID / atomic-rename / lock operations to `inner`.
57/// - Applying `Migrator`-based schema evolution on load.
58pub struct DirStorage {
59    /// Raw ACID-safe directory store (no migration knowledge).
60    inner: local_store::DirStorage,
61    /// Migrator for schema evolution on save/load.
62    migrator: Migrator,
63    /// Strategy for format dispatch (JSON / TOML).
64    strategy: local_store::DirStorageStrategy,
65}
66
67impl DirStorage {
68    /// Create a new `DirStorage` instance.
69    ///
70    /// # Arguments
71    ///
72    /// * `paths` - Application paths manager.
73    /// * `domain_name` - Domain-specific subdirectory name (e.g., `"sessions"`).
74    /// * `migrator` - Migrator instance with registered migration paths.
75    /// * `strategy` - Storage strategy (format, encoding, atomic-write config).
76    ///
77    /// # Behavior
78    ///
79    /// Delegates directory creation to `local_store::DirStorage::new`. The base
80    /// path is resolved as `data_dir/domain_name` and created if absent.
81    ///
82    /// # Errors
83    ///
84    /// Returns `MigrationError::Store` wrapping a `StoreError::IoError` if
85    /// directory creation fails.
86    pub fn new(
87        paths: AppPaths,
88        domain_name: &str,
89        migrator: Migrator,
90        strategy: DirStorageStrategy,
91    ) -> Result<Self, MigrationError> {
92        let inner = local_store::DirStorage::new(paths, domain_name, strategy.clone())
93            .map_err(store_err_to_migration)?;
94        Ok(Self {
95            inner,
96            migrator,
97            strategy,
98        })
99    }
100
101    /// Save an entity to its file atomically.
102    ///
103    /// # Arguments
104    ///
105    /// * `entity_name` - Entity name registered in the migrator.
106    /// * `id` - Unique identifier for this entity (used as the filename stem).
107    /// * `entity` - Value to persist; must implement `serde::Serialize`.
108    ///
109    /// # Process
110    ///
111    /// 1. Converts `entity` to its latest versioned DTO via `migrator.save_domain_flat`.
112    /// 2. Serialises to the configured format (JSON or TOML).
113    /// 3. Delegates atomic write (tmp file + fsync + rename) to `inner.save_raw_string`.
114    ///
115    /// # Errors
116    ///
117    /// Returns `MigrationError` if:
118    /// - `entity_name` is not registered in the migrator.
119    /// - `id` contains invalid characters for the configured encoding.
120    /// - Serialisation or format conversion fails.
121    /// - The underlying file write fails.
122    pub fn save<T>(&self, entity_name: &str, id: &str, entity: T) -> Result<(), MigrationError>
123    where
124        T: serde::Serialize,
125    {
126        let json_string = self.migrator.save_domain_flat(entity_name, entity)?;
127
128        let versioned_value: serde_json::Value = serde_json::from_str(&json_string)
129            .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
130
131        let content = match self.strategy.format {
132            FormatStrategy::Json => serde_json::to_string_pretty(&versioned_value)
133                .map_err(|e| MigrationError::SerializationError(e.to_string()))?,
134            FormatStrategy::Toml => {
135                let tv = local_store::json_to_toml(&versioned_value).map_err(|e| {
136                    MigrationError::Store(local_store::StoreError::FormatConvert(e))
137                })?;
138                toml::to_string_pretty(&tv)
139                    .map_err(|e| MigrationError::TomlSerializeError(e.to_string()))?
140            }
141        };
142
143        self.inner
144            .save_raw_string(entity_name, id, &content)
145            .map_err(store_err_to_migration)
146    }
147
148    /// Load an entity from its file, applying schema migrations if needed.
149    ///
150    /// # Arguments
151    ///
152    /// * `entity_name` - Entity name registered in the migrator.
153    /// * `id` - Unique identifier for the entity.
154    ///
155    /// # Process
156    ///
157    /// 1. Reads raw string content via `inner.load_raw_string`.
158    /// 2. Deserialises to `serde_json::Value` (converting from TOML if needed).
159    /// 3. Applies schema migration via `migrator.load_flat_from`.
160    ///
161    /// # Errors
162    ///
163    /// Returns `MigrationError` if the file is missing, parsing fails, or
164    /// migration fails.
165    pub fn load<D>(&self, entity_name: &str, id: &str) -> Result<D, MigrationError>
166    where
167        D: serde::de::DeserializeOwned,
168    {
169        let content = self
170            .inner
171            .load_raw_string(id)
172            .map_err(store_err_to_migration)?;
173
174        let value = match self.strategy.format {
175            FormatStrategy::Json => serde_json::from_str(&content)
176                .map_err(|e| MigrationError::DeserializationError(e.to_string()))?,
177            FormatStrategy::Toml => {
178                let tv: toml::Value = toml::from_str(&content)
179                    .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
180                toml_to_json(tv)?
181            }
182        };
183
184        self.migrator.load_flat_from(entity_name, value)
185    }
186
187    /// List all entity IDs in the storage directory in lexicographic ascending order.
188    ///
189    /// # Returns
190    ///
191    /// A `Vec<String>` of decoded entity IDs, sorted lexicographically.
192    ///
193    /// # Errors
194    ///
195    /// Returns `MigrationError` if the directory cannot be read or a filename
196    /// cannot be decoded.
197    pub fn list_ids(&self) -> Result<Vec<String>, MigrationError> {
198        let mut ids = self.inner.list_ids().map_err(store_err_to_migration)?;
199        // Guarantee lexicographic ascending order even if inner changes behaviour.
200        ids.sort();
201        Ok(ids)
202    }
203
204    /// Load all entities from the storage directory.
205    ///
206    /// # Arguments
207    ///
208    /// * `entity_name` - Entity name registered in the migrator.
209    ///
210    /// # Returns
211    ///
212    /// A `Vec<(id, entity)>` of all stored entities, ordered by ID.
213    ///
214    /// # Errors
215    ///
216    /// Returns `MigrationError` if any entity fails to load. The whole
217    /// operation fails atomically.
218    pub fn load_all<D>(&self, entity_name: &str) -> Result<Vec<(String, D)>, MigrationError>
219    where
220        D: serde::de::DeserializeOwned,
221    {
222        let ids = self.list_ids()?;
223        let mut results = Vec::new();
224        for id in ids {
225            let entity = self.load(entity_name, &id)?;
226            results.push((id, entity));
227        }
228        Ok(results)
229    }
230
231    /// Check whether an entity file exists.
232    ///
233    /// # Arguments
234    ///
235    /// * `id` - Entity identifier.
236    ///
237    /// # Returns
238    ///
239    /// `true` if the file exists; `false` otherwise.
240    ///
241    /// # Errors
242    ///
243    /// Returns `MigrationError` if `id` cannot be encoded.
244    pub fn exists(&self, id: &str) -> Result<bool, MigrationError> {
245        self.inner.exists(id).map_err(store_err_to_migration)
246    }
247
248    /// Delete an entity file (idempotent).
249    ///
250    /// # Arguments
251    ///
252    /// * `id` - Entity identifier.
253    ///
254    /// # Behavior
255    ///
256    /// Deleting a non-existent entity is not an error (`local_store::DirStorage`
257    /// guarantees idempotency).
258    ///
259    /// # Errors
260    ///
261    /// Returns `MigrationError` if the underlying file deletion fails.
262    pub fn delete(&self, id: &str) -> Result<(), MigrationError> {
263        self.inner.delete(id).map_err(store_err_to_migration)
264    }
265
266    /// Returns a reference to the base directory path.
267    ///
268    /// # Returns
269    ///
270    /// A reference to the resolved base directory path where entity files are stored.
271    pub fn base_path(&self) -> &Path {
272        self.inner.base_path()
273    }
274}
275
276/// Convert a `local_store::StoreError` to `MigrationError`, promoting
277/// `StoreError::FilenameEncoding` to the dedicated `MigrationError::FilenameEncoding`
278/// variant.
279fn store_err_to_migration(e: local_store::StoreError) -> MigrationError {
280    match e {
281        local_store::StoreError::FilenameEncoding { id, reason } => {
282            MigrationError::FilenameEncoding { id, reason }
283        }
284        other => MigrationError::Store(other),
285    }
286}
287
288/// Convert TOML value to JSON value.
289///
290/// Used by the sync `DirStorage::load` for TOML deserialisation.
291fn toml_to_json(toml_value: toml::Value) -> Result<serde_json::Value, MigrationError> {
292    let json_str = serde_json::to_string(&toml_value)
293        .map_err(|e| MigrationError::SerializationError(e.to_string()))?;
294    let json_value: serde_json::Value = serde_json::from_str(&json_str)
295        .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
296    Ok(json_value)
297}
298
299// ============================================================================
300// Async implementation
301// ============================================================================
302
303#[cfg(feature = "async")]
304pub use async_impl::AsyncDirStorage;
305
306#[cfg(feature = "async")]
307mod async_impl {
308    use crate::{AppPaths, MigrationError, Migrator};
309    use std::path::Path;
310
311    use super::{store_err_to_migration, toml_to_json, DirStorageStrategy, FormatStrategy};
312
313    /// Async version of DirStorage for directory-based entity storage.
314    ///
315    /// Wraps `local_store::AsyncDirStorage` for raw async IO and layers
316    /// `Migrator`-based schema evolution on top. All atomic IO (atomic rename,
317    /// fsync, retry) is fully delegated to `inner`; no `tokio::fs::*` calls
318    /// remain in this struct.
319    pub struct AsyncDirStorage {
320        /// Raw ACID-safe async directory store (no migration knowledge).
321        inner: local_store::AsyncDirStorage,
322        /// Migrator for schema evolution on save/load.
323        migrator: Migrator,
324        /// Strategy for format dispatch (JSON / TOML).
325        strategy: DirStorageStrategy,
326    }
327
328    impl AsyncDirStorage {
329        /// Create a new `AsyncDirStorage` instance (async).
330        ///
331        /// Resolves the base path as `paths.data_dir()?.join(domain_name)`, creates
332        /// the directory when absent, and wraps the raw
333        /// `local_store::AsyncDirStorage`.
334        ///
335        /// # Errors
336        ///
337        /// Returns `MigrationError::Store` if directory creation fails.
338        pub async fn new(
339            paths: AppPaths,
340            domain_name: &str,
341            migrator: Migrator,
342            strategy: DirStorageStrategy,
343        ) -> Result<Self, MigrationError> {
344            let inner = local_store::AsyncDirStorage::new(paths, domain_name, strategy.clone())
345                .await
346                .map_err(store_err_to_migration)?;
347            Ok(Self {
348                inner,
349                migrator,
350                strategy,
351            })
352        }
353
354        /// Save an entity to a file (async).
355        ///
356        /// Converts the entity to its latest versioned DTO, applies format
357        /// serialisation, and delegates the atomic write to
358        /// `local_store::AsyncDirStorage::save_raw_string`.
359        ///
360        /// # Errors
361        ///
362        /// Returns `MigrationError` on serialisation failure, invalid ID, or IO errors.
363        pub async fn save<T>(
364            &self,
365            entity_name: &str,
366            id: &str,
367            entity: T,
368        ) -> Result<(), MigrationError>
369        where
370            T: serde::Serialize,
371        {
372            let json_string = self.migrator.save_domain_flat(entity_name, entity)?;
373
374            let versioned_value: serde_json::Value = serde_json::from_str(&json_string)
375                .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
376
377            let content = self.serialize_content(&versioned_value)?;
378
379            self.inner
380                .save_raw_string(entity_name, id, &content)
381                .await
382                .map_err(store_err_to_migration)
383        }
384
385        /// Load an entity from a file (async).
386        ///
387        /// Reads the raw string, deserialises to `serde_json::Value`, and migrates
388        /// to the target domain type.
389        ///
390        /// # Errors
391        ///
392        /// Returns `MigrationError` if the file is not found, deserialisation fails,
393        /// or migration fails.
394        pub async fn load<D>(&self, entity_name: &str, id: &str) -> Result<D, MigrationError>
395        where
396            D: serde::de::DeserializeOwned,
397        {
398            let content = self
399                .inner
400                .load_raw_string(id)
401                .await
402                .map_err(store_err_to_migration)?;
403
404            let value = self.deserialize_content(&content)?;
405            self.migrator.load_flat_from(entity_name, value)
406        }
407
408        /// List all entity IDs in the storage directory in lexicographic ascending order (async).
409        ///
410        /// # Errors
411        ///
412        /// Returns `MigrationError::Store` if the directory read fails.
413        pub async fn list_ids(&self) -> Result<Vec<String>, MigrationError> {
414            let mut ids = self
415                .inner
416                .list_ids()
417                .await
418                .map_err(store_err_to_migration)?;
419            ids.sort();
420            Ok(ids)
421        }
422
423        /// Load all entities from the storage directory (async).
424        ///
425        /// # Errors
426        ///
427        /// Returns the first `MigrationError` encountered during loading.
428        pub async fn load_all<D>(
429            &self,
430            entity_name: &str,
431        ) -> Result<Vec<(String, D)>, MigrationError>
432        where
433            D: serde::de::DeserializeOwned,
434        {
435            let ids = self.list_ids().await?;
436            let mut results = Vec::new();
437            for id in ids {
438                let entity = self.load(entity_name, &id).await?;
439                results.push((id, entity));
440            }
441            Ok(results)
442        }
443
444        /// Check whether an entity file exists (async).
445        ///
446        /// # Errors
447        ///
448        /// Returns `MigrationError::Store` on ID encoding failure or IO error.
449        pub async fn exists(&self, id: &str) -> Result<bool, MigrationError> {
450            self.inner.exists(id).await.map_err(store_err_to_migration)
451        }
452
453        /// Delete an entity file (async).
454        ///
455        /// This operation is idempotent: deleting a non-existent file is not an error.
456        ///
457        /// # Errors
458        ///
459        /// Returns `MigrationError::Store` if file deletion fails.
460        pub async fn delete(&self, id: &str) -> Result<(), MigrationError> {
461            self.inner.delete(id).await.map_err(store_err_to_migration)
462        }
463
464        /// Returns a reference to the base directory path.
465        pub fn base_path(&self) -> &Path {
466            self.inner.base_path()
467        }
468
469        // ================================================================
470        // Private format helpers
471        // ================================================================
472
473        fn serialize_content(&self, value: &serde_json::Value) -> Result<String, MigrationError> {
474            match self.strategy.format {
475                FormatStrategy::Json => serde_json::to_string_pretty(value)
476                    .map_err(|e| MigrationError::SerializationError(e.to_string())),
477                FormatStrategy::Toml => {
478                    let tv = local_store::format_convert::json_to_toml(value).map_err(|e| {
479                        MigrationError::Store(local_store::StoreError::FormatConvert(e))
480                    })?;
481                    toml::to_string_pretty(&tv)
482                        .map_err(|e| MigrationError::TomlSerializeError(e.to_string()))
483                }
484            }
485        }
486
487        fn deserialize_content(&self, content: &str) -> Result<serde_json::Value, MigrationError> {
488            match self.strategy.format {
489                FormatStrategy::Json => serde_json::from_str(content)
490                    .map_err(|e| MigrationError::DeserializationError(e.to_string())),
491                FormatStrategy::Toml => {
492                    let tv: toml::Value = toml::from_str(content)
493                        .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
494                    toml_to_json(tv)
495                }
496            }
497        }
498    }
499
500    // Async tests
501    #[cfg(all(test, feature = "async"))]
502    mod async_tests {
503        use super::*;
504        use crate::{FromDomain, IntoDomain, MigratesTo, Versioned};
505        use base64::engine::general_purpose::URL_SAFE_NO_PAD;
506        use base64::Engine;
507        use local_store::FilenameEncoding;
508        use serde::{Deserialize, Serialize};
509        use tempfile::TempDir;
510
511        // Test entity types (reused from sync tests)
512        #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
513        struct SessionV1_0_0 {
514            id: String,
515            user_id: String,
516        }
517
518        impl Versioned for SessionV1_0_0 {
519            const VERSION: &'static str = "1.0.0";
520        }
521
522        #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
523        struct SessionV1_1_0 {
524            id: String,
525            user_id: String,
526            created_at: Option<String>,
527        }
528
529        impl Versioned for SessionV1_1_0 {
530            const VERSION: &'static str = "1.1.0";
531        }
532
533        impl MigratesTo<SessionV1_1_0> for SessionV1_0_0 {
534            fn migrate(self) -> SessionV1_1_0 {
535                SessionV1_1_0 {
536                    id: self.id,
537                    user_id: self.user_id,
538                    created_at: None,
539                }
540            }
541        }
542
543        #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
544        struct SessionEntity {
545            id: String,
546            user_id: String,
547            created_at: Option<String>,
548        }
549
550        impl IntoDomain<SessionEntity> for SessionV1_1_0 {
551            fn into_domain(self) -> SessionEntity {
552                SessionEntity {
553                    id: self.id,
554                    user_id: self.user_id,
555                    created_at: self.created_at,
556                }
557            }
558        }
559
560        impl FromDomain<SessionEntity> for SessionV1_1_0 {
561            fn from_domain(domain: SessionEntity) -> Self {
562                SessionV1_1_0 {
563                    id: domain.id,
564                    user_id: domain.user_id,
565                    created_at: domain.created_at,
566                }
567            }
568        }
569
570        fn setup_session_migrator() -> Migrator {
571            let path = Migrator::define("session")
572                .from::<SessionV1_0_0>()
573                .step::<SessionV1_1_0>()
574                .into_with_save::<SessionEntity>();
575
576            let mut migrator = Migrator::new();
577            migrator.register(path).unwrap();
578            migrator
579        }
580
581        #[tokio::test]
582        async fn test_async_dir_storage_new_creates_directory() {
583            let temp_dir = TempDir::new().unwrap();
584            let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
585                temp_dir.path().to_path_buf(),
586            ));
587
588            let migrator = Migrator::new();
589            let strategy = DirStorageStrategy::default();
590
591            let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
592                .await
593                .unwrap();
594
595            // Verify directory was created
596            assert!(storage.base_path().exists());
597            assert!(storage.base_path().is_dir());
598            assert!(storage.base_path().ends_with("data/testapp/sessions"));
599        }
600
601        #[tokio::test]
602        async fn test_async_dir_storage_save_and_load_roundtrip() {
603            let temp_dir = TempDir::new().unwrap();
604            let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
605                temp_dir.path().to_path_buf(),
606            ));
607
608            let migrator = setup_session_migrator();
609            let strategy = DirStorageStrategy::default();
610            let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
611                .await
612                .unwrap();
613
614            // Test multiple sessions
615            let sessions = vec![
616                SessionEntity {
617                    id: "session-1".to_string(),
618                    user_id: "user-1".to_string(),
619                    created_at: Some("2024-01-01".to_string()),
620                },
621                SessionEntity {
622                    id: "session-2".to_string(),
623                    user_id: "user-2".to_string(),
624                    created_at: None,
625                },
626                SessionEntity {
627                    id: "session-3".to_string(),
628                    user_id: "user-3".to_string(),
629                    created_at: Some("2024-03-01".to_string()),
630                },
631            ];
632
633            // Save all sessions
634            for session in &sessions {
635                storage
636                    .save("session", &session.id, session.clone())
637                    .await
638                    .unwrap();
639            }
640
641            // Load and verify each session
642            for session in &sessions {
643                let loaded: SessionEntity = storage.load("session", &session.id).await.unwrap();
644                assert_eq!(loaded.id, session.id);
645                assert_eq!(loaded.user_id, session.user_id);
646                assert_eq!(loaded.created_at, session.created_at);
647            }
648        }
649
650        #[tokio::test]
651        async fn test_async_dir_storage_list_ids() {
652            let temp_dir = TempDir::new().unwrap();
653            let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
654                temp_dir.path().to_path_buf(),
655            ));
656
657            let migrator = setup_session_migrator();
658            let strategy = DirStorageStrategy::default();
659            let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
660                .await
661                .unwrap();
662
663            // Save multiple sessions
664            let ids = vec!["session-c", "session-a", "session-b"];
665            for id in &ids {
666                let session = SessionEntity {
667                    id: id.to_string(),
668                    user_id: "user".to_string(),
669                    created_at: None,
670                };
671                storage.save("session", id, session).await.unwrap();
672            }
673
674            // List IDs
675            let listed_ids = storage.list_ids().await.unwrap();
676            assert_eq!(listed_ids.len(), 3);
677            // Should be sorted
678            assert_eq!(listed_ids, vec!["session-a", "session-b", "session-c"]);
679        }
680
681        #[tokio::test]
682        async fn test_async_dir_storage_load_all() {
683            let temp_dir = TempDir::new().unwrap();
684            let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
685                temp_dir.path().to_path_buf(),
686            ));
687
688            let migrator = setup_session_migrator();
689            let strategy = DirStorageStrategy::default();
690            let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
691                .await
692                .unwrap();
693
694            // Save multiple sessions
695            let sessions = vec![
696                SessionEntity {
697                    id: "session-x".to_string(),
698                    user_id: "user-x".to_string(),
699                    created_at: Some("2024-01-01".to_string()),
700                },
701                SessionEntity {
702                    id: "session-y".to_string(),
703                    user_id: "user-y".to_string(),
704                    created_at: None,
705                },
706                SessionEntity {
707                    id: "session-z".to_string(),
708                    user_id: "user-z".to_string(),
709                    created_at: Some("2024-03-01".to_string()),
710                },
711            ];
712
713            for session in &sessions {
714                storage
715                    .save("session", &session.id, session.clone())
716                    .await
717                    .unwrap();
718            }
719
720            // Load all
721            let results: Vec<(String, SessionEntity)> = storage.load_all("session").await.unwrap();
722            assert_eq!(results.len(), 3);
723
724            // Verify all sessions are loaded
725            for (id, loaded) in &results {
726                let original = sessions.iter().find(|s| &s.id == id).unwrap();
727                assert_eq!(loaded.id, original.id);
728                assert_eq!(loaded.user_id, original.user_id);
729                assert_eq!(loaded.created_at, original.created_at);
730            }
731        }
732
733        #[tokio::test]
734        async fn test_async_dir_storage_delete() {
735            let temp_dir = TempDir::new().unwrap();
736            let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
737                temp_dir.path().to_path_buf(),
738            ));
739
740            let migrator = setup_session_migrator();
741            let strategy = DirStorageStrategy::default();
742            let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
743                .await
744                .unwrap();
745
746            // Save a session
747            let session = SessionEntity {
748                id: "session-delete".to_string(),
749                user_id: "user-delete".to_string(),
750                created_at: None,
751            };
752            storage
753                .save("session", "session-delete", session)
754                .await
755                .unwrap();
756
757            // Verify it exists
758            assert!(storage.exists("session-delete").await.unwrap());
759
760            // Delete it
761            storage.delete("session-delete").await.unwrap();
762
763            // Verify it doesn't exist
764            assert!(!storage.exists("session-delete").await.unwrap());
765        }
766
767        #[tokio::test]
768        async fn test_async_dir_storage_filename_encoding_url_roundtrip() {
769            let temp_dir = TempDir::new().unwrap();
770            let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
771                temp_dir.path().to_path_buf(),
772            ));
773
774            let migrator = setup_session_migrator();
775            let strategy =
776                DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
777            let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
778                .await
779                .unwrap();
780
781            // Use an ID with special characters that need URL encoding
782            let complex_id = "user@example.com/path?query=1";
783            let session = SessionEntity {
784                id: complex_id.to_string(),
785                user_id: "user-special".to_string(),
786                created_at: Some("2024-05-01".to_string()),
787            };
788
789            // Save the entity
790            storage
791                .save("session", complex_id, session.clone())
792                .await
793                .unwrap();
794
795            // Verify the file was created with encoded filename
796            let encoded_id = urlencoding::encode(complex_id);
797            let file_path = storage.base_path().join(format!("{}.json", encoded_id));
798            assert!(file_path.exists());
799
800            // Load it back
801            let loaded: SessionEntity = storage.load("session", complex_id).await.unwrap();
802            assert_eq!(loaded.id, session.id);
803            assert_eq!(loaded.user_id, session.user_id);
804            assert_eq!(loaded.created_at, session.created_at);
805
806            // Verify list_ids works correctly
807            let ids = storage.list_ids().await.unwrap();
808            assert_eq!(ids.len(), 1);
809            assert_eq!(ids[0], complex_id);
810        }
811
812        #[tokio::test]
813        async fn test_async_dir_storage_filename_encoding_base64_roundtrip() {
814            let temp_dir = TempDir::new().unwrap();
815            let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
816                temp_dir.path().to_path_buf(),
817            ));
818
819            let migrator = setup_session_migrator();
820            let strategy =
821                DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
822            let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
823                .await
824                .unwrap();
825
826            // Use a complex ID with various special characters
827            let complex_id = "user@example.com/path?query=1&special=!@#$%";
828            let session = SessionEntity {
829                id: complex_id.to_string(),
830                user_id: "user-base64".to_string(),
831                created_at: Some("2024-06-01".to_string()),
832            };
833
834            // Save the entity
835            storage
836                .save("session", complex_id, session.clone())
837                .await
838                .unwrap();
839
840            // Verify the file was created with Base64-encoded filename
841            let encoded_id = URL_SAFE_NO_PAD.encode(complex_id.as_bytes());
842            let file_path = storage.base_path().join(format!("{}.json", encoded_id));
843            assert!(file_path.exists());
844
845            // Load it back
846            let loaded: SessionEntity = storage.load("session", complex_id).await.unwrap();
847            assert_eq!(loaded.id, session.id);
848            assert_eq!(loaded.user_id, session.user_id);
849            assert_eq!(loaded.created_at, session.created_at);
850
851            // Verify list_ids works correctly
852            let ids = storage.list_ids().await.unwrap();
853            assert_eq!(ids.len(), 1);
854            assert_eq!(ids[0], complex_id);
855        }
856    }
857}
858
859#[cfg(test)]
860mod tests {
861    use super::*;
862    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
863    use base64::Engine;
864    use local_store::StoreError;
865    use std::fs;
866    use tempfile::TempDir;
867
868    #[test]
869    fn test_filename_encoding_default() {
870        assert_eq!(FilenameEncoding::default(), FilenameEncoding::Direct);
871    }
872
873    #[test]
874    fn test_dir_storage_strategy_default() {
875        let strategy = DirStorageStrategy::default();
876        assert_eq!(strategy.format, FormatStrategy::Json);
877        assert_eq!(strategy.extension, None);
878        assert_eq!(strategy.filename_encoding, FilenameEncoding::Direct);
879    }
880
881    #[test]
882    fn test_dir_storage_strategy_builder() {
883        let strategy = DirStorageStrategy::new()
884            .with_format(FormatStrategy::Toml)
885            .with_extension("data")
886            .with_filename_encoding(FilenameEncoding::Base64)
887            .with_retry_count(5)
888            .with_cleanup(false);
889
890        assert_eq!(strategy.format, FormatStrategy::Toml);
891        assert_eq!(strategy.extension, Some("data".to_string()));
892        assert_eq!(strategy.filename_encoding, FilenameEncoding::Base64);
893        assert_eq!(strategy.atomic_write.retry_count, 5);
894        assert!(!strategy.atomic_write.cleanup_tmp_files);
895    }
896
897    #[test]
898    fn test_dir_storage_strategy_get_extension() {
899        // Default from JSON format
900        let strategy1 = DirStorageStrategy::default();
901        assert_eq!(strategy1.get_extension(), "json");
902
903        // Default from TOML format
904        let strategy2 = DirStorageStrategy::default().with_format(FormatStrategy::Toml);
905        assert_eq!(strategy2.get_extension(), "toml");
906
907        // Custom extension
908        let strategy3 = DirStorageStrategy::default().with_extension("custom");
909        assert_eq!(strategy3.get_extension(), "custom");
910    }
911
912    #[test]
913    fn test_dir_storage_new_creates_directory() {
914        let temp_dir = TempDir::new().unwrap();
915        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
916            temp_dir.path().to_path_buf(),
917        ));
918
919        let migrator = Migrator::new();
920        let strategy = DirStorageStrategy::default();
921
922        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
923
924        // Verify directory was created
925        assert!(storage.base_path().exists());
926        assert!(storage.base_path().is_dir());
927        assert!(storage.base_path().ends_with("data/testapp/sessions"));
928    }
929
930    #[test]
931    fn test_dir_storage_new_idempotent() {
932        let temp_dir = TempDir::new().unwrap();
933        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
934            temp_dir.path().to_path_buf(),
935        ));
936
937        let migrator1 = Migrator::new();
938        let migrator2 = Migrator::new();
939        let strategy = DirStorageStrategy::default();
940
941        // Create storage twice
942        let storage1 =
943            DirStorage::new(paths.clone(), "sessions", migrator1, strategy.clone()).unwrap();
944        let storage2 = DirStorage::new(paths, "sessions", migrator2, strategy).unwrap();
945
946        // Both should succeed and point to the same directory
947        assert_eq!(storage1.base_path(), storage2.base_path());
948    }
949
950    // Test entity types for save tests
951    use crate::{FromDomain, IntoDomain, MigratesTo, Versioned};
952    use serde::{Deserialize, Serialize};
953
954    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
955    struct SessionV1_0_0 {
956        id: String,
957        user_id: String,
958    }
959
960    impl Versioned for SessionV1_0_0 {
961        const VERSION: &'static str = "1.0.0";
962    }
963
964    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
965    struct SessionV1_1_0 {
966        id: String,
967        user_id: String,
968        created_at: Option<String>,
969    }
970
971    impl Versioned for SessionV1_1_0 {
972        const VERSION: &'static str = "1.1.0";
973    }
974
975    impl MigratesTo<SessionV1_1_0> for SessionV1_0_0 {
976        fn migrate(self) -> SessionV1_1_0 {
977            SessionV1_1_0 {
978                id: self.id,
979                user_id: self.user_id,
980                created_at: None,
981            }
982        }
983    }
984
985    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
986    struct SessionEntity {
987        id: String,
988        user_id: String,
989        created_at: Option<String>,
990    }
991
992    impl IntoDomain<SessionEntity> for SessionV1_1_0 {
993        fn into_domain(self) -> SessionEntity {
994            SessionEntity {
995                id: self.id,
996                user_id: self.user_id,
997                created_at: self.created_at,
998            }
999        }
1000    }
1001
1002    impl FromDomain<SessionEntity> for SessionV1_1_0 {
1003        fn from_domain(domain: SessionEntity) -> Self {
1004            SessionV1_1_0 {
1005                id: domain.id,
1006                user_id: domain.user_id,
1007                created_at: domain.created_at,
1008            }
1009        }
1010    }
1011
1012    fn setup_session_migrator() -> Migrator {
1013        let path = Migrator::define("session")
1014            .from::<SessionV1_0_0>()
1015            .step::<SessionV1_1_0>()
1016            .into_with_save::<SessionEntity>();
1017
1018        let mut migrator = Migrator::new();
1019        migrator.register(path).unwrap();
1020        migrator
1021    }
1022
1023    #[test]
1024    fn test_dir_storage_save_json() {
1025        let temp_dir = TempDir::new().unwrap();
1026        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1027            temp_dir.path().to_path_buf(),
1028        ));
1029
1030        let migrator = setup_session_migrator();
1031        let strategy = DirStorageStrategy::default().with_format(FormatStrategy::Json);
1032        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1033
1034        // Create a session entity
1035        let session = SessionEntity {
1036            id: "session-123".to_string(),
1037            user_id: "user-456".to_string(),
1038            created_at: Some("2024-01-01T00:00:00Z".to_string()),
1039        };
1040
1041        // Save the entity
1042        storage.save("session", "session-123", session).unwrap();
1043
1044        // Verify file was created
1045        let file_path = storage.base_path().join("session-123.json");
1046        assert!(file_path.exists());
1047
1048        // Verify content is valid JSON with version
1049        let content = std::fs::read_to_string(&file_path).unwrap();
1050        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1051        assert_eq!(json["version"], "1.1.0");
1052        assert_eq!(json["id"], "session-123");
1053        assert_eq!(json["user_id"], "user-456");
1054    }
1055
1056    #[test]
1057    fn test_dir_storage_save_toml() {
1058        let temp_dir = TempDir::new().unwrap();
1059        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1060            temp_dir.path().to_path_buf(),
1061        ));
1062
1063        let migrator = setup_session_migrator();
1064        let strategy = DirStorageStrategy::default().with_format(FormatStrategy::Toml);
1065        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1066
1067        // Create a session entity with Some value (TOML doesn't support None/null)
1068        let session = SessionEntity {
1069            id: "session-789".to_string(),
1070            user_id: "user-101".to_string(),
1071            created_at: Some("2024-01-15T10:30:00Z".to_string()),
1072        };
1073
1074        // Save the entity
1075        storage.save("session", "session-789", session).unwrap();
1076
1077        // Verify file was created
1078        let file_path = storage.base_path().join("session-789.toml");
1079        assert!(file_path.exists());
1080
1081        // Verify content is valid TOML with version
1082        let content = std::fs::read_to_string(&file_path).unwrap();
1083        let toml: toml::Value = toml::from_str(&content).unwrap();
1084        assert_eq!(toml["version"].as_str().unwrap(), "1.1.0");
1085        assert_eq!(toml["id"].as_str().unwrap(), "session-789");
1086        assert_eq!(toml["created_at"].as_str().unwrap(), "2024-01-15T10:30:00Z");
1087    }
1088
1089    #[test]
1090    fn test_dir_storage_save_with_invalid_id() {
1091        let temp_dir = TempDir::new().unwrap();
1092        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1093            temp_dir.path().to_path_buf(),
1094        ));
1095
1096        let migrator = setup_session_migrator();
1097        let strategy = DirStorageStrategy::default();
1098        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1099
1100        let session = SessionEntity {
1101            id: "invalid/id".to_string(),
1102            user_id: "user-456".to_string(),
1103            created_at: None,
1104        };
1105
1106        // Should fail due to invalid characters in ID
1107        let result = storage.save("session", "invalid/id", session);
1108        assert!(result.is_err());
1109        assert!(matches!(
1110            result.unwrap_err(),
1111            crate::MigrationError::FilenameEncoding { .. }
1112        ));
1113    }
1114
1115    #[test]
1116    fn test_dir_storage_save_with_custom_extension() {
1117        let temp_dir = TempDir::new().unwrap();
1118        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1119            temp_dir.path().to_path_buf(),
1120        ));
1121
1122        let migrator = setup_session_migrator();
1123        let strategy = DirStorageStrategy::default()
1124            .with_format(FormatStrategy::Json)
1125            .with_extension("data");
1126        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1127
1128        let session = SessionEntity {
1129            id: "session-custom".to_string(),
1130            user_id: "user-999".to_string(),
1131            created_at: None,
1132        };
1133
1134        storage.save("session", "session-custom", session).unwrap();
1135
1136        // Verify custom extension is used
1137        let file_path = storage.base_path().join("session-custom.data");
1138        assert!(file_path.exists());
1139    }
1140
1141    #[test]
1142    fn test_dir_storage_save_overwrites_existing() {
1143        let temp_dir = TempDir::new().unwrap();
1144        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1145            temp_dir.path().to_path_buf(),
1146        ));
1147
1148        let migrator = setup_session_migrator();
1149        let strategy = DirStorageStrategy::default();
1150        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1151
1152        // Save initial version
1153        let session1 = SessionEntity {
1154            id: "session-overwrite".to_string(),
1155            user_id: "user-111".to_string(),
1156            created_at: Some("2024-01-01".to_string()),
1157        };
1158        storage
1159            .save("session", "session-overwrite", session1)
1160            .unwrap();
1161
1162        // Save updated version
1163        let session2 = SessionEntity {
1164            id: "session-overwrite".to_string(),
1165            user_id: "user-222".to_string(),
1166            created_at: Some("2024-01-02".to_string()),
1167        };
1168        storage
1169            .save("session", "session-overwrite", session2)
1170            .unwrap();
1171
1172        // Verify file was overwritten
1173        let file_path = storage.base_path().join("session-overwrite.json");
1174        let content = std::fs::read_to_string(&file_path).unwrap();
1175        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1176        assert_eq!(json["user_id"], "user-222");
1177        assert_eq!(json["created_at"], "2024-01-02");
1178    }
1179
1180    #[test]
1181    fn test_dir_storage_load_success() {
1182        let temp_dir = TempDir::new().unwrap();
1183        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1184            temp_dir.path().to_path_buf(),
1185        ));
1186
1187        let migrator = setup_session_migrator();
1188        let strategy = DirStorageStrategy::default();
1189        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1190
1191        // Save a session
1192        let session = SessionEntity {
1193            id: "session-load".to_string(),
1194            user_id: "user-999".to_string(),
1195            created_at: Some("2024-02-01".to_string()),
1196        };
1197        storage
1198            .save("session", "session-load", session.clone())
1199            .unwrap();
1200
1201        // Load it back
1202        let loaded: SessionEntity = storage.load("session", "session-load").unwrap();
1203        assert_eq!(loaded.id, session.id);
1204        assert_eq!(loaded.user_id, session.user_id);
1205        assert_eq!(loaded.created_at, session.created_at);
1206    }
1207
1208    #[test]
1209    fn test_dir_storage_load_not_found() {
1210        let temp_dir = TempDir::new().unwrap();
1211        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1212            temp_dir.path().to_path_buf(),
1213        ));
1214
1215        let migrator = setup_session_migrator();
1216        let strategy = DirStorageStrategy::default();
1217        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1218
1219        // Try to load non-existent session
1220        let result: Result<SessionEntity, _> = storage.load("session", "non-existent");
1221        assert!(result.is_err());
1222        assert!(matches!(
1223            result.unwrap_err(),
1224            MigrationError::Store(StoreError::IoError { .. })
1225        ));
1226    }
1227
1228    #[test]
1229    fn test_dir_storage_save_and_load_roundtrip() {
1230        let temp_dir = TempDir::new().unwrap();
1231        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1232            temp_dir.path().to_path_buf(),
1233        ));
1234
1235        let migrator = setup_session_migrator();
1236        let strategy = DirStorageStrategy::default();
1237        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1238
1239        // Test multiple sessions
1240        let sessions = vec![
1241            SessionEntity {
1242                id: "session-1".to_string(),
1243                user_id: "user-1".to_string(),
1244                created_at: Some("2024-01-01".to_string()),
1245            },
1246            SessionEntity {
1247                id: "session-2".to_string(),
1248                user_id: "user-2".to_string(),
1249                created_at: None,
1250            },
1251            SessionEntity {
1252                id: "session-3".to_string(),
1253                user_id: "user-3".to_string(),
1254                created_at: Some("2024-03-01".to_string()),
1255            },
1256        ];
1257
1258        // Save all sessions
1259        for session in &sessions {
1260            storage
1261                .save("session", &session.id, session.clone())
1262                .unwrap();
1263        }
1264
1265        // Load and verify each session
1266        for session in &sessions {
1267            let loaded: SessionEntity = storage.load("session", &session.id).unwrap();
1268            assert_eq!(loaded.id, session.id);
1269            assert_eq!(loaded.user_id, session.user_id);
1270            assert_eq!(loaded.created_at, session.created_at);
1271        }
1272    }
1273
1274    #[test]
1275    fn test_dir_storage_list_ids_empty() {
1276        let temp_dir = TempDir::new().unwrap();
1277        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1278            temp_dir.path().to_path_buf(),
1279        ));
1280
1281        let migrator = setup_session_migrator();
1282        let strategy = DirStorageStrategy::default();
1283        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1284
1285        // List IDs from empty directory
1286        let ids = storage.list_ids().unwrap();
1287        assert!(ids.is_empty());
1288    }
1289
1290    #[test]
1291    fn test_dir_storage_list_ids() {
1292        let temp_dir = TempDir::new().unwrap();
1293        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1294            temp_dir.path().to_path_buf(),
1295        ));
1296
1297        let migrator = setup_session_migrator();
1298        let strategy = DirStorageStrategy::default();
1299        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1300
1301        // Save multiple sessions
1302        let ids = vec!["session-c", "session-a", "session-b"];
1303        for id in &ids {
1304            let session = SessionEntity {
1305                id: id.to_string(),
1306                user_id: "user".to_string(),
1307                created_at: None,
1308            };
1309            storage.save("session", id, session).unwrap();
1310        }
1311
1312        // List IDs
1313        let listed_ids = storage.list_ids().unwrap();
1314        assert_eq!(listed_ids.len(), 3);
1315        // Should be sorted
1316        assert_eq!(listed_ids, vec!["session-a", "session-b", "session-c"]);
1317    }
1318
1319    #[test]
1320    fn test_dir_storage_load_all_empty() {
1321        let temp_dir = TempDir::new().unwrap();
1322        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1323            temp_dir.path().to_path_buf(),
1324        ));
1325
1326        let migrator = setup_session_migrator();
1327        let strategy = DirStorageStrategy::default();
1328        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1329
1330        // Load all from empty directory
1331        let results: Vec<(String, SessionEntity)> = storage.load_all("session").unwrap();
1332        assert!(results.is_empty());
1333    }
1334
1335    #[test]
1336    fn test_dir_storage_load_all() {
1337        let temp_dir = TempDir::new().unwrap();
1338        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1339            temp_dir.path().to_path_buf(),
1340        ));
1341
1342        let migrator = setup_session_migrator();
1343        let strategy = DirStorageStrategy::default();
1344        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1345
1346        // Save multiple sessions
1347        let sessions = vec![
1348            SessionEntity {
1349                id: "session-x".to_string(),
1350                user_id: "user-x".to_string(),
1351                created_at: Some("2024-01-01".to_string()),
1352            },
1353            SessionEntity {
1354                id: "session-y".to_string(),
1355                user_id: "user-y".to_string(),
1356                created_at: None,
1357            },
1358            SessionEntity {
1359                id: "session-z".to_string(),
1360                user_id: "user-z".to_string(),
1361                created_at: Some("2024-03-01".to_string()),
1362            },
1363        ];
1364
1365        for session in &sessions {
1366            storage
1367                .save("session", &session.id, session.clone())
1368                .unwrap();
1369        }
1370
1371        // Load all
1372        let results: Vec<(String, SessionEntity)> = storage.load_all("session").unwrap();
1373        assert_eq!(results.len(), 3);
1374
1375        // Verify all sessions are loaded
1376        for (id, loaded) in &results {
1377            let original = sessions.iter().find(|s| &s.id == id).unwrap();
1378            assert_eq!(loaded.id, original.id);
1379            assert_eq!(loaded.user_id, original.user_id);
1380            assert_eq!(loaded.created_at, original.created_at);
1381        }
1382    }
1383
1384    #[test]
1385    fn test_dir_storage_exists() {
1386        let temp_dir = TempDir::new().unwrap();
1387        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1388            temp_dir.path().to_path_buf(),
1389        ));
1390
1391        let migrator = setup_session_migrator();
1392        let strategy = DirStorageStrategy::default();
1393        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1394
1395        // Non-existent file
1396        assert!(!storage.exists("session-exists").unwrap());
1397
1398        // Save a session
1399        let session = SessionEntity {
1400            id: "session-exists".to_string(),
1401            user_id: "user-exists".to_string(),
1402            created_at: None,
1403        };
1404        storage.save("session", "session-exists", session).unwrap();
1405
1406        // Should exist now
1407        assert!(storage.exists("session-exists").unwrap());
1408    }
1409
1410    #[test]
1411    fn test_dir_storage_delete() {
1412        let temp_dir = TempDir::new().unwrap();
1413        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1414            temp_dir.path().to_path_buf(),
1415        ));
1416
1417        let migrator = setup_session_migrator();
1418        let strategy = DirStorageStrategy::default();
1419        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1420
1421        // Save a session
1422        let session = SessionEntity {
1423            id: "session-delete".to_string(),
1424            user_id: "user-delete".to_string(),
1425            created_at: None,
1426        };
1427        storage.save("session", "session-delete", session).unwrap();
1428
1429        // Verify it exists
1430        assert!(storage.exists("session-delete").unwrap());
1431
1432        // Delete it
1433        storage.delete("session-delete").unwrap();
1434
1435        // Verify it doesn't exist
1436        assert!(!storage.exists("session-delete").unwrap());
1437    }
1438
1439    #[test]
1440    fn test_dir_storage_delete_idempotent() {
1441        let temp_dir = TempDir::new().unwrap();
1442        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1443            temp_dir.path().to_path_buf(),
1444        ));
1445
1446        let migrator = setup_session_migrator();
1447        let strategy = DirStorageStrategy::default();
1448        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1449
1450        // Delete non-existent file (should not error)
1451        storage.delete("non-existent").unwrap();
1452
1453        // Delete again (should still not error)
1454        storage.delete("non-existent").unwrap();
1455    }
1456
1457    #[test]
1458    fn test_dir_storage_load_toml() {
1459        let temp_dir = TempDir::new().unwrap();
1460        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1461            temp_dir.path().to_path_buf(),
1462        ));
1463
1464        let migrator = setup_session_migrator();
1465        let strategy = DirStorageStrategy::default().with_format(FormatStrategy::Toml);
1466        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1467
1468        // Save a session
1469        let session = SessionEntity {
1470            id: "session-toml".to_string(),
1471            user_id: "user-toml".to_string(),
1472            created_at: Some("2024-04-01".to_string()),
1473        };
1474        storage
1475            .save("session", "session-toml", session.clone())
1476            .unwrap();
1477
1478        // Load it back
1479        let loaded: SessionEntity = storage.load("session", "session-toml").unwrap();
1480        assert_eq!(loaded.id, session.id);
1481        assert_eq!(loaded.user_id, session.user_id);
1482        assert_eq!(loaded.created_at, session.created_at);
1483    }
1484
1485    #[test]
1486    fn test_dir_storage_list_ids_with_custom_extension() {
1487        let temp_dir = TempDir::new().unwrap();
1488        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1489            temp_dir.path().to_path_buf(),
1490        ));
1491
1492        let migrator = setup_session_migrator();
1493        let strategy = DirStorageStrategy::default().with_extension("data");
1494        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1495
1496        // Save sessions with custom extension
1497        let session = SessionEntity {
1498            id: "session-ext".to_string(),
1499            user_id: "user-ext".to_string(),
1500            created_at: None,
1501        };
1502        storage.save("session", "session-ext", session).unwrap();
1503
1504        // List IDs should find the file
1505        let ids = storage.list_ids().unwrap();
1506        assert_eq!(ids.len(), 1);
1507        assert_eq!(ids[0], "session-ext");
1508    }
1509
1510    #[test]
1511    fn test_dir_storage_load_all_atomic_failure() {
1512        let temp_dir = TempDir::new().unwrap();
1513        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1514            temp_dir.path().to_path_buf(),
1515        ));
1516
1517        let migrator = setup_session_migrator();
1518        let strategy = DirStorageStrategy::default();
1519        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1520
1521        // Save valid sessions
1522        let session1 = SessionEntity {
1523            id: "session-1".to_string(),
1524            user_id: "user-1".to_string(),
1525            created_at: None,
1526        };
1527        storage.save("session", "session-1", session1).unwrap();
1528
1529        // Manually create a corrupted file
1530        let corrupted_path = storage.base_path().join("session-corrupted.json");
1531        std::fs::write(&corrupted_path, "invalid json {{{").unwrap();
1532
1533        // load_all should fail
1534        let result: Result<Vec<(String, SessionEntity)>, _> = storage.load_all("session");
1535        assert!(result.is_err());
1536    }
1537
1538    #[test]
1539    fn test_dir_storage_filename_encoding_url_roundtrip() {
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 = setup_session_migrator();
1546        let strategy =
1547            DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
1548        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1549
1550        // Use an ID with special characters that need URL encoding
1551        let complex_id = "user@example.com/path?query=1";
1552        let session = SessionEntity {
1553            id: complex_id.to_string(),
1554            user_id: "user-special".to_string(),
1555            created_at: Some("2024-05-01".to_string()),
1556        };
1557
1558        // Save the entity
1559        storage
1560            .save("session", complex_id, session.clone())
1561            .unwrap();
1562
1563        // Verify the file was created with encoded filename
1564        let encoded_id = urlencoding::encode(complex_id);
1565        let file_path = storage.base_path().join(format!("{}.json", encoded_id));
1566        assert!(file_path.exists());
1567
1568        // Load it back
1569        let loaded: SessionEntity = storage.load("session", complex_id).unwrap();
1570        assert_eq!(loaded.id, session.id);
1571        assert_eq!(loaded.user_id, session.user_id);
1572        assert_eq!(loaded.created_at, session.created_at);
1573
1574        // Verify list_ids works correctly
1575        let ids = storage.list_ids().unwrap();
1576        assert_eq!(ids.len(), 1);
1577        assert_eq!(ids[0], complex_id);
1578    }
1579
1580    #[test]
1581    fn test_dir_storage_filename_encoding_base64_roundtrip() {
1582        let temp_dir = TempDir::new().unwrap();
1583        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1584            temp_dir.path().to_path_buf(),
1585        ));
1586
1587        let migrator = setup_session_migrator();
1588        let strategy =
1589            DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
1590        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1591
1592        // Use a complex ID with various special characters
1593        let complex_id = "user@example.com/path?query=1&special=!@#$%";
1594        let session = SessionEntity {
1595            id: complex_id.to_string(),
1596            user_id: "user-base64".to_string(),
1597            created_at: Some("2024-06-01".to_string()),
1598        };
1599
1600        // Save the entity
1601        storage
1602            .save("session", complex_id, session.clone())
1603            .unwrap();
1604
1605        // Verify the file was created with Base64-encoded filename
1606        let encoded_id = URL_SAFE_NO_PAD.encode(complex_id.as_bytes());
1607        let file_path = storage.base_path().join(format!("{}.json", encoded_id));
1608        assert!(file_path.exists());
1609
1610        // Load it back
1611        let loaded: SessionEntity = storage.load("session", complex_id).unwrap();
1612        assert_eq!(loaded.id, session.id);
1613        assert_eq!(loaded.user_id, session.user_id);
1614        assert_eq!(loaded.created_at, session.created_at);
1615
1616        // Verify list_ids works correctly
1617        let ids = storage.list_ids().unwrap();
1618        assert_eq!(ids.len(), 1);
1619        assert_eq!(ids[0], complex_id);
1620    }
1621
1622    /// Test that list_ids returns a FilenameEncoding error when a file with an
1623    /// undecoded URL-encoded name (invalid UTF-8 percent sequence) is present in the
1624    /// storage directory.
1625    #[test]
1626    fn test_list_ids_url_decode_error() {
1627        let temp_dir = TempDir::new().unwrap();
1628        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1629            temp_dir.path().to_path_buf(),
1630        ));
1631
1632        let migrator = setup_session_migrator();
1633        let strategy =
1634            DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
1635        let storage = DirStorage::new(paths, "sessions_url_err", migrator, strategy).unwrap();
1636
1637        // Manually create a file whose stem is an invalid UTF-8 percent sequence.
1638        // `%C0%C1` is an overlong encoding that the urlencoding crate rejects.
1639        let bad_stem = "%C0%C1";
1640        let bad_file = storage.base_path().join(format!("{}.json", bad_stem));
1641        std::fs::write(&bad_file, "{}").unwrap();
1642
1643        let result = storage.list_ids();
1644        assert!(
1645            result.is_err(),
1646            "list_ids should propagate the FilenameEncoding decode error"
1647        );
1648        assert!(matches!(
1649            result.unwrap_err(),
1650            MigrationError::FilenameEncoding { .. }
1651        ));
1652    }
1653
1654    /// Test that list_ids returns a FilenameEncoding error when a file with an
1655    /// invalid Base64-encoded name is present in the storage directory.
1656    #[test]
1657    fn test_list_ids_base64_decode_error() {
1658        let temp_dir = TempDir::new().unwrap();
1659        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1660            temp_dir.path().to_path_buf(),
1661        ));
1662
1663        let migrator = setup_session_migrator();
1664        let strategy =
1665            DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
1666        let storage = DirStorage::new(paths, "sessions_b64_err", migrator, strategy).unwrap();
1667
1668        // Manually create a file whose stem is not valid Base64.
1669        let bad_stem = "!!!invalid@@@";
1670        let bad_file = storage.base_path().join(format!("{}.json", bad_stem));
1671        std::fs::write(&bad_file, "{}").unwrap();
1672
1673        let result = storage.list_ids();
1674        assert!(
1675            result.is_err(),
1676            "list_ids should propagate the FilenameEncoding decode error"
1677        );
1678        assert!(matches!(
1679            result.unwrap_err(),
1680            MigrationError::FilenameEncoding { .. }
1681        ));
1682    }
1683
1684    #[test]
1685    fn test_dir_storage_base_path() {
1686        let temp_dir = TempDir::new().unwrap();
1687        let domain_name = "test_sessions";
1688        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1689            temp_dir.path().to_path_buf(),
1690        ));
1691
1692        let migrator = Migrator::new();
1693        let strategy = DirStorageStrategy::default();
1694
1695        let storage = DirStorage::new(paths, domain_name, migrator, strategy).unwrap();
1696
1697        // Verify base_path() returns the expected path
1698        let returned_path = storage.base_path();
1699        assert!(returned_path.ends_with(domain_name));
1700        assert!(returned_path.exists());
1701    }
1702
1703    #[test]
1704    #[cfg(unix)]
1705    fn test_dir_storage_create_dir_permission_denied() {
1706        use std::os::unix::fs::PermissionsExt;
1707
1708        let temp_dir = TempDir::new().unwrap();
1709
1710        // Make the temp dir read-only
1711        let mut perms = fs::metadata(temp_dir.path()).unwrap().permissions();
1712        perms.set_mode(0o444);
1713        fs::set_permissions(temp_dir.path(), perms).unwrap();
1714
1715        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1716            temp_dir.path().to_path_buf(),
1717        ));
1718
1719        let migrator = Migrator::new();
1720        let strategy = DirStorageStrategy::default();
1721
1722        // Should fail because we can't create directory in read-only parent
1723        let result = DirStorage::new(paths, "sessions", migrator, strategy);
1724
1725        // Restore permissions before assertion (so TempDir cleanup works)
1726        let mut perms = fs::metadata(temp_dir.path()).unwrap().permissions();
1727        perms.set_mode(0o755);
1728        fs::set_permissions(temp_dir.path(), perms).unwrap();
1729
1730        assert!(result.is_err());
1731        assert!(matches!(
1732            result,
1733            Err(MigrationError::Store(StoreError::IoError { .. }))
1734        ));
1735    }
1736
1737    #[test]
1738    #[cfg(unix)]
1739    fn test_dir_storage_save_permission_denied() {
1740        use std::os::unix::fs::PermissionsExt;
1741
1742        let temp_dir = TempDir::new().unwrap();
1743        let domain_name = "sessions_readonly";
1744        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1745            temp_dir.path().to_path_buf(),
1746        ));
1747
1748        let migrator = setup_session_migrator();
1749        let strategy = DirStorageStrategy::default();
1750
1751        // Create storage first (creates directory)
1752        let storage = DirStorage::new(paths, domain_name, migrator, strategy).unwrap();
1753
1754        // Make the storage directory read-only
1755        let mut perms = fs::metadata(storage.base_path()).unwrap().permissions();
1756        perms.set_mode(0o444);
1757        fs::set_permissions(storage.base_path(), perms).unwrap();
1758
1759        let session = SessionEntity {
1760            id: "test".to_string(),
1761            user_id: "user".to_string(),
1762            created_at: None,
1763        };
1764
1765        // Should fail because directory is read-only
1766        let result = storage.save("session", "test-session", session);
1767
1768        // Restore permissions before assertion
1769        let mut perms = fs::metadata(storage.base_path()).unwrap().permissions();
1770        perms.set_mode(0o755);
1771        fs::set_permissions(storage.base_path(), perms).unwrap();
1772
1773        assert!(result.is_err());
1774        assert!(matches!(
1775            result,
1776            Err(MigrationError::Store(StoreError::IoError { .. }))
1777        ));
1778    }
1779}