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)]
46#[non_exhaustive]
47pub enum FilenameEncoding {
48 #[default]
54 Direct,
55 UrlEncode,
58 Base64,
60}
61
62#[derive(Debug, Clone)]
64pub struct DirStorageStrategy {
65 pub format: FormatStrategy,
67 pub atomic_write: AtomicWriteConfig,
69 pub extension: Option<String>,
72 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 pub fn new() -> Self {
90 Self::default()
91 }
92
93 pub fn with_format(mut self, format: FormatStrategy) -> Self {
103 self.format = format;
104 self
105 }
106
107 pub fn with_extension(mut self, ext: impl Into<String>) -> Self {
117 self.extension = Some(ext.into());
118 self
119 }
120
121 pub fn with_filename_encoding(mut self, encoding: FilenameEncoding) -> Self {
131 self.filename_encoding = encoding;
132 self
133 }
134
135 pub fn with_retry_count(mut self, count: usize) -> Self {
145 self.atomic_write.retry_count = count;
146 self
147 }
148
149 pub fn with_cleanup(mut self, cleanup: bool) -> Self {
160 self.atomic_write.cleanup_tmp_files = cleanup;
161 self
162 }
163
164 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
176pub struct DirStorage {
192 base_path: PathBuf,
194 strategy: DirStorageStrategy,
196}
197
198impl DirStorage {
199 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 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 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 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 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 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 pub fn base_path(&self) -> &Path {
417 &self.base_path
418 }
419
420 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 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 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 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 fn atomic_write(&self, path: &Path, content: &str) -> Result<(), StoreError> {
548 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#[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 pub struct AsyncDirStorage {
629 base_path: PathBuf,
631 strategy: DirStorageStrategy,
633 }
634
635 impl AsyncDirStorage {
636 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 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 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 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 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 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 pub fn base_path(&self) -> &Path {
879 &self.base_path
880 }
881
882 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 #[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 #[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 #[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 #[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 #[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 storage
1087 .delete("no-such-id")
1088 .await
1089 .expect("delete of missing id should be Ok(())");
1090 }
1091 }
1092}
1093
1094#[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 storage
1285 .delete("does-not-exist")
1286 .expect("delete of missing id should be Ok(())");
1287 }
1288
1289 #[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}