1use crate::{AppPaths, MigrationError, Migrator};
42use base64::engine::general_purpose::URL_SAFE_NO_PAD;
43use base64::Engine;
44use std::fs::{self, File};
45use std::io::Write;
46use std::path::{Path, PathBuf};
47
48pub use crate::storage::{AtomicWriteConfig, FormatStrategy};
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum FilenameEncoding {
56 Direct,
58 UrlEncode,
60 Base64,
62}
63
64impl Default for FilenameEncoding {
65 fn default() -> Self {
66 Self::Direct
67 }
68}
69
70#[derive(Debug, Clone)]
72pub struct DirStorageStrategy {
73 pub format: FormatStrategy,
75 pub atomic_write: AtomicWriteConfig,
77 pub extension: Option<String>,
79 pub filename_encoding: FilenameEncoding,
81}
82
83impl Default for DirStorageStrategy {
84 fn default() -> Self {
85 Self {
86 format: FormatStrategy::Json,
87 atomic_write: AtomicWriteConfig::default(),
88 extension: None,
89 filename_encoding: FilenameEncoding::default(),
90 }
91 }
92}
93
94impl DirStorageStrategy {
95 #[allow(dead_code)]
97 pub fn new() -> Self {
98 Self::default()
99 }
100
101 #[allow(dead_code)]
103 pub fn with_format(mut self, format: FormatStrategy) -> Self {
104 self.format = format;
105 self
106 }
107
108 #[allow(dead_code)]
110 pub fn with_extension(mut self, ext: impl Into<String>) -> Self {
111 self.extension = Some(ext.into());
112 self
113 }
114
115 #[allow(dead_code)]
117 pub fn with_filename_encoding(mut self, encoding: FilenameEncoding) -> Self {
118 self.filename_encoding = encoding;
119 self
120 }
121
122 #[allow(dead_code)]
124 pub fn with_retry_count(mut self, count: usize) -> Self {
125 self.atomic_write.retry_count = count;
126 self
127 }
128
129 #[allow(dead_code)]
131 pub fn with_cleanup(mut self, cleanup: bool) -> Self {
132 self.atomic_write.cleanup_tmp_files = cleanup;
133 self
134 }
135
136 fn get_extension(&self) -> String {
138 self.extension.clone().unwrap_or_else(|| match self.format {
139 FormatStrategy::Json => "json".to_string(),
140 FormatStrategy::Toml => "toml".to_string(),
141 })
142 }
143}
144
145pub struct DirStorage {
153 base_path: PathBuf,
155 migrator: Migrator,
157 strategy: DirStorageStrategy,
159}
160
161impl DirStorage {
162 pub fn new(
193 paths: AppPaths,
194 domain_name: &str,
195 migrator: Migrator,
196 strategy: DirStorageStrategy,
197 ) -> Result<Self, MigrationError> {
198 let base_path = paths.data_dir()?.join(domain_name);
200
201 if !base_path.exists() {
203 std::fs::create_dir_all(&base_path).map_err(|e| MigrationError::IoError {
204 path: base_path.display().to_string(),
205 error: e.to_string(),
206 })?;
207 }
208
209 Ok(Self {
210 base_path,
211 migrator,
212 strategy,
213 })
214 }
215
216 pub fn save<T>(&self, entity_name: &str, id: &str, entity: T) -> Result<(), MigrationError>
248 where
249 T: serde::Serialize,
250 {
251 let json_string = self.migrator.save_domain_flat(entity_name, entity)?;
253
254 let versioned_value: serde_json::Value = serde_json::from_str(&json_string)
256 .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
257
258 let content = self.serialize_content(&versioned_value)?;
260
261 let file_path = self.id_to_path(id)?;
263
264 self.atomic_write(&file_path, &content)?;
266
267 Ok(())
268 }
269
270 fn id_to_path(&self, id: &str) -> Result<PathBuf, MigrationError> {
287 let encoded_id = self.encode_id(id)?;
288 let extension = self.strategy.get_extension();
289 let filename = format!("{}.{}", encoded_id, extension);
290 Ok(self.base_path.join(filename))
291 }
292
293 fn encode_id(&self, id: &str) -> Result<String, MigrationError> {
311 match self.strategy.filename_encoding {
312 FilenameEncoding::Direct => {
313 if id
315 .chars()
316 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
317 {
318 Ok(id.to_string())
319 } else {
320 Err(MigrationError::FilenameEncoding {
321 id: id.to_string(),
322 reason: "ID contains invalid characters for Direct encoding. Only alphanumeric, '-', and '_' are allowed.".to_string(),
323 })
324 }
325 }
326 FilenameEncoding::UrlEncode => {
327 Ok(urlencoding::encode(id).into_owned())
329 }
330 FilenameEncoding::Base64 => {
331 Ok(URL_SAFE_NO_PAD.encode(id.as_bytes()))
333 }
334 }
335 }
336
337 fn serialize_content(&self, value: &serde_json::Value) -> Result<String, MigrationError> {
351 match self.strategy.format {
352 FormatStrategy::Json => serde_json::to_string_pretty(value)
353 .map_err(|e| MigrationError::SerializationError(e.to_string())),
354 FormatStrategy::Toml => {
355 let toml_value = json_to_toml(value)?;
356 toml::to_string_pretty(&toml_value)
357 .map_err(|e| MigrationError::TomlSerializeError(e.to_string()))
358 }
359 }
360 }
361
362 fn atomic_write(&self, path: &Path, content: &str) -> Result<(), MigrationError> {
385 if let Some(parent) = path.parent() {
387 if !parent.exists() {
388 fs::create_dir_all(parent).map_err(|e| MigrationError::IoError {
389 path: parent.display().to_string(),
390 error: e.to_string(),
391 })?;
392 }
393 }
394
395 let tmp_path = self.get_temp_path(path)?;
397
398 let mut tmp_file = File::create(&tmp_path).map_err(|e| MigrationError::IoError {
400 path: tmp_path.display().to_string(),
401 error: e.to_string(),
402 })?;
403
404 tmp_file
405 .write_all(content.as_bytes())
406 .map_err(|e| MigrationError::IoError {
407 path: tmp_path.display().to_string(),
408 error: e.to_string(),
409 })?;
410
411 tmp_file.sync_all().map_err(|e| MigrationError::IoError {
413 path: tmp_path.display().to_string(),
414 error: e.to_string(),
415 })?;
416
417 drop(tmp_file);
418
419 self.atomic_rename(&tmp_path, path)?;
421
422 if self.strategy.atomic_write.cleanup_tmp_files {
424 let _ = self.cleanup_temp_files(path);
425 }
426
427 Ok(())
428 }
429
430 fn get_temp_path(&self, target_path: &Path) -> Result<PathBuf, MigrationError> {
447 let parent = target_path.parent().ok_or_else(|| {
448 MigrationError::PathResolution("Path has no parent directory".to_string())
449 })?;
450
451 let file_name = target_path
452 .file_name()
453 .ok_or_else(|| MigrationError::PathResolution("Path has no file name".to_string()))?;
454
455 let tmp_name = format!(
456 ".{}.tmp.{}",
457 file_name.to_string_lossy(),
458 std::process::id()
459 );
460 Ok(parent.join(tmp_name))
461 }
462
463 fn atomic_rename(&self, tmp_path: &Path, target_path: &Path) -> Result<(), MigrationError> {
477 let mut last_error = None;
478
479 for attempt in 0..self.strategy.atomic_write.retry_count {
480 match fs::rename(tmp_path, target_path) {
481 Ok(()) => return Ok(()),
482 Err(e) => {
483 last_error = Some(e);
484 if attempt + 1 < self.strategy.atomic_write.retry_count {
485 std::thread::sleep(std::time::Duration::from_millis(10));
487 }
488 }
489 }
490 }
491
492 Err(MigrationError::IoError {
493 path: target_path.display().to_string(),
494 error: format!(
495 "Failed to rename after {} attempts: {}",
496 self.strategy.atomic_write.retry_count,
497 last_error.unwrap()
498 ),
499 })
500 }
501
502 fn cleanup_temp_files(&self, target_path: &Path) -> std::io::Result<()> {
511 let parent = match target_path.parent() {
512 Some(p) => p,
513 None => return Ok(()),
514 };
515
516 let file_name = match target_path.file_name() {
517 Some(f) => f.to_string_lossy(),
518 None => return Ok(()),
519 };
520
521 let prefix = format!(".{}.tmp.", file_name);
522
523 if let Ok(entries) = fs::read_dir(parent) {
524 for entry in entries.flatten() {
525 if let Ok(name) = entry.file_name().into_string() {
526 if name.starts_with(&prefix) {
527 let _ = fs::remove_file(entry.path());
529 }
530 }
531 }
532 }
533
534 Ok(())
535 }
536
537 pub fn load<D>(&self, entity_name: &str, id: &str) -> Result<D, MigrationError>
565 where
566 D: serde::de::DeserializeOwned,
567 {
568 let file_path = self.id_to_path(id)?;
570
571 if !file_path.exists() {
573 return Err(MigrationError::IoError {
574 path: file_path.display().to_string(),
575 error: "File not found".to_string(),
576 });
577 }
578
579 let content = fs::read_to_string(&file_path).map_err(|e| MigrationError::IoError {
581 path: file_path.display().to_string(),
582 error: e.to_string(),
583 })?;
584
585 let value = self.deserialize_content(&content)?;
587
588 self.migrator.load_flat_from(entity_name, value)
590 }
591
592 pub fn list_ids(&self) -> Result<Vec<String>, MigrationError> {
613 let entries = fs::read_dir(&self.base_path).map_err(|e| MigrationError::IoError {
615 path: self.base_path.display().to_string(),
616 error: e.to_string(),
617 })?;
618
619 let extension = self.strategy.get_extension();
620 let mut ids = Vec::new();
621
622 for entry in entries {
623 let entry = entry.map_err(|e| MigrationError::IoError {
624 path: self.base_path.display().to_string(),
625 error: e.to_string(),
626 })?;
627
628 let path = entry.path();
629
630 if path.is_file() {
632 if let Some(ext) = path.extension() {
633 if ext == extension.as_str() {
634 if let Some(id) = self.path_to_id(&path)? {
636 ids.push(id);
637 }
638 }
639 }
640 }
641 }
642
643 ids.sort();
645 Ok(ids)
646 }
647
648 pub fn load_all<D>(&self, entity_name: &str) -> Result<Vec<(String, D)>, MigrationError>
672 where
673 D: serde::de::DeserializeOwned,
674 {
675 let ids = self.list_ids()?;
676 let mut results = Vec::new();
677
678 for id in ids {
679 let entity = self.load(entity_name, &id)?;
680 results.push((id, entity));
681 }
682
683 Ok(results)
684 }
685
686 pub fn exists(&self, id: &str) -> Result<bool, MigrationError> {
704 let file_path = self.id_to_path(id)?;
705 Ok(file_path.exists() && file_path.is_file())
706 }
707
708 pub fn delete(&self, id: &str) -> Result<(), MigrationError> {
728 let file_path = self.id_to_path(id)?;
729
730 if file_path.exists() {
731 fs::remove_file(&file_path).map_err(|e| MigrationError::IoError {
732 path: file_path.display().to_string(),
733 error: e.to_string(),
734 })?;
735 }
736
737 Ok(())
738 }
739
740 fn deserialize_content(&self, content: &str) -> Result<serde_json::Value, MigrationError> {
754 match self.strategy.format {
755 FormatStrategy::Json => serde_json::from_str(content)
756 .map_err(|e| MigrationError::DeserializationError(e.to_string())),
757 FormatStrategy::Toml => {
758 let toml_value: toml::Value = toml::from_str(content)
759 .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
760 toml_to_json(toml_value)
761 }
762 }
763 }
764
765 fn path_to_id(&self, path: &Path) -> Result<Option<String>, MigrationError> {
779 let file_stem = match path.file_stem() {
781 Some(stem) => stem.to_string_lossy(),
782 None => return Ok(None),
783 };
784
785 let id = self.decode_id(&file_stem)?;
787 Ok(Some(id))
788 }
789
790 fn decode_id(&self, filename_stem: &str) -> Result<String, MigrationError> {
810 match self.strategy.filename_encoding {
811 FilenameEncoding::Direct => {
812 Ok(filename_stem.to_string())
814 }
815 FilenameEncoding::UrlEncode => {
816 urlencoding::decode(filename_stem)
818 .map(|s| s.into_owned())
819 .map_err(|e| MigrationError::FilenameEncoding {
820 id: filename_stem.to_string(),
821 reason: format!("Failed to URL-decode filename: {}", e),
822 })
823 }
824 FilenameEncoding::Base64 => {
825 URL_SAFE_NO_PAD
827 .decode(filename_stem.as_bytes())
828 .map_err(|e| MigrationError::FilenameEncoding {
829 id: filename_stem.to_string(),
830 reason: format!("Failed to Base64-decode filename: {}", e),
831 })
832 .and_then(|bytes| {
833 String::from_utf8(bytes).map_err(|e| MigrationError::FilenameEncoding {
834 id: filename_stem.to_string(),
835 reason: format!(
836 "Failed to convert Base64-decoded bytes to UTF-8: {}",
837 e
838 ),
839 })
840 })
841 }
842 }
843 }
844}
845
846fn json_to_toml(json_value: &serde_json::Value) -> Result<toml::Value, MigrationError> {
850 let json_str = serde_json::to_string(json_value)
851 .map_err(|e| MigrationError::SerializationError(e.to_string()))?;
852 let toml_value: toml::Value = serde_json::from_str(&json_str)
853 .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
854 Ok(toml_value)
855}
856
857fn toml_to_json(toml_value: toml::Value) -> Result<serde_json::Value, MigrationError> {
861 let json_str = serde_json::to_string(&toml_value)
862 .map_err(|e| MigrationError::SerializationError(e.to_string()))?;
863 let json_value: serde_json::Value = serde_json::from_str(&json_str)
864 .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
865 Ok(json_value)
866}
867
868#[cfg(test)]
869mod tests {
870 use super::*;
871 use tempfile::TempDir;
872
873 #[test]
874 fn test_filename_encoding_default() {
875 assert_eq!(FilenameEncoding::default(), FilenameEncoding::Direct);
876 }
877
878 #[test]
879 fn test_dir_storage_strategy_default() {
880 let strategy = DirStorageStrategy::default();
881 assert_eq!(strategy.format, FormatStrategy::Json);
882 assert_eq!(strategy.extension, None);
883 assert_eq!(strategy.filename_encoding, FilenameEncoding::Direct);
884 }
885
886 #[test]
887 fn test_dir_storage_strategy_builder() {
888 let strategy = DirStorageStrategy::new()
889 .with_format(FormatStrategy::Toml)
890 .with_extension("data")
891 .with_filename_encoding(FilenameEncoding::Base64)
892 .with_retry_count(5)
893 .with_cleanup(false);
894
895 assert_eq!(strategy.format, FormatStrategy::Toml);
896 assert_eq!(strategy.extension, Some("data".to_string()));
897 assert_eq!(strategy.filename_encoding, FilenameEncoding::Base64);
898 assert_eq!(strategy.atomic_write.retry_count, 5);
899 assert!(!strategy.atomic_write.cleanup_tmp_files);
900 }
901
902 #[test]
903 fn test_dir_storage_strategy_get_extension() {
904 let strategy1 = DirStorageStrategy::default();
906 assert_eq!(strategy1.get_extension(), "json");
907
908 let strategy2 = DirStorageStrategy::default().with_format(FormatStrategy::Toml);
910 assert_eq!(strategy2.get_extension(), "toml");
911
912 let strategy3 = DirStorageStrategy::default().with_extension("custom");
914 assert_eq!(strategy3.get_extension(), "custom");
915 }
916
917 #[test]
918 fn test_dir_storage_new_creates_directory() {
919 let temp_dir = TempDir::new().unwrap();
920 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
921 temp_dir.path().to_path_buf(),
922 ));
923
924 let migrator = Migrator::new();
925 let strategy = DirStorageStrategy::default();
926
927 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
928
929 assert!(storage.base_path.exists());
931 assert!(storage.base_path.is_dir());
932 assert!(storage.base_path.ends_with("data/testapp/sessions"));
933 }
934
935 #[test]
936 fn test_dir_storage_new_idempotent() {
937 let temp_dir = TempDir::new().unwrap();
938 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
939 temp_dir.path().to_path_buf(),
940 ));
941
942 let migrator1 = Migrator::new();
943 let migrator2 = Migrator::new();
944 let strategy = DirStorageStrategy::default();
945
946 let storage1 =
948 DirStorage::new(paths.clone(), "sessions", migrator1, strategy.clone()).unwrap();
949 let storage2 = DirStorage::new(paths, "sessions", migrator2, strategy).unwrap();
950
951 assert_eq!(storage1.base_path, storage2.base_path);
953 }
954
955 use crate::{FromDomain, IntoDomain, MigratesTo, Versioned};
957 use serde::{Deserialize, Serialize};
958
959 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
960 struct SessionV1_0_0 {
961 id: String,
962 user_id: String,
963 }
964
965 impl Versioned for SessionV1_0_0 {
966 const VERSION: &'static str = "1.0.0";
967 }
968
969 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
970 struct SessionV1_1_0 {
971 id: String,
972 user_id: String,
973 created_at: Option<String>,
974 }
975
976 impl Versioned for SessionV1_1_0 {
977 const VERSION: &'static str = "1.1.0";
978 }
979
980 impl MigratesTo<SessionV1_1_0> for SessionV1_0_0 {
981 fn migrate(self) -> SessionV1_1_0 {
982 SessionV1_1_0 {
983 id: self.id,
984 user_id: self.user_id,
985 created_at: None,
986 }
987 }
988 }
989
990 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
991 struct SessionEntity {
992 id: String,
993 user_id: String,
994 created_at: Option<String>,
995 }
996
997 impl IntoDomain<SessionEntity> for SessionV1_1_0 {
998 fn into_domain(self) -> SessionEntity {
999 SessionEntity {
1000 id: self.id,
1001 user_id: self.user_id,
1002 created_at: self.created_at,
1003 }
1004 }
1005 }
1006
1007 impl FromDomain<SessionEntity> for SessionV1_1_0 {
1008 fn from_domain(domain: SessionEntity) -> Self {
1009 SessionV1_1_0 {
1010 id: domain.id,
1011 user_id: domain.user_id,
1012 created_at: domain.created_at,
1013 }
1014 }
1015 }
1016
1017 fn setup_session_migrator() -> Migrator {
1018 let path = Migrator::define("session")
1019 .from::<SessionV1_0_0>()
1020 .step::<SessionV1_1_0>()
1021 .into_with_save::<SessionEntity>();
1022
1023 let mut migrator = Migrator::new();
1024 migrator.register(path).unwrap();
1025 migrator
1026 }
1027
1028 #[test]
1029 fn test_dir_storage_save_json() {
1030 let temp_dir = TempDir::new().unwrap();
1031 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1032 temp_dir.path().to_path_buf(),
1033 ));
1034
1035 let migrator = setup_session_migrator();
1036 let strategy = DirStorageStrategy::default().with_format(FormatStrategy::Json);
1037 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1038
1039 let session = SessionEntity {
1041 id: "session-123".to_string(),
1042 user_id: "user-456".to_string(),
1043 created_at: Some("2024-01-01T00:00:00Z".to_string()),
1044 };
1045
1046 storage.save("session", "session-123", session).unwrap();
1048
1049 let file_path = storage.base_path.join("session-123.json");
1051 assert!(file_path.exists());
1052
1053 let content = std::fs::read_to_string(&file_path).unwrap();
1055 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1056 assert_eq!(json["version"], "1.1.0");
1057 assert_eq!(json["id"], "session-123");
1058 assert_eq!(json["user_id"], "user-456");
1059 }
1060
1061 #[test]
1062 fn test_dir_storage_save_toml() {
1063 let temp_dir = TempDir::new().unwrap();
1064 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1065 temp_dir.path().to_path_buf(),
1066 ));
1067
1068 let migrator = setup_session_migrator();
1069 let strategy = DirStorageStrategy::default().with_format(FormatStrategy::Toml);
1070 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1071
1072 let session = SessionEntity {
1074 id: "session-789".to_string(),
1075 user_id: "user-101".to_string(),
1076 created_at: Some("2024-01-15T10:30:00Z".to_string()),
1077 };
1078
1079 storage.save("session", "session-789", session).unwrap();
1081
1082 let file_path = storage.base_path.join("session-789.toml");
1084 assert!(file_path.exists());
1085
1086 let content = std::fs::read_to_string(&file_path).unwrap();
1088 let toml: toml::Value = toml::from_str(&content).unwrap();
1089 assert_eq!(toml["version"].as_str().unwrap(), "1.1.0");
1090 assert_eq!(toml["id"].as_str().unwrap(), "session-789");
1091 assert_eq!(toml["created_at"].as_str().unwrap(), "2024-01-15T10:30:00Z");
1092 }
1093
1094 #[test]
1095 fn test_dir_storage_save_with_invalid_id() {
1096 let temp_dir = TempDir::new().unwrap();
1097 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1098 temp_dir.path().to_path_buf(),
1099 ));
1100
1101 let migrator = setup_session_migrator();
1102 let strategy = DirStorageStrategy::default();
1103 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1104
1105 let session = SessionEntity {
1106 id: "invalid/id".to_string(),
1107 user_id: "user-456".to_string(),
1108 created_at: None,
1109 };
1110
1111 let result = storage.save("session", "invalid/id", session);
1113 assert!(result.is_err());
1114 assert!(matches!(
1115 result.unwrap_err(),
1116 crate::MigrationError::FilenameEncoding { .. }
1117 ));
1118 }
1119
1120 #[test]
1121 fn test_dir_storage_save_with_custom_extension() {
1122 let temp_dir = TempDir::new().unwrap();
1123 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1124 temp_dir.path().to_path_buf(),
1125 ));
1126
1127 let migrator = setup_session_migrator();
1128 let strategy = DirStorageStrategy::default()
1129 .with_format(FormatStrategy::Json)
1130 .with_extension("data");
1131 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1132
1133 let session = SessionEntity {
1134 id: "session-custom".to_string(),
1135 user_id: "user-999".to_string(),
1136 created_at: None,
1137 };
1138
1139 storage.save("session", "session-custom", session).unwrap();
1140
1141 let file_path = storage.base_path.join("session-custom.data");
1143 assert!(file_path.exists());
1144 }
1145
1146 #[test]
1147 fn test_dir_storage_save_overwrites_existing() {
1148 let temp_dir = TempDir::new().unwrap();
1149 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1150 temp_dir.path().to_path_buf(),
1151 ));
1152
1153 let migrator = setup_session_migrator();
1154 let strategy = DirStorageStrategy::default();
1155 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1156
1157 let session1 = SessionEntity {
1159 id: "session-overwrite".to_string(),
1160 user_id: "user-111".to_string(),
1161 created_at: Some("2024-01-01".to_string()),
1162 };
1163 storage
1164 .save("session", "session-overwrite", session1)
1165 .unwrap();
1166
1167 let session2 = SessionEntity {
1169 id: "session-overwrite".to_string(),
1170 user_id: "user-222".to_string(),
1171 created_at: Some("2024-01-02".to_string()),
1172 };
1173 storage
1174 .save("session", "session-overwrite", session2)
1175 .unwrap();
1176
1177 let file_path = storage.base_path.join("session-overwrite.json");
1179 let content = std::fs::read_to_string(&file_path).unwrap();
1180 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1181 assert_eq!(json["user_id"], "user-222");
1182 assert_eq!(json["created_at"], "2024-01-02");
1183 }
1184
1185 #[test]
1186 fn test_dir_storage_load_success() {
1187 let temp_dir = TempDir::new().unwrap();
1188 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1189 temp_dir.path().to_path_buf(),
1190 ));
1191
1192 let migrator = setup_session_migrator();
1193 let strategy = DirStorageStrategy::default();
1194 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1195
1196 let session = SessionEntity {
1198 id: "session-load".to_string(),
1199 user_id: "user-999".to_string(),
1200 created_at: Some("2024-02-01".to_string()),
1201 };
1202 storage
1203 .save("session", "session-load", session.clone())
1204 .unwrap();
1205
1206 let loaded: SessionEntity = storage.load("session", "session-load").unwrap();
1208 assert_eq!(loaded.id, session.id);
1209 assert_eq!(loaded.user_id, session.user_id);
1210 assert_eq!(loaded.created_at, session.created_at);
1211 }
1212
1213 #[test]
1214 fn test_dir_storage_load_not_found() {
1215 let temp_dir = TempDir::new().unwrap();
1216 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1217 temp_dir.path().to_path_buf(),
1218 ));
1219
1220 let migrator = setup_session_migrator();
1221 let strategy = DirStorageStrategy::default();
1222 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1223
1224 let result: Result<SessionEntity, _> = storage.load("session", "non-existent");
1226 assert!(result.is_err());
1227 assert!(matches!(
1228 result.unwrap_err(),
1229 MigrationError::IoError { .. }
1230 ));
1231 }
1232
1233 #[test]
1234 fn test_dir_storage_save_and_load_roundtrip() {
1235 let temp_dir = TempDir::new().unwrap();
1236 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1237 temp_dir.path().to_path_buf(),
1238 ));
1239
1240 let migrator = setup_session_migrator();
1241 let strategy = DirStorageStrategy::default();
1242 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1243
1244 let sessions = vec![
1246 SessionEntity {
1247 id: "session-1".to_string(),
1248 user_id: "user-1".to_string(),
1249 created_at: Some("2024-01-01".to_string()),
1250 },
1251 SessionEntity {
1252 id: "session-2".to_string(),
1253 user_id: "user-2".to_string(),
1254 created_at: None,
1255 },
1256 SessionEntity {
1257 id: "session-3".to_string(),
1258 user_id: "user-3".to_string(),
1259 created_at: Some("2024-03-01".to_string()),
1260 },
1261 ];
1262
1263 for session in &sessions {
1265 storage
1266 .save("session", &session.id, session.clone())
1267 .unwrap();
1268 }
1269
1270 for session in &sessions {
1272 let loaded: SessionEntity = storage.load("session", &session.id).unwrap();
1273 assert_eq!(loaded.id, session.id);
1274 assert_eq!(loaded.user_id, session.user_id);
1275 assert_eq!(loaded.created_at, session.created_at);
1276 }
1277 }
1278
1279 #[test]
1280 fn test_dir_storage_list_ids_empty() {
1281 let temp_dir = TempDir::new().unwrap();
1282 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1283 temp_dir.path().to_path_buf(),
1284 ));
1285
1286 let migrator = setup_session_migrator();
1287 let strategy = DirStorageStrategy::default();
1288 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1289
1290 let ids = storage.list_ids().unwrap();
1292 assert!(ids.is_empty());
1293 }
1294
1295 #[test]
1296 fn test_dir_storage_list_ids() {
1297 let temp_dir = TempDir::new().unwrap();
1298 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1299 temp_dir.path().to_path_buf(),
1300 ));
1301
1302 let migrator = setup_session_migrator();
1303 let strategy = DirStorageStrategy::default();
1304 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1305
1306 let ids = vec!["session-c", "session-a", "session-b"];
1308 for id in &ids {
1309 let session = SessionEntity {
1310 id: id.to_string(),
1311 user_id: "user".to_string(),
1312 created_at: None,
1313 };
1314 storage.save("session", id, session).unwrap();
1315 }
1316
1317 let listed_ids = storage.list_ids().unwrap();
1319 assert_eq!(listed_ids.len(), 3);
1320 assert_eq!(listed_ids, vec!["session-a", "session-b", "session-c"]);
1322 }
1323
1324 #[test]
1325 fn test_dir_storage_load_all_empty() {
1326 let temp_dir = TempDir::new().unwrap();
1327 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1328 temp_dir.path().to_path_buf(),
1329 ));
1330
1331 let migrator = setup_session_migrator();
1332 let strategy = DirStorageStrategy::default();
1333 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1334
1335 let results: Vec<(String, SessionEntity)> = storage.load_all("session").unwrap();
1337 assert!(results.is_empty());
1338 }
1339
1340 #[test]
1341 fn test_dir_storage_load_all() {
1342 let temp_dir = TempDir::new().unwrap();
1343 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1344 temp_dir.path().to_path_buf(),
1345 ));
1346
1347 let migrator = setup_session_migrator();
1348 let strategy = DirStorageStrategy::default();
1349 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1350
1351 let sessions = vec![
1353 SessionEntity {
1354 id: "session-x".to_string(),
1355 user_id: "user-x".to_string(),
1356 created_at: Some("2024-01-01".to_string()),
1357 },
1358 SessionEntity {
1359 id: "session-y".to_string(),
1360 user_id: "user-y".to_string(),
1361 created_at: None,
1362 },
1363 SessionEntity {
1364 id: "session-z".to_string(),
1365 user_id: "user-z".to_string(),
1366 created_at: Some("2024-03-01".to_string()),
1367 },
1368 ];
1369
1370 for session in &sessions {
1371 storage
1372 .save("session", &session.id, session.clone())
1373 .unwrap();
1374 }
1375
1376 let results: Vec<(String, SessionEntity)> = storage.load_all("session").unwrap();
1378 assert_eq!(results.len(), 3);
1379
1380 for (id, loaded) in &results {
1382 let original = sessions.iter().find(|s| &s.id == id).unwrap();
1383 assert_eq!(loaded.id, original.id);
1384 assert_eq!(loaded.user_id, original.user_id);
1385 assert_eq!(loaded.created_at, original.created_at);
1386 }
1387 }
1388
1389 #[test]
1390 fn test_dir_storage_exists() {
1391 let temp_dir = TempDir::new().unwrap();
1392 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1393 temp_dir.path().to_path_buf(),
1394 ));
1395
1396 let migrator = setup_session_migrator();
1397 let strategy = DirStorageStrategy::default();
1398 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1399
1400 assert!(!storage.exists("session-exists").unwrap());
1402
1403 let session = SessionEntity {
1405 id: "session-exists".to_string(),
1406 user_id: "user-exists".to_string(),
1407 created_at: None,
1408 };
1409 storage.save("session", "session-exists", session).unwrap();
1410
1411 assert!(storage.exists("session-exists").unwrap());
1413 }
1414
1415 #[test]
1416 fn test_dir_storage_delete() {
1417 let temp_dir = TempDir::new().unwrap();
1418 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1419 temp_dir.path().to_path_buf(),
1420 ));
1421
1422 let migrator = setup_session_migrator();
1423 let strategy = DirStorageStrategy::default();
1424 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1425
1426 let session = SessionEntity {
1428 id: "session-delete".to_string(),
1429 user_id: "user-delete".to_string(),
1430 created_at: None,
1431 };
1432 storage.save("session", "session-delete", session).unwrap();
1433
1434 assert!(storage.exists("session-delete").unwrap());
1436
1437 storage.delete("session-delete").unwrap();
1439
1440 assert!(!storage.exists("session-delete").unwrap());
1442 }
1443
1444 #[test]
1445 fn test_dir_storage_delete_idempotent() {
1446 let temp_dir = TempDir::new().unwrap();
1447 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1448 temp_dir.path().to_path_buf(),
1449 ));
1450
1451 let migrator = setup_session_migrator();
1452 let strategy = DirStorageStrategy::default();
1453 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1454
1455 storage.delete("non-existent").unwrap();
1457
1458 storage.delete("non-existent").unwrap();
1460 }
1461
1462 #[test]
1463 fn test_dir_storage_load_toml() {
1464 let temp_dir = TempDir::new().unwrap();
1465 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1466 temp_dir.path().to_path_buf(),
1467 ));
1468
1469 let migrator = setup_session_migrator();
1470 let strategy = DirStorageStrategy::default().with_format(FormatStrategy::Toml);
1471 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1472
1473 let session = SessionEntity {
1475 id: "session-toml".to_string(),
1476 user_id: "user-toml".to_string(),
1477 created_at: Some("2024-04-01".to_string()),
1478 };
1479 storage
1480 .save("session", "session-toml", session.clone())
1481 .unwrap();
1482
1483 let loaded: SessionEntity = storage.load("session", "session-toml").unwrap();
1485 assert_eq!(loaded.id, session.id);
1486 assert_eq!(loaded.user_id, session.user_id);
1487 assert_eq!(loaded.created_at, session.created_at);
1488 }
1489
1490 #[test]
1491 fn test_dir_storage_list_ids_with_custom_extension() {
1492 let temp_dir = TempDir::new().unwrap();
1493 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1494 temp_dir.path().to_path_buf(),
1495 ));
1496
1497 let migrator = setup_session_migrator();
1498 let strategy = DirStorageStrategy::default().with_extension("data");
1499 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1500
1501 let session = SessionEntity {
1503 id: "session-ext".to_string(),
1504 user_id: "user-ext".to_string(),
1505 created_at: None,
1506 };
1507 storage.save("session", "session-ext", session).unwrap();
1508
1509 let ids = storage.list_ids().unwrap();
1511 assert_eq!(ids.len(), 1);
1512 assert_eq!(ids[0], "session-ext");
1513 }
1514
1515 #[test]
1516 fn test_dir_storage_load_all_atomic_failure() {
1517 let temp_dir = TempDir::new().unwrap();
1518 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1519 temp_dir.path().to_path_buf(),
1520 ));
1521
1522 let migrator = setup_session_migrator();
1523 let strategy = DirStorageStrategy::default();
1524 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1525
1526 let session1 = SessionEntity {
1528 id: "session-1".to_string(),
1529 user_id: "user-1".to_string(),
1530 created_at: None,
1531 };
1532 storage.save("session", "session-1", session1).unwrap();
1533
1534 let corrupted_path = storage.base_path.join("session-corrupted.json");
1536 std::fs::write(&corrupted_path, "invalid json {{{").unwrap();
1537
1538 let result: Result<Vec<(String, SessionEntity)>, _> = storage.load_all("session");
1540 assert!(result.is_err());
1541 }
1542
1543 #[test]
1544 fn test_dir_storage_filename_encoding_url_roundtrip() {
1545 let temp_dir = TempDir::new().unwrap();
1546 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1547 temp_dir.path().to_path_buf(),
1548 ));
1549
1550 let migrator = setup_session_migrator();
1551 let strategy =
1552 DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
1553 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1554
1555 let complex_id = "user@example.com/path?query=1";
1557 let session = SessionEntity {
1558 id: complex_id.to_string(),
1559 user_id: "user-special".to_string(),
1560 created_at: Some("2024-05-01".to_string()),
1561 };
1562
1563 storage
1565 .save("session", complex_id, session.clone())
1566 .unwrap();
1567
1568 let encoded_id = urlencoding::encode(complex_id);
1570 let file_path = storage.base_path.join(format!("{}.json", encoded_id));
1571 assert!(file_path.exists());
1572
1573 let loaded: SessionEntity = storage.load("session", complex_id).unwrap();
1575 assert_eq!(loaded.id, session.id);
1576 assert_eq!(loaded.user_id, session.user_id);
1577 assert_eq!(loaded.created_at, session.created_at);
1578
1579 let ids = storage.list_ids().unwrap();
1581 assert_eq!(ids.len(), 1);
1582 assert_eq!(ids[0], complex_id);
1583 }
1584
1585 #[test]
1586 fn test_dir_storage_filename_encoding_base64_roundtrip() {
1587 let temp_dir = TempDir::new().unwrap();
1588 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1589 temp_dir.path().to_path_buf(),
1590 ));
1591
1592 let migrator = setup_session_migrator();
1593 let strategy =
1594 DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
1595 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1596
1597 let complex_id = "user@example.com/path?query=1&special=!@#$%";
1599 let session = SessionEntity {
1600 id: complex_id.to_string(),
1601 user_id: "user-base64".to_string(),
1602 created_at: Some("2024-06-01".to_string()),
1603 };
1604
1605 storage
1607 .save("session", complex_id, session.clone())
1608 .unwrap();
1609
1610 let encoded_id = URL_SAFE_NO_PAD.encode(complex_id.as_bytes());
1612 let file_path = storage.base_path.join(format!("{}.json", encoded_id));
1613 assert!(file_path.exists());
1614
1615 let loaded: SessionEntity = storage.load("session", complex_id).unwrap();
1617 assert_eq!(loaded.id, session.id);
1618 assert_eq!(loaded.user_id, session.user_id);
1619 assert_eq!(loaded.created_at, session.created_at);
1620
1621 let ids = storage.list_ids().unwrap();
1623 assert_eq!(ids.len(), 1);
1624 assert_eq!(ids[0], complex_id);
1625 }
1626
1627 #[test]
1628 fn test_decode_id_error_handling() {
1629 let temp_dir = TempDir::new().unwrap();
1630 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1631 temp_dir.path().to_path_buf(),
1632 ));
1633
1634 let migrator_url = setup_session_migrator();
1638 let strategy_url =
1639 DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
1640 let storage_url =
1641 DirStorage::new(paths.clone(), "sessions_url", migrator_url, strategy_url).unwrap();
1642
1643 let invalid_url_encoded = "%C0%C1"; let result = storage_url.decode_id(invalid_url_encoded);
1646 assert!(result.is_err());
1647 if let Err(MigrationError::FilenameEncoding { id, reason }) = result {
1648 assert_eq!(id, invalid_url_encoded);
1649 assert!(reason.contains("Failed to URL-decode filename"));
1650 }
1651
1652 let migrator_base64 = setup_session_migrator();
1654 let strategy_base64 =
1655 DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
1656 let storage_base64 =
1657 DirStorage::new(paths, "sessions_base64", migrator_base64, strategy_base64).unwrap();
1658
1659 let invalid_base64 = "!!!invalid@@@";
1661 let result = storage_base64.decode_id(invalid_base64);
1662 assert!(result.is_err());
1663 if let Err(MigrationError::FilenameEncoding { id, reason }) = result {
1664 assert_eq!(id, invalid_base64);
1665 assert!(reason.contains("Failed to Base64-decode filename"));
1666 }
1667
1668 let invalid_utf8_bytes = vec![0xFF, 0xFE, 0xFD];
1671 let valid_base64_invalid_utf8 = URL_SAFE_NO_PAD.encode(&invalid_utf8_bytes);
1672 let result = storage_base64.decode_id(&valid_base64_invalid_utf8);
1673 assert!(result.is_err());
1674 if let Err(MigrationError::FilenameEncoding { id, reason }) = result {
1675 assert_eq!(id, valid_base64_invalid_utf8);
1676 assert!(reason.contains("Failed to convert Base64-decoded bytes to UTF-8"));
1677 }
1678 }
1679}