use crate::{
atomic_io,
errors::{IoOperationKind, StoreError},
AppPaths,
};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use std::fs::{self, File};
use std::io::Write as IoWrite;
use std::path::{Path, PathBuf};
pub use crate::storage::{AtomicWriteConfig, FormatStrategy};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FilenameEncoding {
#[default]
Direct,
UrlEncode,
Base64,
}
#[derive(Debug, Clone)]
pub struct DirStorageStrategy {
pub format: FormatStrategy,
pub atomic_write: AtomicWriteConfig,
pub extension: Option<String>,
pub filename_encoding: FilenameEncoding,
}
impl Default for DirStorageStrategy {
fn default() -> Self {
Self {
format: FormatStrategy::Json,
atomic_write: AtomicWriteConfig::default(),
extension: None,
filename_encoding: FilenameEncoding::default(),
}
}
}
impl DirStorageStrategy {
pub fn new() -> Self {
Self::default()
}
pub fn with_format(mut self, format: FormatStrategy) -> Self {
self.format = format;
self
}
pub fn with_extension(mut self, ext: impl Into<String>) -> Self {
self.extension = Some(ext.into());
self
}
pub fn with_filename_encoding(mut self, encoding: FilenameEncoding) -> Self {
self.filename_encoding = encoding;
self
}
pub fn with_retry_count(mut self, count: usize) -> Self {
self.atomic_write.retry_count = count;
self
}
pub fn with_cleanup(mut self, cleanup: bool) -> Self {
self.atomic_write.cleanup_tmp_files = cleanup;
self
}
pub fn get_extension(&self) -> String {
self.extension.clone().unwrap_or_else(|| match self.format {
FormatStrategy::Json => "json".to_string(),
FormatStrategy::Toml => "toml".to_string(),
})
}
}
pub struct DirStorage {
base_path: PathBuf,
strategy: DirStorageStrategy,
}
impl DirStorage {
pub fn new(
paths: AppPaths,
category: impl Into<String>,
strategy: DirStorageStrategy,
) -> Result<Self, StoreError> {
let category: String = category.into();
let base_path = paths.data_dir()?.join(&category);
if !base_path.exists() {
fs::create_dir_all(&base_path).map_err(|e| StoreError::IoError {
operation: IoOperationKind::CreateDir,
path: base_path.display().to_string(),
context: Some("storage base directory".to_string()),
error: e.to_string(),
})?;
}
Ok(Self {
base_path,
strategy,
})
}
pub fn save_raw_string(
&self,
_entity_name: impl Into<String>,
id: impl Into<String>,
content: &str,
) -> Result<(), StoreError> {
let id: String = id.into();
let file_path = self.id_to_path(&id)?;
self.atomic_write(&file_path, content)?;
Ok(())
}
pub fn load_raw_string(&self, id: impl Into<String>) -> Result<String, StoreError> {
let id: String = id.into();
let file_path = self.id_to_path(&id)?;
if !file_path.exists() {
return Err(StoreError::IoError {
operation: IoOperationKind::Read,
path: file_path.display().to_string(),
context: None,
error: "File not found".to_string(),
});
}
fs::read_to_string(&file_path).map_err(|e| StoreError::IoError {
operation: IoOperationKind::Read,
path: file_path.display().to_string(),
context: None,
error: e.to_string(),
})
}
pub fn list_ids(&self) -> Result<Vec<String>, StoreError> {
let entries = fs::read_dir(&self.base_path).map_err(|e| StoreError::IoError {
operation: IoOperationKind::ReadDir,
path: self.base_path.display().to_string(),
context: None,
error: e.to_string(),
})?;
let extension = self.strategy.get_extension();
let mut ids = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| StoreError::IoError {
operation: IoOperationKind::ReadDir,
path: self.base_path.display().to_string(),
context: Some("directory entry".to_string()),
error: e.to_string(),
})?;
let path = entry.path();
if path.is_file() {
if let Some(ext) = path.extension() {
if ext == extension.as_str() {
if let Some(id) = self.path_to_id(&path)? {
ids.push(id);
}
}
}
}
}
ids.sort();
Ok(ids)
}
pub fn exists(&self, id: impl Into<String>) -> Result<bool, StoreError> {
let id: String = id.into();
let file_path = self.id_to_path(&id)?;
Ok(file_path.exists() && file_path.is_file())
}
pub fn delete(&self, id: impl Into<String>) -> Result<(), StoreError> {
let id: String = id.into();
let file_path = self.id_to_path(&id)?;
if file_path.exists() {
fs::remove_file(&file_path).map_err(|e| StoreError::IoError {
operation: IoOperationKind::Delete,
path: file_path.display().to_string(),
context: None,
error: e.to_string(),
})?;
}
Ok(())
}
pub fn base_path(&self) -> &Path {
&self.base_path
}
fn id_to_path(&self, id: &str) -> Result<PathBuf, StoreError> {
let encoded_id = self.encode_id(id)?;
let extension = self.strategy.get_extension();
let filename = format!("{}.{}", encoded_id, extension);
Ok(self.base_path.join(filename))
}
fn encode_id(&self, id: &str) -> Result<String, StoreError> {
match self.strategy.filename_encoding {
FilenameEncoding::Direct => {
if id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
Ok(id.to_string())
} else {
Err(StoreError::FilenameEncoding {
id: id.to_string(),
reason: "ID contains invalid characters for Direct encoding. \
Only alphanumeric, '-', and '_' are allowed."
.to_string(),
})
}
}
FilenameEncoding::UrlEncode => Ok(urlencoding::encode(id).into_owned()),
FilenameEncoding::Base64 => Ok(URL_SAFE_NO_PAD.encode(id.as_bytes())),
}
}
fn decode_id(&self, filename_stem: &str) -> Result<String, StoreError> {
match self.strategy.filename_encoding {
FilenameEncoding::Direct => Ok(filename_stem.to_string()),
FilenameEncoding::UrlEncode => urlencoding::decode(filename_stem)
.map(|s| s.into_owned())
.map_err(|e| StoreError::FilenameEncoding {
id: filename_stem.to_string(),
reason: format!("Failed to URL-decode filename: {}", e),
}),
FilenameEncoding::Base64 => URL_SAFE_NO_PAD
.decode(filename_stem.as_bytes())
.map_err(|e| StoreError::FilenameEncoding {
id: filename_stem.to_string(),
reason: format!("Failed to Base64-decode filename: {}", e),
})
.and_then(|bytes| {
String::from_utf8(bytes).map_err(|e| StoreError::FilenameEncoding {
id: filename_stem.to_string(),
reason: format!("Failed to convert Base64-decoded bytes to UTF-8: {}", e),
})
}),
}
}
fn path_to_id(&self, path: &Path) -> Result<Option<String>, StoreError> {
let file_stem = match path.file_stem() {
Some(stem) => stem.to_string_lossy(),
None => return Ok(None),
};
let id = self.decode_id(&file_stem)?;
Ok(Some(id))
}
fn atomic_write(&self, path: &Path, content: &str) -> Result<(), StoreError> {
if let Some(parent) = path.parent() {
if !parent.exists() {
fs::create_dir_all(parent).map_err(|e| StoreError::IoError {
operation: IoOperationKind::CreateDir,
path: parent.display().to_string(),
context: Some("parent directory".to_string()),
error: e.to_string(),
})?;
}
}
let tmp_path = atomic_io::get_temp_path(path)?;
let mut tmp_file = File::create(&tmp_path).map_err(|e| StoreError::IoError {
operation: IoOperationKind::Create,
path: tmp_path.display().to_string(),
context: Some("temporary file".to_string()),
error: e.to_string(),
})?;
tmp_file
.write_all(content.as_bytes())
.map_err(|e| StoreError::IoError {
operation: IoOperationKind::Write,
path: tmp_path.display().to_string(),
context: Some("temporary file".to_string()),
error: e.to_string(),
})?;
tmp_file.sync_all().map_err(|e| StoreError::IoError {
operation: IoOperationKind::Sync,
path: tmp_path.display().to_string(),
context: Some("temporary file".to_string()),
error: e.to_string(),
})?;
drop(tmp_file);
atomic_io::atomic_rename(&tmp_path, path, self.strategy.atomic_write.retry_count)?;
if self.strategy.atomic_write.cleanup_tmp_files {
let _ = atomic_io::cleanup_temp_files(path);
}
Ok(())
}
}
#[cfg(feature = "async")]
pub use async_impl::AsyncDirStorage;
#[cfg(feature = "async")]
mod async_impl {
use super::{DirStorageStrategy, FilenameEncoding};
use crate::{
atomic_io,
errors::{IoOperationKind, StoreError},
AppPaths,
};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use std::path::{Path, PathBuf};
use tokio::io::AsyncWriteExt;
pub struct AsyncDirStorage {
base_path: PathBuf,
strategy: DirStorageStrategy,
}
impl AsyncDirStorage {
pub async fn new(
paths: AppPaths,
category: impl Into<String>,
strategy: DirStorageStrategy,
) -> Result<Self, StoreError> {
let category: String = category.into();
let base_path = paths.data_dir()?.join(&category);
if !tokio::fs::try_exists(&base_path).await.unwrap_or(false) {
tokio::fs::create_dir_all(&base_path)
.await
.map_err(|e| StoreError::IoError {
operation: IoOperationKind::CreateDir,
path: base_path.display().to_string(),
context: Some("storage base directory (async)".to_string()),
error: e.to_string(),
})?;
}
Ok(Self {
base_path,
strategy,
})
}
pub async fn save_raw_string(
&self,
_entity_name: impl Into<String>,
id: impl Into<String>,
content: &str,
) -> Result<(), StoreError> {
let id: String = id.into();
let file_path = self.id_to_path(&id)?;
self.atomic_write(&file_path, content).await?;
Ok(())
}
pub async fn load_raw_string(&self, id: impl Into<String>) -> Result<String, StoreError> {
let id: String = id.into();
let file_path = self.id_to_path(&id)?;
if !tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
return Err(StoreError::IoError {
operation: IoOperationKind::Read,
path: file_path.display().to_string(),
context: None,
error: "File not found".to_string(),
});
}
tokio::fs::read_to_string(&file_path)
.await
.map_err(|e| StoreError::IoError {
operation: IoOperationKind::Read,
path: file_path.display().to_string(),
context: None,
error: e.to_string(),
})
}
pub async fn list_ids(&self) -> Result<Vec<String>, StoreError> {
let mut entries =
tokio::fs::read_dir(&self.base_path)
.await
.map_err(|e| StoreError::IoError {
operation: IoOperationKind::ReadDir,
path: self.base_path.display().to_string(),
context: None,
error: e.to_string(),
})?;
let extension = self.strategy.get_extension();
let mut ids = Vec::new();
while let Some(entry) = entries
.next_entry()
.await
.map_err(|e| StoreError::IoError {
operation: IoOperationKind::ReadDir,
path: self.base_path.display().to_string(),
context: Some("directory entry (async)".to_string()),
error: e.to_string(),
})?
{
let path = entry.path();
let metadata =
tokio::fs::metadata(&path)
.await
.map_err(|e| StoreError::IoError {
operation: IoOperationKind::Read,
path: path.display().to_string(),
context: Some("metadata (async)".to_string()),
error: e.to_string(),
})?;
if metadata.is_file() {
if let Some(ext) = path.extension() {
if ext == extension.as_str() {
if let Some(id) = self.path_to_id(&path)? {
ids.push(id);
}
}
}
}
}
ids.sort();
Ok(ids)
}
pub async fn exists(&self, id: impl Into<String>) -> Result<bool, StoreError> {
let id: String = id.into();
let file_path = self.id_to_path(&id)?;
if !tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
return Ok(false);
}
let metadata =
tokio::fs::metadata(&file_path)
.await
.map_err(|e| StoreError::IoError {
operation: IoOperationKind::Read,
path: file_path.display().to_string(),
context: Some("metadata (async)".to_string()),
error: e.to_string(),
})?;
Ok(metadata.is_file())
}
pub async fn delete(&self, id: impl Into<String>) -> Result<(), StoreError> {
let id: String = id.into();
let file_path = self.id_to_path(&id)?;
if tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
tokio::fs::remove_file(&file_path)
.await
.map_err(|e| StoreError::IoError {
operation: IoOperationKind::Delete,
path: file_path.display().to_string(),
context: None,
error: e.to_string(),
})?;
}
Ok(())
}
pub fn base_path(&self) -> &Path {
&self.base_path
}
fn id_to_path(&self, id: &str) -> Result<PathBuf, StoreError> {
let encoded_id = self.encode_id(id)?;
let extension = self.strategy.get_extension();
let filename = format!("{}.{}", encoded_id, extension);
Ok(self.base_path.join(filename))
}
fn encode_id(&self, id: &str) -> Result<String, StoreError> {
match self.strategy.filename_encoding {
FilenameEncoding::Direct => {
if id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
Ok(id.to_string())
} else {
Err(StoreError::FilenameEncoding {
id: id.to_string(),
reason: "ID contains invalid characters for Direct encoding. \
Only alphanumeric, '-', and '_' are allowed."
.to_string(),
})
}
}
FilenameEncoding::UrlEncode => Ok(urlencoding::encode(id).into_owned()),
FilenameEncoding::Base64 => Ok(URL_SAFE_NO_PAD.encode(id.as_bytes())),
}
}
fn decode_id(&self, filename_stem: &str) -> Result<String, StoreError> {
match self.strategy.filename_encoding {
FilenameEncoding::Direct => Ok(filename_stem.to_string()),
FilenameEncoding::UrlEncode => urlencoding::decode(filename_stem)
.map(|s| s.into_owned())
.map_err(|e| StoreError::FilenameEncoding {
id: filename_stem.to_string(),
reason: format!("Failed to URL-decode filename: {}", e),
}),
FilenameEncoding::Base64 => URL_SAFE_NO_PAD
.decode(filename_stem.as_bytes())
.map_err(|e| StoreError::FilenameEncoding {
id: filename_stem.to_string(),
reason: format!("Failed to Base64-decode filename: {}", e),
})
.and_then(|bytes| {
String::from_utf8(bytes).map_err(|e| StoreError::FilenameEncoding {
id: filename_stem.to_string(),
reason: format!(
"Failed to convert Base64-decoded bytes to UTF-8: {}",
e
),
})
}),
}
}
fn path_to_id(&self, path: &Path) -> Result<Option<String>, StoreError> {
let file_stem = match path.file_stem() {
Some(stem) => stem.to_string_lossy(),
None => return Ok(None),
};
let id = self.decode_id(&file_stem)?;
Ok(Some(id))
}
async fn atomic_write(&self, path: &Path, content: &str) -> Result<(), StoreError> {
if let Some(parent) = path.parent() {
if !tokio::fs::try_exists(parent).await.unwrap_or(false) {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| StoreError::IoError {
operation: IoOperationKind::CreateDir,
path: parent.display().to_string(),
context: Some("parent directory (async)".to_string()),
error: e.to_string(),
})?;
}
}
let tmp_path = atomic_io::get_temp_path(path)?;
let mut tmp_file =
tokio::fs::File::create(&tmp_path)
.await
.map_err(|e| StoreError::IoError {
operation: IoOperationKind::Create,
path: tmp_path.display().to_string(),
context: Some("temporary file (async)".to_string()),
error: e.to_string(),
})?;
tmp_file
.write_all(content.as_bytes())
.await
.map_err(|e| StoreError::IoError {
operation: IoOperationKind::Write,
path: tmp_path.display().to_string(),
context: Some("temporary file (async)".to_string()),
error: e.to_string(),
})?;
tmp_file.sync_all().await.map_err(|e| StoreError::IoError {
operation: IoOperationKind::Sync,
path: tmp_path.display().to_string(),
context: Some("temporary file (async)".to_string()),
error: e.to_string(),
})?;
drop(tmp_file);
atomic_io::async_io::atomic_rename(
&tmp_path,
path,
self.strategy.atomic_write.retry_count,
)
.await?;
if self.strategy.atomic_write.cleanup_tmp_files {
let _ = atomic_io::async_io::cleanup_temp_files(path).await;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{AppPaths, PathStrategy};
use tempfile::TempDir;
fn make_paths(dir: &TempDir) -> AppPaths {
AppPaths::new("test-app")
.data_strategy(PathStrategy::CustomBase(dir.path().to_path_buf()))
}
#[tokio::test]
async fn test_async_new_creates_directory() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(&tmp);
let storage = AsyncDirStorage::new(paths, "sessions", DirStorageStrategy::default())
.await
.expect("AsyncDirStorage::new should succeed");
assert!(
storage.base_path().exists(),
"base_path should be created by new"
);
}
#[tokio::test]
async fn test_async_save_and_load_raw_string() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(&tmp);
let storage = AsyncDirStorage::new(paths, "items", DirStorageStrategy::default())
.await
.unwrap();
storage
.save_raw_string("item", "item-1", r#"{"value":42}"#)
.await
.expect("save_raw_string should succeed");
let content = storage
.load_raw_string("item-1")
.await
.expect("load_raw_string should succeed");
assert_eq!(content, r#"{"value":42}"#);
}
#[tokio::test]
async fn test_async_load_missing_id_returns_error() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(&tmp);
let storage = AsyncDirStorage::new(paths, "items", DirStorageStrategy::default())
.await
.unwrap();
let result = storage.load_raw_string("nonexistent").await;
assert!(result.is_err(), "loading missing id should return Err");
}
#[tokio::test]
async fn test_async_delete_idempotent() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(&tmp);
let storage = AsyncDirStorage::new(paths, "items", DirStorageStrategy::default())
.await
.unwrap();
storage
.delete("no-such-id")
.await
.expect("delete of missing id should be Ok(())");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{AppPaths, PathStrategy};
use tempfile::TempDir;
fn make_paths(dir: &TempDir) -> AppPaths {
AppPaths::new("test-app").data_strategy(PathStrategy::CustomBase(dir.path().to_path_buf()))
}
#[test]
fn test_new_creates_directory() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(&tmp);
let storage =
DirStorage::new(paths, "sessions", DirStorageStrategy::default()).expect("new ok");
assert!(storage.base_path().exists(), "base_path should be created");
assert!(storage.base_path().is_dir());
}
#[test]
fn test_save_and_load_raw_string_roundtrip() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(&tmp);
let storage =
DirStorage::new(paths, "items", DirStorageStrategy::default()).expect("new ok");
storage
.save_raw_string("item", "item-1", r#"{"value":99}"#)
.expect("save ok");
let content = storage.load_raw_string("item-1").expect("load ok");
assert_eq!(content, r#"{"value":99}"#);
}
#[test]
fn test_list_ids_excludes_tmp_files() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(&tmp);
let storage =
DirStorage::new(paths, "items", DirStorageStrategy::default()).expect("new ok");
storage.save_raw_string("x", "alpha", "a").expect("save ok");
storage.save_raw_string("x", "beta", "b").expect("save ok");
let tmp_file = storage.base_path().join(".alpha.json.tmp.99999");
std::fs::write(&tmp_file, "garbage").unwrap();
let ids = storage.list_ids().expect("list ok");
assert_eq!(ids, vec!["alpha".to_string(), "beta".to_string()]);
}
#[test]
fn test_exists_reflects_storage_state() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(&tmp);
let storage =
DirStorage::new(paths, "items", DirStorageStrategy::default()).expect("new ok");
storage
.save_raw_string("x", "present", "hi")
.expect("save ok");
assert!(storage.exists("present").expect("exists ok"));
assert!(!storage.exists("absent").expect("exists ok"));
}
#[test]
fn test_direct_encoding_empty_id() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(&tmp);
let storage =
DirStorage::new(paths, "items", DirStorageStrategy::default()).expect("new ok");
let result = storage.save_raw_string("x", "", "content");
let _ = result;
}
#[test]
fn test_direct_encoding_rejects_slash() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(&tmp);
let storage =
DirStorage::new(paths, "items", DirStorageStrategy::default()).expect("new ok");
let err = storage
.save_raw_string("x", "bad/id", "x")
.expect_err("slash in id should fail");
assert!(
matches!(err, StoreError::FilenameEncoding { .. }),
"expected FilenameEncoding error, got: {:?}",
err
);
}
#[test]
fn test_url_encode_roundtrip() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(&tmp);
let strategy =
DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
let storage = DirStorage::new(paths, "items", strategy).expect("new ok");
let special_id = "user@example.com/session 1";
storage
.save_raw_string("x", special_id, "data")
.expect("save ok");
let ids = storage.list_ids().expect("list ok");
assert_eq!(ids, vec![special_id.to_string()]);
}
#[test]
fn test_base64_encode_roundtrip() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(&tmp);
let strategy =
DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
let storage = DirStorage::new(paths, "items", strategy).expect("new ok");
let id = "hello world!";
storage
.save_raw_string("x", id, "base64-content")
.expect("save ok");
let loaded = storage.load_raw_string(id).expect("load ok");
assert_eq!(loaded, "base64-content");
}
#[test]
fn test_load_missing_id_returns_error() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(&tmp);
let storage =
DirStorage::new(paths, "items", DirStorageStrategy::default()).expect("new ok");
let result = storage.load_raw_string("nonexistent");
assert!(result.is_err(), "should return Err for missing id");
if let Err(StoreError::IoError {
operation,
context,
error,
..
}) = result
{
assert_eq!(operation, IoOperationKind::Read);
assert!(context.is_none());
assert!(error.contains("not found") || error.contains("File not found"));
} else {
panic!("expected IoError(Read)");
}
}
#[test]
fn test_delete_idempotent_missing_id() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(&tmp);
let storage =
DirStorage::new(paths, "items", DirStorageStrategy::default()).expect("new ok");
storage
.delete("does-not-exist")
.expect("delete of missing id should be Ok(())");
}
#[test]
fn test_direct_encoding_error_on_space() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(&tmp);
let storage =
DirStorage::new(paths, "items", DirStorageStrategy::default()).expect("new ok");
let err = storage
.save_raw_string("x", "has space", "x")
.expect_err("space in id should fail Direct encoding");
assert!(
matches!(err, StoreError::FilenameEncoding { .. }),
"expected FilenameEncoding, got {:?}",
err
);
}
}