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 serde::{Deserialize, Serialize};
508        use tempfile::TempDir;
509
510        // Test entity types (reused from sync tests)
511        #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
512        struct SessionV1_0_0 {
513            id: String,
514            user_id: String,
515        }
516
517        impl Versioned for SessionV1_0_0 {
518            const VERSION: &'static str = "1.0.0";
519        }
520
521        #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
522        struct SessionV1_1_0 {
523            id: String,
524            user_id: String,
525            created_at: Option<String>,
526        }
527
528        impl Versioned for SessionV1_1_0 {
529            const VERSION: &'static str = "1.1.0";
530        }
531
532        impl MigratesTo<SessionV1_1_0> for SessionV1_0_0 {
533            fn migrate(self) -> SessionV1_1_0 {
534                SessionV1_1_0 {
535                    id: self.id,
536                    user_id: self.user_id,
537                    created_at: None,
538                }
539            }
540        }
541
542        #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
543        struct SessionEntity {
544            id: String,
545            user_id: String,
546            created_at: Option<String>,
547        }
548
549        impl IntoDomain<SessionEntity> for SessionV1_1_0 {
550            fn into_domain(self) -> SessionEntity {
551                SessionEntity {
552                    id: self.id,
553                    user_id: self.user_id,
554                    created_at: self.created_at,
555                }
556            }
557        }
558
559        impl FromDomain<SessionEntity> for SessionV1_1_0 {
560            fn from_domain(domain: SessionEntity) -> Self {
561                SessionV1_1_0 {
562                    id: domain.id,
563                    user_id: domain.user_id,
564                    created_at: domain.created_at,
565                }
566            }
567        }
568
569        fn setup_session_migrator() -> Migrator {
570            let path = Migrator::define("session")
571                .from::<SessionV1_0_0>()
572                .step::<SessionV1_1_0>()
573                .into_with_save::<SessionEntity>();
574
575            let mut migrator = Migrator::new();
576            migrator.register(path).unwrap();
577            migrator
578        }
579
580        #[tokio::test]
581        async fn test_async_dir_storage_new_creates_directory() {
582            let temp_dir = TempDir::new().unwrap();
583            let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
584                temp_dir.path().to_path_buf(),
585            ));
586
587            let migrator = Migrator::new();
588            let strategy = DirStorageStrategy::default();
589
590            let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
591                .await
592                .unwrap();
593
594            // Verify directory was created
595            assert!(storage.base_path().exists());
596            assert!(storage.base_path().is_dir());
597            assert!(storage.base_path().ends_with("data/testapp/sessions"));
598        }
599
600        #[tokio::test]
601        async fn test_async_dir_storage_save_and_load_roundtrip() {
602            let temp_dir = TempDir::new().unwrap();
603            let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
604                temp_dir.path().to_path_buf(),
605            ));
606
607            let migrator = setup_session_migrator();
608            let strategy = DirStorageStrategy::default();
609            let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
610                .await
611                .unwrap();
612
613            // Test multiple sessions
614            let sessions = vec![
615                SessionEntity {
616                    id: "session-1".to_string(),
617                    user_id: "user-1".to_string(),
618                    created_at: Some("2024-01-01".to_string()),
619                },
620                SessionEntity {
621                    id: "session-2".to_string(),
622                    user_id: "user-2".to_string(),
623                    created_at: None,
624                },
625                SessionEntity {
626                    id: "session-3".to_string(),
627                    user_id: "user-3".to_string(),
628                    created_at: Some("2024-03-01".to_string()),
629                },
630            ];
631
632            // Save all sessions
633            for session in &sessions {
634                storage
635                    .save("session", &session.id, session.clone())
636                    .await
637                    .unwrap();
638            }
639
640            // Load and verify each session
641            for session in &sessions {
642                let loaded: SessionEntity = storage.load("session", &session.id).await.unwrap();
643                assert_eq!(loaded.id, session.id);
644                assert_eq!(loaded.user_id, session.user_id);
645                assert_eq!(loaded.created_at, session.created_at);
646            }
647        }
648
649        #[tokio::test]
650        async fn test_async_dir_storage_list_ids() {
651            let temp_dir = TempDir::new().unwrap();
652            let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
653                temp_dir.path().to_path_buf(),
654            ));
655
656            let migrator = setup_session_migrator();
657            let strategy = DirStorageStrategy::default();
658            let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
659                .await
660                .unwrap();
661
662            // Save multiple sessions
663            let ids = vec!["session-c", "session-a", "session-b"];
664            for id in &ids {
665                let session = SessionEntity {
666                    id: id.to_string(),
667                    user_id: "user".to_string(),
668                    created_at: None,
669                };
670                storage.save("session", id, session).await.unwrap();
671            }
672
673            // List IDs
674            let listed_ids = storage.list_ids().await.unwrap();
675            assert_eq!(listed_ids.len(), 3);
676            // Should be sorted
677            assert_eq!(listed_ids, vec!["session-a", "session-b", "session-c"]);
678        }
679
680        #[tokio::test]
681        async fn test_async_dir_storage_load_all() {
682            let temp_dir = TempDir::new().unwrap();
683            let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
684                temp_dir.path().to_path_buf(),
685            ));
686
687            let migrator = setup_session_migrator();
688            let strategy = DirStorageStrategy::default();
689            let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
690                .await
691                .unwrap();
692
693            // Save multiple sessions
694            let sessions = vec![
695                SessionEntity {
696                    id: "session-x".to_string(),
697                    user_id: "user-x".to_string(),
698                    created_at: Some("2024-01-01".to_string()),
699                },
700                SessionEntity {
701                    id: "session-y".to_string(),
702                    user_id: "user-y".to_string(),
703                    created_at: None,
704                },
705                SessionEntity {
706                    id: "session-z".to_string(),
707                    user_id: "user-z".to_string(),
708                    created_at: Some("2024-03-01".to_string()),
709                },
710            ];
711
712            for session in &sessions {
713                storage
714                    .save("session", &session.id, session.clone())
715                    .await
716                    .unwrap();
717            }
718
719            // Load all
720            let results: Vec<(String, SessionEntity)> = storage.load_all("session").await.unwrap();
721            assert_eq!(results.len(), 3);
722
723            // Verify all sessions are loaded
724            for (id, loaded) in &results {
725                let original = sessions.iter().find(|s| &s.id == id).unwrap();
726                assert_eq!(loaded.id, original.id);
727                assert_eq!(loaded.user_id, original.user_id);
728                assert_eq!(loaded.created_at, original.created_at);
729            }
730        }
731
732        #[tokio::test]
733        async fn test_async_dir_storage_delete() {
734            let temp_dir = TempDir::new().unwrap();
735            let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
736                temp_dir.path().to_path_buf(),
737            ));
738
739            let migrator = setup_session_migrator();
740            let strategy = DirStorageStrategy::default();
741            let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
742                .await
743                .unwrap();
744
745            // Save a session
746            let session = SessionEntity {
747                id: "session-delete".to_string(),
748                user_id: "user-delete".to_string(),
749                created_at: None,
750            };
751            storage
752                .save("session", "session-delete", session)
753                .await
754                .unwrap();
755
756            // Verify it exists
757            assert!(storage.exists("session-delete").await.unwrap());
758
759            // Delete it
760            storage.delete("session-delete").await.unwrap();
761
762            // Verify it doesn't exist
763            assert!(!storage.exists("session-delete").await.unwrap());
764        }
765
766        #[tokio::test]
767        async fn test_async_dir_storage_filename_encoding_url_roundtrip() {
768            let temp_dir = TempDir::new().unwrap();
769            let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
770                temp_dir.path().to_path_buf(),
771            ));
772
773            let migrator = setup_session_migrator();
774            let strategy =
775                DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
776            let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
777                .await
778                .unwrap();
779
780            // Use an ID with special characters that need URL encoding
781            let complex_id = "user@example.com/path?query=1";
782            let session = SessionEntity {
783                id: complex_id.to_string(),
784                user_id: "user-special".to_string(),
785                created_at: Some("2024-05-01".to_string()),
786            };
787
788            // Save the entity
789            storage
790                .save("session", complex_id, session.clone())
791                .await
792                .unwrap();
793
794            // Verify the file was created with encoded filename
795            let encoded_id = urlencoding::encode(complex_id);
796            let file_path = storage.base_path().join(format!("{}.json", encoded_id));
797            assert!(file_path.exists());
798
799            // Load it back
800            let loaded: SessionEntity = storage.load("session", complex_id).await.unwrap();
801            assert_eq!(loaded.id, session.id);
802            assert_eq!(loaded.user_id, session.user_id);
803            assert_eq!(loaded.created_at, session.created_at);
804
805            // Verify list_ids works correctly
806            let ids = storage.list_ids().await.unwrap();
807            assert_eq!(ids.len(), 1);
808            assert_eq!(ids[0], complex_id);
809        }
810
811        #[tokio::test]
812        async fn test_async_dir_storage_filename_encoding_base64_roundtrip() {
813            let temp_dir = TempDir::new().unwrap();
814            let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
815                temp_dir.path().to_path_buf(),
816            ));
817
818            let migrator = setup_session_migrator();
819            let strategy =
820                DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
821            let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
822                .await
823                .unwrap();
824
825            // Use a complex ID with various special characters
826            let complex_id = "user@example.com/path?query=1&special=!@#$%";
827            let session = SessionEntity {
828                id: complex_id.to_string(),
829                user_id: "user-base64".to_string(),
830                created_at: Some("2024-06-01".to_string()),
831            };
832
833            // Save the entity
834            storage
835                .save("session", complex_id, session.clone())
836                .await
837                .unwrap();
838
839            // Verify the file was created with Base64-encoded filename
840            let encoded_id = URL_SAFE_NO_PAD.encode(complex_id.as_bytes());
841            let file_path = storage.base_path().join(format!("{}.json", encoded_id));
842            assert!(file_path.exists());
843
844            // Load it back
845            let loaded: SessionEntity = storage.load("session", complex_id).await.unwrap();
846            assert_eq!(loaded.id, session.id);
847            assert_eq!(loaded.user_id, session.user_id);
848            assert_eq!(loaded.created_at, session.created_at);
849
850            // Verify list_ids works correctly
851            let ids = storage.list_ids().await.unwrap();
852            assert_eq!(ids.len(), 1);
853            assert_eq!(ids[0], complex_id);
854        }
855    }
856}
857
858#[cfg(test)]
859mod tests {
860    use super::*;
861    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
862    use base64::Engine;
863    use local_store::StoreError;
864    use std::fs;
865    use tempfile::TempDir;
866
867    #[test]
868    fn test_filename_encoding_default() {
869        assert_eq!(FilenameEncoding::default(), FilenameEncoding::Direct);
870    }
871
872    #[test]
873    fn test_dir_storage_strategy_default() {
874        let strategy = DirStorageStrategy::default();
875        assert_eq!(strategy.format, FormatStrategy::Json);
876        assert_eq!(strategy.extension, None);
877        assert_eq!(strategy.filename_encoding, FilenameEncoding::Direct);
878    }
879
880    #[test]
881    fn test_dir_storage_strategy_builder() {
882        let strategy = DirStorageStrategy::new()
883            .with_format(FormatStrategy::Toml)
884            .with_extension("data")
885            .with_filename_encoding(FilenameEncoding::Base64)
886            .with_retry_count(5)
887            .with_cleanup(false);
888
889        assert_eq!(strategy.format, FormatStrategy::Toml);
890        assert_eq!(strategy.extension, Some("data".to_string()));
891        assert_eq!(strategy.filename_encoding, FilenameEncoding::Base64);
892        assert_eq!(strategy.atomic_write.retry_count, 5);
893        assert!(!strategy.atomic_write.cleanup_tmp_files);
894    }
895
896    #[test]
897    fn test_dir_storage_strategy_get_extension() {
898        // Default from JSON format
899        let strategy1 = DirStorageStrategy::default();
900        assert_eq!(strategy1.get_extension(), "json");
901
902        // Default from TOML format
903        let strategy2 = DirStorageStrategy::default().with_format(FormatStrategy::Toml);
904        assert_eq!(strategy2.get_extension(), "toml");
905
906        // Custom extension
907        let strategy3 = DirStorageStrategy::default().with_extension("custom");
908        assert_eq!(strategy3.get_extension(), "custom");
909    }
910
911    #[test]
912    fn test_dir_storage_new_creates_directory() {
913        let temp_dir = TempDir::new().unwrap();
914        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
915            temp_dir.path().to_path_buf(),
916        ));
917
918        let migrator = Migrator::new();
919        let strategy = DirStorageStrategy::default();
920
921        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
922
923        // Verify directory was created
924        assert!(storage.base_path().exists());
925        assert!(storage.base_path().is_dir());
926        assert!(storage.base_path().ends_with("data/testapp/sessions"));
927    }
928
929    #[test]
930    fn test_dir_storage_new_idempotent() {
931        let temp_dir = TempDir::new().unwrap();
932        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
933            temp_dir.path().to_path_buf(),
934        ));
935
936        let migrator1 = Migrator::new();
937        let migrator2 = Migrator::new();
938        let strategy = DirStorageStrategy::default();
939
940        // Create storage twice
941        let storage1 =
942            DirStorage::new(paths.clone(), "sessions", migrator1, strategy.clone()).unwrap();
943        let storage2 = DirStorage::new(paths, "sessions", migrator2, strategy).unwrap();
944
945        // Both should succeed and point to the same directory
946        assert_eq!(storage1.base_path(), storage2.base_path());
947    }
948
949    // Test entity types for save tests
950    use crate::{FromDomain, IntoDomain, MigratesTo, Versioned};
951    use serde::{Deserialize, Serialize};
952
953    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
954    struct SessionV1_0_0 {
955        id: String,
956        user_id: String,
957    }
958
959    impl Versioned for SessionV1_0_0 {
960        const VERSION: &'static str = "1.0.0";
961    }
962
963    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
964    struct SessionV1_1_0 {
965        id: String,
966        user_id: String,
967        created_at: Option<String>,
968    }
969
970    impl Versioned for SessionV1_1_0 {
971        const VERSION: &'static str = "1.1.0";
972    }
973
974    impl MigratesTo<SessionV1_1_0> for SessionV1_0_0 {
975        fn migrate(self) -> SessionV1_1_0 {
976            SessionV1_1_0 {
977                id: self.id,
978                user_id: self.user_id,
979                created_at: None,
980            }
981        }
982    }
983
984    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
985    struct SessionEntity {
986        id: String,
987        user_id: String,
988        created_at: Option<String>,
989    }
990
991    impl IntoDomain<SessionEntity> for SessionV1_1_0 {
992        fn into_domain(self) -> SessionEntity {
993            SessionEntity {
994                id: self.id,
995                user_id: self.user_id,
996                created_at: self.created_at,
997            }
998        }
999    }
1000
1001    impl FromDomain<SessionEntity> for SessionV1_1_0 {
1002        fn from_domain(domain: SessionEntity) -> Self {
1003            SessionV1_1_0 {
1004                id: domain.id,
1005                user_id: domain.user_id,
1006                created_at: domain.created_at,
1007            }
1008        }
1009    }
1010
1011    fn setup_session_migrator() -> Migrator {
1012        let path = Migrator::define("session")
1013            .from::<SessionV1_0_0>()
1014            .step::<SessionV1_1_0>()
1015            .into_with_save::<SessionEntity>();
1016
1017        let mut migrator = Migrator::new();
1018        migrator.register(path).unwrap();
1019        migrator
1020    }
1021
1022    #[test]
1023    fn test_dir_storage_save_json() {
1024        let temp_dir = TempDir::new().unwrap();
1025        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1026            temp_dir.path().to_path_buf(),
1027        ));
1028
1029        let migrator = setup_session_migrator();
1030        let strategy = DirStorageStrategy::default().with_format(FormatStrategy::Json);
1031        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1032
1033        // Create a session entity
1034        let session = SessionEntity {
1035            id: "session-123".to_string(),
1036            user_id: "user-456".to_string(),
1037            created_at: Some("2024-01-01T00:00:00Z".to_string()),
1038        };
1039
1040        // Save the entity
1041        storage.save("session", "session-123", session).unwrap();
1042
1043        // Verify file was created
1044        let file_path = storage.base_path().join("session-123.json");
1045        assert!(file_path.exists());
1046
1047        // Verify content is valid JSON with version
1048        let content = std::fs::read_to_string(&file_path).unwrap();
1049        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1050        assert_eq!(json["version"], "1.1.0");
1051        assert_eq!(json["id"], "session-123");
1052        assert_eq!(json["user_id"], "user-456");
1053    }
1054
1055    #[test]
1056    fn test_dir_storage_save_toml() {
1057        let temp_dir = TempDir::new().unwrap();
1058        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1059            temp_dir.path().to_path_buf(),
1060        ));
1061
1062        let migrator = setup_session_migrator();
1063        let strategy = DirStorageStrategy::default().with_format(FormatStrategy::Toml);
1064        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1065
1066        // Create a session entity with Some value (TOML doesn't support None/null)
1067        let session = SessionEntity {
1068            id: "session-789".to_string(),
1069            user_id: "user-101".to_string(),
1070            created_at: Some("2024-01-15T10:30:00Z".to_string()),
1071        };
1072
1073        // Save the entity
1074        storage.save("session", "session-789", session).unwrap();
1075
1076        // Verify file was created
1077        let file_path = storage.base_path().join("session-789.toml");
1078        assert!(file_path.exists());
1079
1080        // Verify content is valid TOML with version
1081        let content = std::fs::read_to_string(&file_path).unwrap();
1082        let toml: toml::Value = toml::from_str(&content).unwrap();
1083        assert_eq!(toml["version"].as_str().unwrap(), "1.1.0");
1084        assert_eq!(toml["id"].as_str().unwrap(), "session-789");
1085        assert_eq!(toml["created_at"].as_str().unwrap(), "2024-01-15T10:30:00Z");
1086    }
1087
1088    #[test]
1089    fn test_dir_storage_save_with_invalid_id() {
1090        let temp_dir = TempDir::new().unwrap();
1091        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1092            temp_dir.path().to_path_buf(),
1093        ));
1094
1095        let migrator = setup_session_migrator();
1096        let strategy = DirStorageStrategy::default();
1097        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1098
1099        let session = SessionEntity {
1100            id: "invalid/id".to_string(),
1101            user_id: "user-456".to_string(),
1102            created_at: None,
1103        };
1104
1105        // Should fail due to invalid characters in ID
1106        let result = storage.save("session", "invalid/id", session);
1107        assert!(result.is_err());
1108        assert!(matches!(
1109            result.unwrap_err(),
1110            crate::MigrationError::FilenameEncoding { .. }
1111        ));
1112    }
1113
1114    #[test]
1115    fn test_dir_storage_save_with_custom_extension() {
1116        let temp_dir = TempDir::new().unwrap();
1117        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1118            temp_dir.path().to_path_buf(),
1119        ));
1120
1121        let migrator = setup_session_migrator();
1122        let strategy = DirStorageStrategy::default()
1123            .with_format(FormatStrategy::Json)
1124            .with_extension("data");
1125        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1126
1127        let session = SessionEntity {
1128            id: "session-custom".to_string(),
1129            user_id: "user-999".to_string(),
1130            created_at: None,
1131        };
1132
1133        storage.save("session", "session-custom", session).unwrap();
1134
1135        // Verify custom extension is used
1136        let file_path = storage.base_path().join("session-custom.data");
1137        assert!(file_path.exists());
1138    }
1139
1140    #[test]
1141    fn test_dir_storage_save_overwrites_existing() {
1142        let temp_dir = TempDir::new().unwrap();
1143        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1144            temp_dir.path().to_path_buf(),
1145        ));
1146
1147        let migrator = setup_session_migrator();
1148        let strategy = DirStorageStrategy::default();
1149        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1150
1151        // Save initial version
1152        let session1 = SessionEntity {
1153            id: "session-overwrite".to_string(),
1154            user_id: "user-111".to_string(),
1155            created_at: Some("2024-01-01".to_string()),
1156        };
1157        storage
1158            .save("session", "session-overwrite", session1)
1159            .unwrap();
1160
1161        // Save updated version
1162        let session2 = SessionEntity {
1163            id: "session-overwrite".to_string(),
1164            user_id: "user-222".to_string(),
1165            created_at: Some("2024-01-02".to_string()),
1166        };
1167        storage
1168            .save("session", "session-overwrite", session2)
1169            .unwrap();
1170
1171        // Verify file was overwritten
1172        let file_path = storage.base_path().join("session-overwrite.json");
1173        let content = std::fs::read_to_string(&file_path).unwrap();
1174        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1175        assert_eq!(json["user_id"], "user-222");
1176        assert_eq!(json["created_at"], "2024-01-02");
1177    }
1178
1179    #[test]
1180    fn test_dir_storage_load_success() {
1181        let temp_dir = TempDir::new().unwrap();
1182        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1183            temp_dir.path().to_path_buf(),
1184        ));
1185
1186        let migrator = setup_session_migrator();
1187        let strategy = DirStorageStrategy::default();
1188        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1189
1190        // Save a session
1191        let session = SessionEntity {
1192            id: "session-load".to_string(),
1193            user_id: "user-999".to_string(),
1194            created_at: Some("2024-02-01".to_string()),
1195        };
1196        storage
1197            .save("session", "session-load", session.clone())
1198            .unwrap();
1199
1200        // Load it back
1201        let loaded: SessionEntity = storage.load("session", "session-load").unwrap();
1202        assert_eq!(loaded.id, session.id);
1203        assert_eq!(loaded.user_id, session.user_id);
1204        assert_eq!(loaded.created_at, session.created_at);
1205    }
1206
1207    #[test]
1208    fn test_dir_storage_load_not_found() {
1209        let temp_dir = TempDir::new().unwrap();
1210        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1211            temp_dir.path().to_path_buf(),
1212        ));
1213
1214        let migrator = setup_session_migrator();
1215        let strategy = DirStorageStrategy::default();
1216        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1217
1218        // Try to load non-existent session
1219        let result: Result<SessionEntity, _> = storage.load("session", "non-existent");
1220        assert!(result.is_err());
1221        assert!(matches!(
1222            result.unwrap_err(),
1223            MigrationError::Store(StoreError::IoError { .. })
1224        ));
1225    }
1226
1227    #[test]
1228    fn test_dir_storage_save_and_load_roundtrip() {
1229        let temp_dir = TempDir::new().unwrap();
1230        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1231            temp_dir.path().to_path_buf(),
1232        ));
1233
1234        let migrator = setup_session_migrator();
1235        let strategy = DirStorageStrategy::default();
1236        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1237
1238        // Test multiple sessions
1239        let sessions = vec![
1240            SessionEntity {
1241                id: "session-1".to_string(),
1242                user_id: "user-1".to_string(),
1243                created_at: Some("2024-01-01".to_string()),
1244            },
1245            SessionEntity {
1246                id: "session-2".to_string(),
1247                user_id: "user-2".to_string(),
1248                created_at: None,
1249            },
1250            SessionEntity {
1251                id: "session-3".to_string(),
1252                user_id: "user-3".to_string(),
1253                created_at: Some("2024-03-01".to_string()),
1254            },
1255        ];
1256
1257        // Save all sessions
1258        for session in &sessions {
1259            storage
1260                .save("session", &session.id, session.clone())
1261                .unwrap();
1262        }
1263
1264        // Load and verify each session
1265        for session in &sessions {
1266            let loaded: SessionEntity = storage.load("session", &session.id).unwrap();
1267            assert_eq!(loaded.id, session.id);
1268            assert_eq!(loaded.user_id, session.user_id);
1269            assert_eq!(loaded.created_at, session.created_at);
1270        }
1271    }
1272
1273    #[test]
1274    fn test_dir_storage_list_ids_empty() {
1275        let temp_dir = TempDir::new().unwrap();
1276        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1277            temp_dir.path().to_path_buf(),
1278        ));
1279
1280        let migrator = setup_session_migrator();
1281        let strategy = DirStorageStrategy::default();
1282        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1283
1284        // List IDs from empty directory
1285        let ids = storage.list_ids().unwrap();
1286        assert!(ids.is_empty());
1287    }
1288
1289    #[test]
1290    fn test_dir_storage_list_ids() {
1291        let temp_dir = TempDir::new().unwrap();
1292        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1293            temp_dir.path().to_path_buf(),
1294        ));
1295
1296        let migrator = setup_session_migrator();
1297        let strategy = DirStorageStrategy::default();
1298        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1299
1300        // Save multiple sessions
1301        let ids = vec!["session-c", "session-a", "session-b"];
1302        for id in &ids {
1303            let session = SessionEntity {
1304                id: id.to_string(),
1305                user_id: "user".to_string(),
1306                created_at: None,
1307            };
1308            storage.save("session", id, session).unwrap();
1309        }
1310
1311        // List IDs
1312        let listed_ids = storage.list_ids().unwrap();
1313        assert_eq!(listed_ids.len(), 3);
1314        // Should be sorted
1315        assert_eq!(listed_ids, vec!["session-a", "session-b", "session-c"]);
1316    }
1317
1318    #[test]
1319    fn test_dir_storage_load_all_empty() {
1320        let temp_dir = TempDir::new().unwrap();
1321        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1322            temp_dir.path().to_path_buf(),
1323        ));
1324
1325        let migrator = setup_session_migrator();
1326        let strategy = DirStorageStrategy::default();
1327        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1328
1329        // Load all from empty directory
1330        let results: Vec<(String, SessionEntity)> = storage.load_all("session").unwrap();
1331        assert!(results.is_empty());
1332    }
1333
1334    #[test]
1335    fn test_dir_storage_load_all() {
1336        let temp_dir = TempDir::new().unwrap();
1337        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1338            temp_dir.path().to_path_buf(),
1339        ));
1340
1341        let migrator = setup_session_migrator();
1342        let strategy = DirStorageStrategy::default();
1343        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1344
1345        // Save multiple sessions
1346        let sessions = vec![
1347            SessionEntity {
1348                id: "session-x".to_string(),
1349                user_id: "user-x".to_string(),
1350                created_at: Some("2024-01-01".to_string()),
1351            },
1352            SessionEntity {
1353                id: "session-y".to_string(),
1354                user_id: "user-y".to_string(),
1355                created_at: None,
1356            },
1357            SessionEntity {
1358                id: "session-z".to_string(),
1359                user_id: "user-z".to_string(),
1360                created_at: Some("2024-03-01".to_string()),
1361            },
1362        ];
1363
1364        for session in &sessions {
1365            storage
1366                .save("session", &session.id, session.clone())
1367                .unwrap();
1368        }
1369
1370        // Load all
1371        let results: Vec<(String, SessionEntity)> = storage.load_all("session").unwrap();
1372        assert_eq!(results.len(), 3);
1373
1374        // Verify all sessions are loaded
1375        for (id, loaded) in &results {
1376            let original = sessions.iter().find(|s| &s.id == id).unwrap();
1377            assert_eq!(loaded.id, original.id);
1378            assert_eq!(loaded.user_id, original.user_id);
1379            assert_eq!(loaded.created_at, original.created_at);
1380        }
1381    }
1382
1383    #[test]
1384    fn test_dir_storage_exists() {
1385        let temp_dir = TempDir::new().unwrap();
1386        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1387            temp_dir.path().to_path_buf(),
1388        ));
1389
1390        let migrator = setup_session_migrator();
1391        let strategy = DirStorageStrategy::default();
1392        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1393
1394        // Non-existent file
1395        assert!(!storage.exists("session-exists").unwrap());
1396
1397        // Save a session
1398        let session = SessionEntity {
1399            id: "session-exists".to_string(),
1400            user_id: "user-exists".to_string(),
1401            created_at: None,
1402        };
1403        storage.save("session", "session-exists", session).unwrap();
1404
1405        // Should exist now
1406        assert!(storage.exists("session-exists").unwrap());
1407    }
1408
1409    #[test]
1410    fn test_dir_storage_delete() {
1411        let temp_dir = TempDir::new().unwrap();
1412        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1413            temp_dir.path().to_path_buf(),
1414        ));
1415
1416        let migrator = setup_session_migrator();
1417        let strategy = DirStorageStrategy::default();
1418        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1419
1420        // Save a session
1421        let session = SessionEntity {
1422            id: "session-delete".to_string(),
1423            user_id: "user-delete".to_string(),
1424            created_at: None,
1425        };
1426        storage.save("session", "session-delete", session).unwrap();
1427
1428        // Verify it exists
1429        assert!(storage.exists("session-delete").unwrap());
1430
1431        // Delete it
1432        storage.delete("session-delete").unwrap();
1433
1434        // Verify it doesn't exist
1435        assert!(!storage.exists("session-delete").unwrap());
1436    }
1437
1438    #[test]
1439    fn test_dir_storage_delete_idempotent() {
1440        let temp_dir = TempDir::new().unwrap();
1441        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1442            temp_dir.path().to_path_buf(),
1443        ));
1444
1445        let migrator = setup_session_migrator();
1446        let strategy = DirStorageStrategy::default();
1447        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1448
1449        // Delete non-existent file (should not error)
1450        storage.delete("non-existent").unwrap();
1451
1452        // Delete again (should still not error)
1453        storage.delete("non-existent").unwrap();
1454    }
1455
1456    #[test]
1457    fn test_dir_storage_load_toml() {
1458        let temp_dir = TempDir::new().unwrap();
1459        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1460            temp_dir.path().to_path_buf(),
1461        ));
1462
1463        let migrator = setup_session_migrator();
1464        let strategy = DirStorageStrategy::default().with_format(FormatStrategy::Toml);
1465        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1466
1467        // Save a session
1468        let session = SessionEntity {
1469            id: "session-toml".to_string(),
1470            user_id: "user-toml".to_string(),
1471            created_at: Some("2024-04-01".to_string()),
1472        };
1473        storage
1474            .save("session", "session-toml", session.clone())
1475            .unwrap();
1476
1477        // Load it back
1478        let loaded: SessionEntity = storage.load("session", "session-toml").unwrap();
1479        assert_eq!(loaded.id, session.id);
1480        assert_eq!(loaded.user_id, session.user_id);
1481        assert_eq!(loaded.created_at, session.created_at);
1482    }
1483
1484    #[test]
1485    fn test_dir_storage_list_ids_with_custom_extension() {
1486        let temp_dir = TempDir::new().unwrap();
1487        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1488            temp_dir.path().to_path_buf(),
1489        ));
1490
1491        let migrator = setup_session_migrator();
1492        let strategy = DirStorageStrategy::default().with_extension("data");
1493        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1494
1495        // Save sessions with custom extension
1496        let session = SessionEntity {
1497            id: "session-ext".to_string(),
1498            user_id: "user-ext".to_string(),
1499            created_at: None,
1500        };
1501        storage.save("session", "session-ext", session).unwrap();
1502
1503        // List IDs should find the file
1504        let ids = storage.list_ids().unwrap();
1505        assert_eq!(ids.len(), 1);
1506        assert_eq!(ids[0], "session-ext");
1507    }
1508
1509    #[test]
1510    fn test_dir_storage_load_all_atomic_failure() {
1511        let temp_dir = TempDir::new().unwrap();
1512        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1513            temp_dir.path().to_path_buf(),
1514        ));
1515
1516        let migrator = setup_session_migrator();
1517        let strategy = DirStorageStrategy::default();
1518        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1519
1520        // Save valid sessions
1521        let session1 = SessionEntity {
1522            id: "session-1".to_string(),
1523            user_id: "user-1".to_string(),
1524            created_at: None,
1525        };
1526        storage.save("session", "session-1", session1).unwrap();
1527
1528        // Manually create a corrupted file
1529        let corrupted_path = storage.base_path().join("session-corrupted.json");
1530        std::fs::write(&corrupted_path, "invalid json {{{").unwrap();
1531
1532        // load_all should fail
1533        let result: Result<Vec<(String, SessionEntity)>, _> = storage.load_all("session");
1534        assert!(result.is_err());
1535    }
1536
1537    #[test]
1538    fn test_dir_storage_filename_encoding_url_roundtrip() {
1539        let temp_dir = TempDir::new().unwrap();
1540        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1541            temp_dir.path().to_path_buf(),
1542        ));
1543
1544        let migrator = setup_session_migrator();
1545        let strategy =
1546            DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
1547        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1548
1549        // Use an ID with special characters that need URL encoding
1550        let complex_id = "user@example.com/path?query=1";
1551        let session = SessionEntity {
1552            id: complex_id.to_string(),
1553            user_id: "user-special".to_string(),
1554            created_at: Some("2024-05-01".to_string()),
1555        };
1556
1557        // Save the entity
1558        storage
1559            .save("session", complex_id, session.clone())
1560            .unwrap();
1561
1562        // Verify the file was created with encoded filename
1563        let encoded_id = urlencoding::encode(complex_id);
1564        let file_path = storage.base_path().join(format!("{}.json", encoded_id));
1565        assert!(file_path.exists());
1566
1567        // Load it back
1568        let loaded: SessionEntity = storage.load("session", complex_id).unwrap();
1569        assert_eq!(loaded.id, session.id);
1570        assert_eq!(loaded.user_id, session.user_id);
1571        assert_eq!(loaded.created_at, session.created_at);
1572
1573        // Verify list_ids works correctly
1574        let ids = storage.list_ids().unwrap();
1575        assert_eq!(ids.len(), 1);
1576        assert_eq!(ids[0], complex_id);
1577    }
1578
1579    #[test]
1580    fn test_dir_storage_filename_encoding_base64_roundtrip() {
1581        let temp_dir = TempDir::new().unwrap();
1582        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1583            temp_dir.path().to_path_buf(),
1584        ));
1585
1586        let migrator = setup_session_migrator();
1587        let strategy =
1588            DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
1589        let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1590
1591        // Use a complex ID with various special characters
1592        let complex_id = "user@example.com/path?query=1&special=!@#$%";
1593        let session = SessionEntity {
1594            id: complex_id.to_string(),
1595            user_id: "user-base64".to_string(),
1596            created_at: Some("2024-06-01".to_string()),
1597        };
1598
1599        // Save the entity
1600        storage
1601            .save("session", complex_id, session.clone())
1602            .unwrap();
1603
1604        // Verify the file was created with Base64-encoded filename
1605        let encoded_id = URL_SAFE_NO_PAD.encode(complex_id.as_bytes());
1606        let file_path = storage.base_path().join(format!("{}.json", encoded_id));
1607        assert!(file_path.exists());
1608
1609        // Load it back
1610        let loaded: SessionEntity = storage.load("session", complex_id).unwrap();
1611        assert_eq!(loaded.id, session.id);
1612        assert_eq!(loaded.user_id, session.user_id);
1613        assert_eq!(loaded.created_at, session.created_at);
1614
1615        // Verify list_ids works correctly
1616        let ids = storage.list_ids().unwrap();
1617        assert_eq!(ids.len(), 1);
1618        assert_eq!(ids[0], complex_id);
1619    }
1620
1621    /// Test that list_ids returns a FilenameEncoding error when a file with an
1622    /// undecoded URL-encoded name (invalid UTF-8 percent sequence) is present in the
1623    /// storage directory.
1624    #[test]
1625    fn test_list_ids_url_decode_error() {
1626        let temp_dir = TempDir::new().unwrap();
1627        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1628            temp_dir.path().to_path_buf(),
1629        ));
1630
1631        let migrator = setup_session_migrator();
1632        let strategy =
1633            DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
1634        let storage = DirStorage::new(paths, "sessions_url_err", migrator, strategy).unwrap();
1635
1636        // Manually create a file whose stem is an invalid UTF-8 percent sequence.
1637        // `%C0%C1` is an overlong encoding that the urlencoding crate rejects.
1638        let bad_stem = "%C0%C1";
1639        let bad_file = storage.base_path().join(format!("{}.json", bad_stem));
1640        std::fs::write(&bad_file, "{}").unwrap();
1641
1642        let result = storage.list_ids();
1643        assert!(
1644            result.is_err(),
1645            "list_ids should propagate the FilenameEncoding decode error"
1646        );
1647        assert!(matches!(
1648            result.unwrap_err(),
1649            MigrationError::FilenameEncoding { .. }
1650        ));
1651    }
1652
1653    /// Test that list_ids returns a FilenameEncoding error when a file with an
1654    /// invalid Base64-encoded name is present in the storage directory.
1655    #[test]
1656    fn test_list_ids_base64_decode_error() {
1657        let temp_dir = TempDir::new().unwrap();
1658        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1659            temp_dir.path().to_path_buf(),
1660        ));
1661
1662        let migrator = setup_session_migrator();
1663        let strategy =
1664            DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
1665        let storage = DirStorage::new(paths, "sessions_b64_err", migrator, strategy).unwrap();
1666
1667        // Manually create a file whose stem is not valid Base64.
1668        let bad_stem = "!!!invalid@@@";
1669        let bad_file = storage.base_path().join(format!("{}.json", bad_stem));
1670        std::fs::write(&bad_file, "{}").unwrap();
1671
1672        let result = storage.list_ids();
1673        assert!(
1674            result.is_err(),
1675            "list_ids should propagate the FilenameEncoding decode error"
1676        );
1677        assert!(matches!(
1678            result.unwrap_err(),
1679            MigrationError::FilenameEncoding { .. }
1680        ));
1681    }
1682
1683    #[test]
1684    fn test_dir_storage_base_path() {
1685        let temp_dir = TempDir::new().unwrap();
1686        let domain_name = "test_sessions";
1687        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1688            temp_dir.path().to_path_buf(),
1689        ));
1690
1691        let migrator = Migrator::new();
1692        let strategy = DirStorageStrategy::default();
1693
1694        let storage = DirStorage::new(paths, domain_name, migrator, strategy).unwrap();
1695
1696        // Verify base_path() returns the expected path
1697        let returned_path = storage.base_path();
1698        assert!(returned_path.ends_with(domain_name));
1699        assert!(returned_path.exists());
1700    }
1701
1702    #[test]
1703    #[cfg(unix)]
1704    fn test_dir_storage_create_dir_permission_denied() {
1705        use std::os::unix::fs::PermissionsExt;
1706
1707        let temp_dir = TempDir::new().unwrap();
1708
1709        // Make the temp dir read-only
1710        let mut perms = fs::metadata(temp_dir.path()).unwrap().permissions();
1711        perms.set_mode(0o444);
1712        fs::set_permissions(temp_dir.path(), perms).unwrap();
1713
1714        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1715            temp_dir.path().to_path_buf(),
1716        ));
1717
1718        let migrator = Migrator::new();
1719        let strategy = DirStorageStrategy::default();
1720
1721        // Should fail because we can't create directory in read-only parent
1722        let result = DirStorage::new(paths, "sessions", migrator, strategy);
1723
1724        // Restore permissions before assertion (so TempDir cleanup works)
1725        let mut perms = fs::metadata(temp_dir.path()).unwrap().permissions();
1726        perms.set_mode(0o755);
1727        fs::set_permissions(temp_dir.path(), perms).unwrap();
1728
1729        assert!(result.is_err());
1730        assert!(matches!(
1731            result,
1732            Err(MigrationError::Store(StoreError::IoError { .. }))
1733        ));
1734    }
1735
1736    #[test]
1737    #[cfg(unix)]
1738    fn test_dir_storage_save_permission_denied() {
1739        use std::os::unix::fs::PermissionsExt;
1740
1741        let temp_dir = TempDir::new().unwrap();
1742        let domain_name = "sessions_readonly";
1743        let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1744            temp_dir.path().to_path_buf(),
1745        ));
1746
1747        let migrator = setup_session_migrator();
1748        let strategy = DirStorageStrategy::default();
1749
1750        // Create storage first (creates directory)
1751        let storage = DirStorage::new(paths, domain_name, migrator, strategy).unwrap();
1752
1753        // Make the storage directory read-only
1754        let mut perms = fs::metadata(storage.base_path()).unwrap().permissions();
1755        perms.set_mode(0o444);
1756        fs::set_permissions(storage.base_path(), perms).unwrap();
1757
1758        let session = SessionEntity {
1759            id: "test".to_string(),
1760            user_id: "user".to_string(),
1761            created_at: None,
1762        };
1763
1764        // Should fail because directory is read-only
1765        let result = storage.save("session", "test-session", session);
1766
1767        // Restore permissions before assertion
1768        let mut perms = fs::metadata(storage.base_path()).unwrap().permissions();
1769        perms.set_mode(0o755);
1770        fs::set_permissions(storage.base_path(), perms).unwrap();
1771
1772        assert!(result.is_err());
1773        assert!(matches!(
1774            result,
1775            Err(MigrationError::Store(StoreError::IoError { .. }))
1776        ));
1777    }
1778}