Skip to main content

local_store/
dir_storage.rs

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