Skip to main content

local_store/
dir_storage.rs

1//! Raw directory-based storage for one-file-per-entity persistence.
2//!
3//! Provides ACID-safe IO operations (atomic rename, fsync, retry) without any
4//! migration or versioning logic.  Schema evolution is the caller's responsibility.
5//!
6//! # Crux constraint compliance
7//!
8//! - This module contains **no** reference to `Migrator`, `ConfigMigrator`,
9//!   `Queryable`, `MigrationError`, or `version_migrate`.
10//! - All public APIs accept `category` / `entity_name` / `id` as
11//!   `impl Into<String>` (never a concrete enum type).
12
13use crate::{
14    atomic_io,
15    errors::{IoOperationKind, StoreError},
16    AppPaths,
17};
18use base64::engine::general_purpose::URL_SAFE_NO_PAD;
19use base64::Engine;
20use std::fs::{self, File};
21use std::io::Write as IoWrite;
22use std::path::{Path, PathBuf};
23
24// Re-export shared types from storage module so callers can use them from
25// a single import path.
26pub use crate::storage::{AtomicWriteConfig, FormatStrategy};
27
28// ============================================================================
29// Configuration types
30// ============================================================================
31
32/// File-naming encoding strategy for entity IDs.
33///
34/// Determines how entity IDs are encoded into filesystem-safe filenames.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum FilenameEncoding {
37    /// Use the ID directly as the filename.
38    ///
39    /// Only IDs consisting entirely of ASCII alphanumeric characters, `-`, and
40    /// `_` are accepted; any other character causes a `StoreError::FilenameEncoding`
41    /// error.
42    #[default]
43    Direct,
44    /// URL-encode the ID so that special characters become percent-escaped
45    /// sequences that are safe to use in a filename.
46    UrlEncode,
47    /// Base64-encode the ID using the URL-safe alphabet without padding.
48    Base64,
49}
50
51/// Strategy configuration for directory-based storage operations.
52#[derive(Debug, Clone)]
53pub struct DirStorageStrategy {
54    /// File format to use for serialisation (JSON or TOML).
55    pub format: FormatStrategy,
56    /// Atomic write configuration (retry count, temp-file cleanup).
57    pub atomic_write: AtomicWriteConfig,
58    /// Custom file extension.  When `None` the extension is derived from
59    /// `format` (`"json"` or `"toml"`).
60    pub extension: Option<String>,
61    /// Filename encoding strategy for entity IDs.
62    pub filename_encoding: FilenameEncoding,
63}
64
65impl Default for DirStorageStrategy {
66    fn default() -> Self {
67        Self {
68            format: FormatStrategy::Json,
69            atomic_write: AtomicWriteConfig::default(),
70            extension: None,
71            filename_encoding: FilenameEncoding::default(),
72        }
73    }
74}
75
76impl DirStorageStrategy {
77    /// Create a new strategy with default values.
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    /// Set the file format.
83    ///
84    /// # Arguments
85    ///
86    /// * `format` - `FormatStrategy::Json` or `FormatStrategy::Toml`.
87    ///
88    /// # Returns
89    ///
90    /// `self` with the updated format (builder pattern).
91    pub fn with_format(mut self, format: FormatStrategy) -> Self {
92        self.format = format;
93        self
94    }
95
96    /// Set a custom file extension.
97    ///
98    /// # Arguments
99    ///
100    /// * `ext` - Extension string without the leading dot (e.g. `"json"`).
101    ///
102    /// # Returns
103    ///
104    /// `self` with the updated extension (builder pattern).
105    pub fn with_extension(mut self, ext: impl Into<String>) -> Self {
106        self.extension = Some(ext.into());
107        self
108    }
109
110    /// Set the filename encoding strategy.
111    ///
112    /// # Arguments
113    ///
114    /// * `encoding` - One of `FilenameEncoding::Direct`, `UrlEncode`, or `Base64`.
115    ///
116    /// # Returns
117    ///
118    /// `self` with the updated encoding (builder pattern).
119    pub fn with_filename_encoding(mut self, encoding: FilenameEncoding) -> Self {
120        self.filename_encoding = encoding;
121        self
122    }
123
124    /// Set the retry count for atomic writes.
125    ///
126    /// # Arguments
127    ///
128    /// * `count` - Number of rename attempts before returning an error.
129    ///
130    /// # Returns
131    ///
132    /// `self` with the updated retry count (builder pattern).
133    pub fn with_retry_count(mut self, count: usize) -> Self {
134        self.atomic_write.retry_count = count;
135        self
136    }
137
138    /// Set whether to clean up orphaned temporary files.
139    ///
140    /// # Arguments
141    ///
142    /// * `cleanup` - When `true`, stale `.tmp.*` files are removed after every
143    ///   successful atomic write (best-effort; errors are silently ignored).
144    ///
145    /// # Returns
146    ///
147    /// `self` with the updated cleanup flag (builder pattern).
148    pub fn with_cleanup(mut self, cleanup: bool) -> Self {
149        self.atomic_write.cleanup_tmp_files = cleanup;
150        self
151    }
152
153    /// Returns the effective file extension for this strategy.
154    ///
155    /// Uses `self.extension` when set; otherwise derives `"json"` or `"toml"`
156    /// from `self.format`.
157    pub fn get_extension(&self) -> String {
158        self.extension.clone().unwrap_or_else(|| match self.format {
159            FormatStrategy::Json => "json".to_string(),
160            FormatStrategy::Toml => "toml".to_string(),
161        })
162    }
163}
164
165// ============================================================================
166// Sync DirStorage
167// ============================================================================
168
169/// Raw directory-based entity storage with ACID guarantees.
170///
171/// Manages one file per entity and provides:
172///
173/// - **Atomicity**: writes use a temporary file followed by an atomic rename.
174/// - **Durability**: `fsync` is called before the rename.
175/// - **Idempotent delete**: calling `delete` on a missing ID returns `Ok(())`.
176///
177/// This type holds no `Migrator` and performs no schema migration.
178/// Content is stored and retrieved as opaque UTF-8 strings; callers are
179/// responsible for any serialisation/deserialisation.
180pub struct DirStorage {
181    /// Resolved absolute path to the storage directory.
182    base_path: PathBuf,
183    /// Storage strategy (format, encoding, atomic-write config).
184    strategy: DirStorageStrategy,
185}
186
187impl DirStorage {
188    /// Create a new `DirStorage` instance.
189    ///
190    /// # Arguments
191    ///
192    /// * `paths` - Application path manager used to resolve `data_dir`.
193    /// * `category` - Sub-directory name appended to `data_dir` (e.g. `"sessions"`).
194    /// * `strategy` - Storage strategy configuration.
195    ///
196    /// # Returns
197    ///
198    /// `Ok(DirStorage)` with `base_path = data_dir/category`.
199    ///
200    /// # Errors
201    ///
202    /// Returns `StoreError::HomeDirNotFound` if `data_dir` cannot be resolved,
203    /// or `StoreError::IoError { operation: CreateDir, … }` if the base
204    /// directory cannot be created.
205    pub fn new(
206        paths: AppPaths,
207        category: impl Into<String>,
208        strategy: DirStorageStrategy,
209    ) -> Result<Self, StoreError> {
210        let category: String = category.into();
211        let base_path = paths.data_dir()?.join(&category);
212
213        if !base_path.exists() {
214            fs::create_dir_all(&base_path).map_err(|e| StoreError::IoError {
215                operation: IoOperationKind::CreateDir,
216                path: base_path.display().to_string(),
217                context: Some("storage base directory".to_string()),
218                error: e.to_string(),
219            })?;
220        }
221
222        Ok(Self {
223            base_path,
224            strategy,
225        })
226    }
227
228    /// Write raw string content for an entity, atomically.
229    ///
230    /// # Arguments
231    ///
232    /// * `entity_name` - Logical entity type name (informational; not used in
233    ///   the file path).
234    /// * `id` - Unique identifier for this entity (encoded into the filename).
235    /// * `content` - UTF-8 string to persist verbatim.
236    ///
237    /// # Returns
238    ///
239    /// `Ok(())` on success.
240    ///
241    /// # Errors
242    ///
243    /// - `StoreError::FilenameEncoding` if `id` cannot be encoded with the
244    ///   configured strategy.
245    /// - `StoreError::IoError` if the file cannot be written.
246    pub fn save_raw_string(
247        &self,
248        _entity_name: impl Into<String>,
249        id: impl Into<String>,
250        content: &str,
251    ) -> Result<(), StoreError> {
252        let id: String = id.into();
253        let file_path = self.id_to_path(&id)?;
254        self.atomic_write(&file_path, content)?;
255        Ok(())
256    }
257
258    /// Read the raw string content for an entity.
259    ///
260    /// # Arguments
261    ///
262    /// * `id` - Unique identifier for the entity.
263    ///
264    /// # Returns
265    ///
266    /// The UTF-8 string content stored for `id`.
267    ///
268    /// # Errors
269    ///
270    /// - `StoreError::FilenameEncoding` if `id` cannot be encoded.
271    /// - `StoreError::IoError { operation: Read, … }` if the file is missing
272    ///   or cannot be read.
273    pub fn load_raw_string(&self, id: impl Into<String>) -> Result<String, StoreError> {
274        let id: String = id.into();
275        let file_path = self.id_to_path(&id)?;
276
277        if !file_path.exists() {
278            return Err(StoreError::IoError {
279                operation: IoOperationKind::Read,
280                path: file_path.display().to_string(),
281                context: None,
282                error: "File not found".to_string(),
283            });
284        }
285
286        fs::read_to_string(&file_path).map_err(|e| StoreError::IoError {
287            operation: IoOperationKind::Read,
288            path: file_path.display().to_string(),
289            context: None,
290            error: e.to_string(),
291        })
292    }
293
294    /// List all entity IDs stored in the base directory.
295    ///
296    /// Only files whose extension matches `strategy.get_extension()` are
297    /// included.  Temporary files (`.tmp.*`) are excluded because their
298    /// extension is `tmp`, not the configured extension.
299    ///
300    /// # Returns
301    ///
302    /// A sorted `Vec<String>` of decoded entity IDs.
303    ///
304    /// # Errors
305    ///
306    /// - `StoreError::IoError { operation: ReadDir, … }` if the directory
307    ///   cannot be read.
308    /// - `StoreError::FilenameEncoding` if a filename cannot be decoded.
309    pub fn list_ids(&self) -> Result<Vec<String>, StoreError> {
310        let entries = fs::read_dir(&self.base_path).map_err(|e| StoreError::IoError {
311            operation: IoOperationKind::ReadDir,
312            path: self.base_path.display().to_string(),
313            context: None,
314            error: e.to_string(),
315        })?;
316
317        let extension = self.strategy.get_extension();
318        let mut ids = Vec::new();
319
320        for entry in entries {
321            let entry = entry.map_err(|e| StoreError::IoError {
322                operation: IoOperationKind::ReadDir,
323                path: self.base_path.display().to_string(),
324                context: Some("directory entry".to_string()),
325                error: e.to_string(),
326            })?;
327
328            let path = entry.path();
329
330            if path.is_file() {
331                if let Some(ext) = path.extension() {
332                    if ext == extension.as_str() {
333                        if let Some(id) = self.path_to_id(&path)? {
334                            ids.push(id);
335                        }
336                    }
337                }
338            }
339        }
340
341        ids.sort();
342        Ok(ids)
343    }
344
345    /// Check whether an entity file exists.
346    ///
347    /// # Arguments
348    ///
349    /// * `id` - Entity identifier.
350    ///
351    /// # Returns
352    ///
353    /// `true` if the encoded file exists and is a regular file; `false`
354    /// otherwise.
355    ///
356    /// # Errors
357    ///
358    /// `StoreError::FilenameEncoding` if `id` cannot be encoded.
359    pub fn exists(&self, id: impl Into<String>) -> Result<bool, StoreError> {
360        let id: String = id.into();
361        let file_path = self.id_to_path(&id)?;
362        Ok(file_path.exists() && file_path.is_file())
363    }
364
365    /// Delete the file associated with an entity ID.
366    ///
367    /// This operation is **idempotent**: if the file does not exist, `Ok(())`
368    /// is returned without error (matches original behaviour at
369    /// `dir_storage.rs:760-775`).
370    ///
371    /// # Arguments
372    ///
373    /// * `id` - Entity identifier.
374    ///
375    /// # Returns
376    ///
377    /// `Ok(())` whether or not the file existed.
378    ///
379    /// # Errors
380    ///
381    /// - `StoreError::FilenameEncoding` if `id` cannot be encoded.
382    /// - `StoreError::IoError { operation: Delete, … }` if the file exists but
383    ///   cannot be removed.
384    pub fn delete(&self, id: impl Into<String>) -> Result<(), StoreError> {
385        let id: String = id.into();
386        let file_path = self.id_to_path(&id)?;
387
388        if file_path.exists() {
389            fs::remove_file(&file_path).map_err(|e| StoreError::IoError {
390                operation: IoOperationKind::Delete,
391                path: file_path.display().to_string(),
392                context: None,
393                error: e.to_string(),
394            })?;
395        }
396
397        Ok(())
398    }
399
400    /// Returns a reference to the resolved base directory path.
401    ///
402    /// # Returns
403    ///
404    /// The absolute `Path` at which entity files are stored.
405    pub fn base_path(&self) -> &Path {
406        &self.base_path
407    }
408
409    // =========================================================================
410    // Private helpers
411    // =========================================================================
412
413    /// Encode `id` and build the full file path for it.
414    ///
415    /// # Errors
416    ///
417    /// `StoreError::FilenameEncoding` if the encoding strategy rejects the ID.
418    fn id_to_path(&self, id: &str) -> Result<PathBuf, StoreError> {
419        let encoded_id = self.encode_id(id)?;
420        let extension = self.strategy.get_extension();
421        let filename = format!("{}.{}", encoded_id, extension);
422        Ok(self.base_path.join(filename))
423    }
424
425    /// Encode an entity ID to a filesystem-safe stem using the configured
426    /// encoding strategy.
427    ///
428    /// # Arguments
429    ///
430    /// * `id` - Raw entity identifier string.
431    ///
432    /// # Returns
433    ///
434    /// The encoded stem (without extension).
435    ///
436    /// # Errors
437    ///
438    /// `StoreError::FilenameEncoding { id, reason }` when:
439    /// - `Direct` mode and `id` contains characters outside `[A-Za-z0-9\-_]`.
440    fn encode_id(&self, id: &str) -> Result<String, StoreError> {
441        match self.strategy.filename_encoding {
442            FilenameEncoding::Direct => {
443                if id
444                    .chars()
445                    .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
446                {
447                    Ok(id.to_string())
448                } else {
449                    Err(StoreError::FilenameEncoding {
450                        id: id.to_string(),
451                        reason: "ID contains invalid characters for Direct encoding. \
452                             Only alphanumeric, '-', and '_' are allowed."
453                            .to_string(),
454                    })
455                }
456            }
457            FilenameEncoding::UrlEncode => Ok(urlencoding::encode(id).into_owned()),
458            FilenameEncoding::Base64 => Ok(URL_SAFE_NO_PAD.encode(id.as_bytes())),
459        }
460    }
461
462    /// Decode a filename stem back to the original entity ID.
463    ///
464    /// # Arguments
465    ///
466    /// * `filename_stem` - The file name without extension.
467    ///
468    /// # Returns
469    ///
470    /// The decoded entity ID string.
471    ///
472    /// # Errors
473    ///
474    /// `StoreError::FilenameEncoding { id, reason }` when decoding fails.
475    fn decode_id(&self, filename_stem: &str) -> Result<String, StoreError> {
476        match self.strategy.filename_encoding {
477            FilenameEncoding::Direct => Ok(filename_stem.to_string()),
478            FilenameEncoding::UrlEncode => urlencoding::decode(filename_stem)
479                .map(|s| s.into_owned())
480                .map_err(|e| StoreError::FilenameEncoding {
481                    id: filename_stem.to_string(),
482                    reason: format!("Failed to URL-decode filename: {}", e),
483                }),
484            FilenameEncoding::Base64 => URL_SAFE_NO_PAD
485                .decode(filename_stem.as_bytes())
486                .map_err(|e| StoreError::FilenameEncoding {
487                    id: filename_stem.to_string(),
488                    reason: format!("Failed to Base64-decode filename: {}", e),
489                })
490                .and_then(|bytes| {
491                    String::from_utf8(bytes).map_err(|e| StoreError::FilenameEncoding {
492                        id: filename_stem.to_string(),
493                        reason: format!("Failed to convert Base64-decoded bytes to UTF-8: {}", e),
494                    })
495                }),
496        }
497    }
498
499    /// Extract the entity ID from an absolute file path.
500    ///
501    /// # Returns
502    ///
503    /// `Some(id)` when a valid stem is found; `None` when the path has no stem.
504    ///
505    /// # Errors
506    ///
507    /// `StoreError::FilenameEncoding` if the stem cannot be decoded.
508    fn path_to_id(&self, path: &Path) -> Result<Option<String>, StoreError> {
509        let file_stem = match path.file_stem() {
510            Some(stem) => stem.to_string_lossy(),
511            None => return Ok(None),
512        };
513        let id = self.decode_id(&file_stem)?;
514        Ok(Some(id))
515    }
516
517    /// Write `content` to `path` atomically (tmp file + fsync + rename).
518    ///
519    /// # Arguments
520    ///
521    /// * `path` - Final target path.
522    /// * `content` - UTF-8 string to write.
523    ///
524    /// # Errors
525    ///
526    /// `StoreError::IoError` if any step (create / write / sync / rename) fails.
527    fn atomic_write(&self, path: &Path, content: &str) -> Result<(), StoreError> {
528        // Ensure parent directory exists.
529        if let Some(parent) = path.parent() {
530            if !parent.exists() {
531                fs::create_dir_all(parent).map_err(|e| StoreError::IoError {
532                    operation: IoOperationKind::CreateDir,
533                    path: parent.display().to_string(),
534                    context: Some("parent directory".to_string()),
535                    error: e.to_string(),
536                })?;
537            }
538        }
539
540        let tmp_path = atomic_io::get_temp_path(path)?;
541
542        let mut tmp_file = File::create(&tmp_path).map_err(|e| StoreError::IoError {
543            operation: IoOperationKind::Create,
544            path: tmp_path.display().to_string(),
545            context: Some("temporary file".to_string()),
546            error: e.to_string(),
547        })?;
548
549        tmp_file
550            .write_all(content.as_bytes())
551            .map_err(|e| StoreError::IoError {
552                operation: IoOperationKind::Write,
553                path: tmp_path.display().to_string(),
554                context: Some("temporary file".to_string()),
555                error: e.to_string(),
556            })?;
557
558        tmp_file.sync_all().map_err(|e| StoreError::IoError {
559            operation: IoOperationKind::Sync,
560            path: tmp_path.display().to_string(),
561            context: Some("temporary file".to_string()),
562            error: e.to_string(),
563        })?;
564
565        drop(tmp_file);
566
567        atomic_io::atomic_rename(&tmp_path, path, self.strategy.atomic_write.retry_count)?;
568
569        if self.strategy.atomic_write.cleanup_tmp_files {
570            let _ = atomic_io::cleanup_temp_files(path);
571        }
572
573        Ok(())
574    }
575}
576
577// ============================================================================
578// Async implementation
579// ============================================================================
580
581#[cfg(feature = "async")]
582pub use async_impl::AsyncDirStorage;
583
584#[cfg(feature = "async")]
585mod async_impl {
586    use super::{DirStorageStrategy, FilenameEncoding};
587    use crate::{
588        atomic_io,
589        errors::{IoOperationKind, StoreError},
590        AppPaths,
591    };
592    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
593    use base64::Engine;
594    use std::path::{Path, PathBuf};
595    use tokio::io::AsyncWriteExt;
596
597    /// Async version of [`DirStorage`](super::DirStorage).
598    ///
599    /// Provides the same raw IO guarantees (atomic rename, fsync, retry) using
600    /// `tokio::fs` for non-blocking operation.
601    ///
602    /// # Crux constraint compliance
603    ///
604    /// This struct contains no reference to `Migrator`, `ConfigMigrator`,
605    /// `Queryable`, `MigrationError`, or `version_migrate`.
606    pub struct AsyncDirStorage {
607        /// Resolved absolute path to the storage directory.
608        base_path: PathBuf,
609        /// Storage strategy (format, encoding, atomic-write config).
610        strategy: DirStorageStrategy,
611    }
612
613    impl AsyncDirStorage {
614        /// Create a new `AsyncDirStorage` instance (async).
615        ///
616        /// # Arguments
617        ///
618        /// * `paths` - Application path manager.
619        /// * `category` - Sub-directory name appended to `data_dir`.
620        /// * `strategy` - Storage strategy configuration.
621        ///
622        /// # Returns
623        ///
624        /// `Ok(AsyncDirStorage)` with `base_path = data_dir/category`.
625        ///
626        /// # Errors
627        ///
628        /// `StoreError::HomeDirNotFound` or `StoreError::IoError { operation:
629        /// CreateDir, … }`.
630        pub async fn new(
631            paths: AppPaths,
632            category: impl Into<String>,
633            strategy: DirStorageStrategy,
634        ) -> Result<Self, StoreError> {
635            let category: String = category.into();
636            let base_path = paths.data_dir()?.join(&category);
637
638            if !tokio::fs::try_exists(&base_path).await.unwrap_or(false) {
639                tokio::fs::create_dir_all(&base_path)
640                    .await
641                    .map_err(|e| StoreError::IoError {
642                        operation: IoOperationKind::CreateDir,
643                        path: base_path.display().to_string(),
644                        context: Some("storage base directory (async)".to_string()),
645                        error: e.to_string(),
646                    })?;
647            }
648
649            Ok(Self {
650                base_path,
651                strategy,
652            })
653        }
654
655        /// Write raw string content for an entity, atomically (async).
656        ///
657        /// # Arguments
658        ///
659        /// * `entity_name` - Logical entity type name (informational).
660        /// * `id` - Unique identifier (encoded into the filename).
661        /// * `content` - UTF-8 string to persist verbatim.
662        ///
663        /// # Returns
664        ///
665        /// `Ok(())` on success.
666        ///
667        /// # Errors
668        ///
669        /// `StoreError::FilenameEncoding` or `StoreError::IoError`.
670        pub async fn save_raw_string(
671            &self,
672            _entity_name: impl Into<String>,
673            id: impl Into<String>,
674            content: &str,
675        ) -> Result<(), StoreError> {
676            let id: String = id.into();
677            let file_path = self.id_to_path(&id)?;
678            self.atomic_write(&file_path, content).await?;
679            Ok(())
680        }
681
682        /// Read the raw string content for an entity (async).
683        ///
684        /// # Arguments
685        ///
686        /// * `id` - Unique identifier for the entity.
687        ///
688        /// # Returns
689        ///
690        /// The UTF-8 string content stored for `id`.
691        ///
692        /// # Errors
693        ///
694        /// `StoreError::FilenameEncoding` or `StoreError::IoError { operation:
695        /// Read, … }` (including "File not found").
696        pub async fn load_raw_string(&self, id: impl Into<String>) -> Result<String, StoreError> {
697            let id: String = id.into();
698            let file_path = self.id_to_path(&id)?;
699
700            if !tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
701                return Err(StoreError::IoError {
702                    operation: IoOperationKind::Read,
703                    path: file_path.display().to_string(),
704                    context: None,
705                    error: "File not found".to_string(),
706                });
707            }
708
709            tokio::fs::read_to_string(&file_path)
710                .await
711                .map_err(|e| StoreError::IoError {
712                    operation: IoOperationKind::Read,
713                    path: file_path.display().to_string(),
714                    context: None,
715                    error: e.to_string(),
716                })
717        }
718
719        /// List all entity IDs stored in the base directory (async).
720        ///
721        /// Only files matching `strategy.get_extension()` are included;
722        /// `.tmp.*` files are excluded.
723        ///
724        /// # Returns
725        ///
726        /// A sorted `Vec<String>` of decoded entity IDs.
727        ///
728        /// # Errors
729        ///
730        /// `StoreError::IoError { operation: ReadDir, … }` or
731        /// `StoreError::FilenameEncoding`.
732        pub async fn list_ids(&self) -> Result<Vec<String>, StoreError> {
733            let mut entries =
734                tokio::fs::read_dir(&self.base_path)
735                    .await
736                    .map_err(|e| StoreError::IoError {
737                        operation: IoOperationKind::ReadDir,
738                        path: self.base_path.display().to_string(),
739                        context: None,
740                        error: e.to_string(),
741                    })?;
742
743            let extension = self.strategy.get_extension();
744            let mut ids = Vec::new();
745
746            while let Some(entry) = entries
747                .next_entry()
748                .await
749                .map_err(|e| StoreError::IoError {
750                    operation: IoOperationKind::ReadDir,
751                    path: self.base_path.display().to_string(),
752                    context: Some("directory entry (async)".to_string()),
753                    error: e.to_string(),
754                })?
755            {
756                let path = entry.path();
757
758                let metadata =
759                    tokio::fs::metadata(&path)
760                        .await
761                        .map_err(|e| StoreError::IoError {
762                            operation: IoOperationKind::Read,
763                            path: path.display().to_string(),
764                            context: Some("metadata (async)".to_string()),
765                            error: e.to_string(),
766                        })?;
767
768                if metadata.is_file() {
769                    if let Some(ext) = path.extension() {
770                        if ext == extension.as_str() {
771                            if let Some(id) = self.path_to_id(&path)? {
772                                ids.push(id);
773                            }
774                        }
775                    }
776                }
777            }
778
779            ids.sort();
780            Ok(ids)
781        }
782
783        /// Check whether an entity file exists (async).
784        ///
785        /// # Arguments
786        ///
787        /// * `id` - Entity identifier.
788        ///
789        /// # Returns
790        ///
791        /// `true` if the encoded file exists and is a regular file.
792        ///
793        /// # Errors
794        ///
795        /// `StoreError::FilenameEncoding` if `id` cannot be encoded.
796        pub async fn exists(&self, id: impl Into<String>) -> Result<bool, StoreError> {
797            let id: String = id.into();
798            let file_path = self.id_to_path(&id)?;
799
800            if !tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
801                return Ok(false);
802            }
803
804            let metadata =
805                tokio::fs::metadata(&file_path)
806                    .await
807                    .map_err(|e| StoreError::IoError {
808                        operation: IoOperationKind::Read,
809                        path: file_path.display().to_string(),
810                        context: Some("metadata (async)".to_string()),
811                        error: e.to_string(),
812                    })?;
813
814            Ok(metadata.is_file())
815        }
816
817        /// Delete the file associated with an entity ID (async).
818        ///
819        /// This operation is **idempotent**: missing files return `Ok(())`.
820        ///
821        /// # Arguments
822        ///
823        /// * `id` - Entity identifier.
824        ///
825        /// # Returns
826        ///
827        /// `Ok(())` whether or not the file existed.
828        ///
829        /// # Errors
830        ///
831        /// `StoreError::FilenameEncoding` or `StoreError::IoError { operation:
832        /// Delete, … }`.
833        pub async fn delete(&self, id: impl Into<String>) -> Result<(), StoreError> {
834            let id: String = id.into();
835            let file_path = self.id_to_path(&id)?;
836
837            if tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
838                tokio::fs::remove_file(&file_path)
839                    .await
840                    .map_err(|e| StoreError::IoError {
841                        operation: IoOperationKind::Delete,
842                        path: file_path.display().to_string(),
843                        context: None,
844                        error: e.to_string(),
845                    })?;
846            }
847
848            Ok(())
849        }
850
851        /// Returns a reference to the resolved base directory path.
852        ///
853        /// # Returns
854        ///
855        /// The absolute `Path` at which entity files are stored.
856        pub fn base_path(&self) -> &Path {
857            &self.base_path
858        }
859
860        // =================================================================
861        // Private helpers (async)
862        // =================================================================
863
864        fn id_to_path(&self, id: &str) -> Result<PathBuf, StoreError> {
865            let encoded_id = self.encode_id(id)?;
866            let extension = self.strategy.get_extension();
867            let filename = format!("{}.{}", encoded_id, extension);
868            Ok(self.base_path.join(filename))
869        }
870
871        fn encode_id(&self, id: &str) -> Result<String, StoreError> {
872            match self.strategy.filename_encoding {
873                FilenameEncoding::Direct => {
874                    if id
875                        .chars()
876                        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
877                    {
878                        Ok(id.to_string())
879                    } else {
880                        Err(StoreError::FilenameEncoding {
881                            id: id.to_string(),
882                            reason: "ID contains invalid characters for Direct encoding. \
883                                 Only alphanumeric, '-', and '_' are allowed."
884                                .to_string(),
885                        })
886                    }
887                }
888                FilenameEncoding::UrlEncode => Ok(urlencoding::encode(id).into_owned()),
889                FilenameEncoding::Base64 => Ok(URL_SAFE_NO_PAD.encode(id.as_bytes())),
890            }
891        }
892
893        fn decode_id(&self, filename_stem: &str) -> Result<String, StoreError> {
894            match self.strategy.filename_encoding {
895                FilenameEncoding::Direct => Ok(filename_stem.to_string()),
896                FilenameEncoding::UrlEncode => urlencoding::decode(filename_stem)
897                    .map(|s| s.into_owned())
898                    .map_err(|e| StoreError::FilenameEncoding {
899                        id: filename_stem.to_string(),
900                        reason: format!("Failed to URL-decode filename: {}", e),
901                    }),
902                FilenameEncoding::Base64 => URL_SAFE_NO_PAD
903                    .decode(filename_stem.as_bytes())
904                    .map_err(|e| StoreError::FilenameEncoding {
905                        id: filename_stem.to_string(),
906                        reason: format!("Failed to Base64-decode filename: {}", e),
907                    })
908                    .and_then(|bytes| {
909                        String::from_utf8(bytes).map_err(|e| StoreError::FilenameEncoding {
910                            id: filename_stem.to_string(),
911                            reason: format!(
912                                "Failed to convert Base64-decoded bytes to UTF-8: {}",
913                                e
914                            ),
915                        })
916                    }),
917            }
918        }
919
920        fn path_to_id(&self, path: &Path) -> Result<Option<String>, StoreError> {
921            let file_stem = match path.file_stem() {
922                Some(stem) => stem.to_string_lossy(),
923                None => return Ok(None),
924            };
925            let id = self.decode_id(&file_stem)?;
926            Ok(Some(id))
927        }
928
929        async fn atomic_write(&self, path: &Path, content: &str) -> Result<(), StoreError> {
930            if let Some(parent) = path.parent() {
931                if !tokio::fs::try_exists(parent).await.unwrap_or(false) {
932                    tokio::fs::create_dir_all(parent)
933                        .await
934                        .map_err(|e| StoreError::IoError {
935                            operation: IoOperationKind::CreateDir,
936                            path: parent.display().to_string(),
937                            context: Some("parent directory (async)".to_string()),
938                            error: e.to_string(),
939                        })?;
940                }
941            }
942
943            let tmp_path = atomic_io::get_temp_path(path)?;
944
945            let mut tmp_file =
946                tokio::fs::File::create(&tmp_path)
947                    .await
948                    .map_err(|e| StoreError::IoError {
949                        operation: IoOperationKind::Create,
950                        path: tmp_path.display().to_string(),
951                        context: Some("temporary file (async)".to_string()),
952                        error: e.to_string(),
953                    })?;
954
955            tmp_file
956                .write_all(content.as_bytes())
957                .await
958                .map_err(|e| StoreError::IoError {
959                    operation: IoOperationKind::Write,
960                    path: tmp_path.display().to_string(),
961                    context: Some("temporary file (async)".to_string()),
962                    error: e.to_string(),
963                })?;
964
965            tmp_file.sync_all().await.map_err(|e| StoreError::IoError {
966                operation: IoOperationKind::Sync,
967                path: tmp_path.display().to_string(),
968                context: Some("temporary file (async)".to_string()),
969                error: e.to_string(),
970            })?;
971
972            drop(tmp_file);
973
974            atomic_io::async_io::atomic_rename(
975                &tmp_path,
976                path,
977                self.strategy.atomic_write.retry_count,
978            )
979            .await?;
980
981            if self.strategy.atomic_write.cleanup_tmp_files {
982                let _ = atomic_io::async_io::cleanup_temp_files(path).await;
983            }
984
985            Ok(())
986        }
987    }
988
989    // =========================================================================
990    // Async tests
991    // =========================================================================
992
993    #[cfg(test)]
994    mod tests {
995        use super::*;
996        use crate::{AppPaths, PathStrategy};
997        use tempfile::TempDir;
998
999        fn make_paths(dir: &TempDir) -> AppPaths {
1000            AppPaths::new("test-app")
1001                .data_strategy(PathStrategy::CustomBase(dir.path().to_path_buf()))
1002        }
1003
1004        /// T1: new creates the storage directory.
1005        #[tokio::test]
1006        async fn test_async_new_creates_directory() {
1007            let tmp = TempDir::new().unwrap();
1008            let paths = make_paths(&tmp);
1009            let storage = AsyncDirStorage::new(paths, "sessions", DirStorageStrategy::default())
1010                .await
1011                .expect("AsyncDirStorage::new should succeed");
1012            assert!(
1013                storage.base_path().exists(),
1014                "base_path should be created by new"
1015            );
1016        }
1017
1018        /// T1: save_raw_string + load_raw_string round-trip.
1019        #[tokio::test]
1020        async fn test_async_save_and_load_raw_string() {
1021            let tmp = TempDir::new().unwrap();
1022            let paths = make_paths(&tmp);
1023            let storage = AsyncDirStorage::new(paths, "items", DirStorageStrategy::default())
1024                .await
1025                .unwrap();
1026
1027            storage
1028                .save_raw_string("item", "item-1", r#"{"value":42}"#)
1029                .await
1030                .expect("save_raw_string should succeed");
1031
1032            let content = storage
1033                .load_raw_string("item-1")
1034                .await
1035                .expect("load_raw_string should succeed");
1036            assert_eq!(content, r#"{"value":42}"#);
1037        }
1038
1039        /// T2: load_raw_string on missing id returns IoError.
1040        #[tokio::test]
1041        async fn test_async_load_missing_id_returns_error() {
1042            let tmp = TempDir::new().unwrap();
1043            let paths = make_paths(&tmp);
1044            let storage = AsyncDirStorage::new(paths, "items", DirStorageStrategy::default())
1045                .await
1046                .unwrap();
1047
1048            let result = storage.load_raw_string("nonexistent").await;
1049            assert!(result.is_err(), "loading missing id should return Err");
1050        }
1051
1052        /// T3: delete is idempotent — deleting missing id returns Ok(()).
1053        #[tokio::test]
1054        async fn test_async_delete_idempotent() {
1055            let tmp = TempDir::new().unwrap();
1056            let paths = make_paths(&tmp);
1057            let storage = AsyncDirStorage::new(paths, "items", DirStorageStrategy::default())
1058                .await
1059                .unwrap();
1060
1061            // Should not fail even though the file does not exist.
1062            storage
1063                .delete("no-such-id")
1064                .await
1065                .expect("delete of missing id should be Ok(())");
1066        }
1067    }
1068}
1069
1070// ============================================================================
1071// Sync tests
1072// ============================================================================
1073
1074#[cfg(test)]
1075mod tests {
1076    use super::*;
1077    use crate::{AppPaths, PathStrategy};
1078    use tempfile::TempDir;
1079
1080    fn make_paths(dir: &TempDir) -> AppPaths {
1081        AppPaths::new("test-app").data_strategy(PathStrategy::CustomBase(dir.path().to_path_buf()))
1082    }
1083
1084    // ---- T1: happy path --------------------------------------------------
1085
1086    /// T1-a: DirStorage::new resolves base_path and creates the directory.
1087    #[test]
1088    fn test_new_creates_directory() {
1089        let tmp = TempDir::new().unwrap();
1090        let paths = make_paths(&tmp);
1091        let storage =
1092            DirStorage::new(paths, "sessions", DirStorageStrategy::default()).expect("new ok");
1093        assert!(storage.base_path().exists(), "base_path should be created");
1094        assert!(storage.base_path().is_dir());
1095    }
1096
1097    /// T1-b: save_raw_string followed by load_raw_string yields the same string.
1098    #[test]
1099    fn test_save_and_load_raw_string_roundtrip() {
1100        let tmp = TempDir::new().unwrap();
1101        let paths = make_paths(&tmp);
1102        let storage =
1103            DirStorage::new(paths, "items", DirStorageStrategy::default()).expect("new ok");
1104
1105        storage
1106            .save_raw_string("item", "item-1", r#"{"value":99}"#)
1107            .expect("save ok");
1108        let content = storage.load_raw_string("item-1").expect("load ok");
1109        assert_eq!(content, r#"{"value":99}"#);
1110    }
1111
1112    /// T1-c: list_ids returns all stored IDs and excludes tmp files.
1113    #[test]
1114    fn test_list_ids_excludes_tmp_files() {
1115        let tmp = TempDir::new().unwrap();
1116        let paths = make_paths(&tmp);
1117        let storage =
1118            DirStorage::new(paths, "items", DirStorageStrategy::default()).expect("new ok");
1119
1120        storage.save_raw_string("x", "alpha", "a").expect("save ok");
1121        storage.save_raw_string("x", "beta", "b").expect("save ok");
1122
1123        // Manually drop a spurious .tmp file in the directory — should not appear.
1124        let tmp_file = storage.base_path().join(".alpha.json.tmp.99999");
1125        std::fs::write(&tmp_file, "garbage").unwrap();
1126
1127        let ids = storage.list_ids().expect("list ok");
1128        assert_eq!(ids, vec!["alpha".to_string(), "beta".to_string()]);
1129    }
1130
1131    /// T1-d: exists returns true for a stored id and false for an unknown id.
1132    #[test]
1133    fn test_exists_reflects_storage_state() {
1134        let tmp = TempDir::new().unwrap();
1135        let paths = make_paths(&tmp);
1136        let storage =
1137            DirStorage::new(paths, "items", DirStorageStrategy::default()).expect("new ok");
1138
1139        storage
1140            .save_raw_string("x", "present", "hi")
1141            .expect("save ok");
1142        assert!(storage.exists("present").expect("exists ok"));
1143        assert!(!storage.exists("absent").expect("exists ok"));
1144    }
1145
1146    // ---- T2: boundary / edge cases ---------------------------------------
1147
1148    /// T2-a: empty string id fails Direct encoding.
1149    #[test]
1150    fn test_direct_encoding_empty_id() {
1151        let tmp = TempDir::new().unwrap();
1152        let paths = make_paths(&tmp);
1153        let storage =
1154            DirStorage::new(paths, "items", DirStorageStrategy::default()).expect("new ok");
1155        // Empty string passes the character check (vacuously true) — should succeed.
1156        let result = storage.save_raw_string("x", "", "content");
1157        // Empty id encodes to "." which is still a legal path component; behaviour
1158        // is documented as Direct: all-alphanumeric constraint (empty vacuously ok).
1159        // We just verify it does not panic.
1160        let _ = result;
1161    }
1162
1163    /// T2-b: Direct encoding rejects an id with a slash.
1164    #[test]
1165    fn test_direct_encoding_rejects_slash() {
1166        let tmp = TempDir::new().unwrap();
1167        let paths = make_paths(&tmp);
1168        let storage =
1169            DirStorage::new(paths, "items", DirStorageStrategy::default()).expect("new ok");
1170
1171        let err = storage
1172            .save_raw_string("x", "bad/id", "x")
1173            .expect_err("slash in id should fail");
1174        assert!(
1175            matches!(err, StoreError::FilenameEncoding { .. }),
1176            "expected FilenameEncoding error, got: {:?}",
1177            err
1178        );
1179    }
1180
1181    /// T2-c: UrlEncode encoding round-trips an id with special characters.
1182    #[test]
1183    fn test_url_encode_roundtrip() {
1184        let tmp = TempDir::new().unwrap();
1185        let paths = make_paths(&tmp);
1186        let strategy =
1187            DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
1188        let storage = DirStorage::new(paths, "items", strategy).expect("new ok");
1189
1190        let special_id = "user@example.com/session 1";
1191        storage
1192            .save_raw_string("x", special_id, "data")
1193            .expect("save ok");
1194        let ids = storage.list_ids().expect("list ok");
1195        assert_eq!(ids, vec![special_id.to_string()]);
1196    }
1197
1198    /// T2-d: Base64 encoding round-trips an id with special characters.
1199    #[test]
1200    fn test_base64_encode_roundtrip() {
1201        let tmp = TempDir::new().unwrap();
1202        let paths = make_paths(&tmp);
1203        let strategy =
1204            DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
1205        let storage = DirStorage::new(paths, "items", strategy).expect("new ok");
1206
1207        let id = "hello world!";
1208        storage
1209            .save_raw_string("x", id, "base64-content")
1210            .expect("save ok");
1211        let loaded = storage.load_raw_string(id).expect("load ok");
1212        assert_eq!(loaded, "base64-content");
1213    }
1214
1215    // ---- T3: error paths -------------------------------------------------
1216
1217    /// T3-a: load_raw_string on a missing id returns StoreError::IoError.
1218    #[test]
1219    fn test_load_missing_id_returns_error() {
1220        let tmp = TempDir::new().unwrap();
1221        let paths = make_paths(&tmp);
1222        let storage =
1223            DirStorage::new(paths, "items", DirStorageStrategy::default()).expect("new ok");
1224
1225        let result = storage.load_raw_string("nonexistent");
1226        assert!(result.is_err(), "should return Err for missing id");
1227        if let Err(StoreError::IoError {
1228            operation,
1229            context,
1230            error,
1231            ..
1232        }) = result
1233        {
1234            assert_eq!(operation, IoOperationKind::Read);
1235            assert!(context.is_none());
1236            assert!(error.contains("not found") || error.contains("File not found"));
1237        } else {
1238            panic!("expected IoError(Read)");
1239        }
1240    }
1241
1242    /// T3-b: delete is idempotent — deleting a missing id returns Ok(()).
1243    #[test]
1244    fn test_delete_idempotent_missing_id() {
1245        let tmp = TempDir::new().unwrap();
1246        let paths = make_paths(&tmp);
1247        let storage =
1248            DirStorage::new(paths, "items", DirStorageStrategy::default()).expect("new ok");
1249
1250        // Must return Ok(()) without error (R-S2-3 compliance).
1251        storage
1252            .delete("does-not-exist")
1253            .expect("delete of missing id should be Ok(())");
1254    }
1255
1256    /// T3-c: Direct encoding of id with space returns FilenameEncoding error.
1257    #[test]
1258    fn test_direct_encoding_error_on_space() {
1259        let tmp = TempDir::new().unwrap();
1260        let paths = make_paths(&tmp);
1261        let storage =
1262            DirStorage::new(paths, "items", DirStorageStrategy::default()).expect("new ok");
1263
1264        let err = storage
1265            .save_raw_string("x", "has space", "x")
1266            .expect_err("space in id should fail Direct encoding");
1267        assert!(
1268            matches!(err, StoreError::FilenameEncoding { .. }),
1269            "expected FilenameEncoding, got {:?}",
1270            err
1271        );
1272    }
1273}