1use 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
24pub use crate::storage::{AtomicWriteConfig, FormatStrategy};
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum FilenameEncoding {
37 #[default]
43 Direct,
44 UrlEncode,
47 Base64,
49}
50
51#[derive(Debug, Clone)]
53pub struct DirStorageStrategy {
54 pub format: FormatStrategy,
56 pub atomic_write: AtomicWriteConfig,
58 pub extension: Option<String>,
61 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 pub fn new() -> Self {
79 Self::default()
80 }
81
82 pub fn with_format(mut self, format: FormatStrategy) -> Self {
92 self.format = format;
93 self
94 }
95
96 pub fn with_extension(mut self, ext: impl Into<String>) -> Self {
106 self.extension = Some(ext.into());
107 self
108 }
109
110 pub fn with_filename_encoding(mut self, encoding: FilenameEncoding) -> Self {
120 self.filename_encoding = encoding;
121 self
122 }
123
124 pub fn with_retry_count(mut self, count: usize) -> Self {
134 self.atomic_write.retry_count = count;
135 self
136 }
137
138 pub fn with_cleanup(mut self, cleanup: bool) -> Self {
149 self.atomic_write.cleanup_tmp_files = cleanup;
150 self
151 }
152
153 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
165pub struct DirStorage {
181 base_path: PathBuf,
183 strategy: DirStorageStrategy,
185}
186
187impl DirStorage {
188 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 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 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 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 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 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 pub fn base_path(&self) -> &Path {
406 &self.base_path
407 }
408
409 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 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 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 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 fn atomic_write(&self, path: &Path, content: &str) -> Result<(), StoreError> {
528 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#[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 pub struct AsyncDirStorage {
607 base_path: PathBuf,
609 strategy: DirStorageStrategy,
611 }
612
613 impl AsyncDirStorage {
614 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 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 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 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 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 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 pub fn base_path(&self) -> &Path {
857 &self.base_path
858 }
859
860 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 #[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 #[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 #[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 #[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 #[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 storage
1063 .delete("no-such-id")
1064 .await
1065 .expect("delete of missing id should be Ok(())");
1066 }
1067 }
1068}
1069
1070#[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 #[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 #[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 #[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 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 #[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 #[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 let result = storage.save_raw_string("x", "", "content");
1157 let _ = result;
1161 }
1162
1163 #[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 #[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 #[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 #[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 #[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 storage
1252 .delete("does-not-exist")
1253 .expect("delete of missing id should be Ok(())");
1254 }
1255
1256 #[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}