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 pub fn base_path(&self) -> &Path {
746 &self.base_path
747 }
748
749 fn deserialize_content(&self, content: &str) -> Result<serde_json::Value, MigrationError> {
763 match self.strategy.format {
764 FormatStrategy::Json => serde_json::from_str(content)
765 .map_err(|e| MigrationError::DeserializationError(e.to_string())),
766 FormatStrategy::Toml => {
767 let toml_value: toml::Value = toml::from_str(content)
768 .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
769 toml_to_json(toml_value)
770 }
771 }
772 }
773
774 fn path_to_id(&self, path: &Path) -> Result<Option<String>, MigrationError> {
788 let file_stem = match path.file_stem() {
790 Some(stem) => stem.to_string_lossy(),
791 None => return Ok(None),
792 };
793
794 let id = self.decode_id(&file_stem)?;
796 Ok(Some(id))
797 }
798
799 fn decode_id(&self, filename_stem: &str) -> Result<String, MigrationError> {
819 match self.strategy.filename_encoding {
820 FilenameEncoding::Direct => {
821 Ok(filename_stem.to_string())
823 }
824 FilenameEncoding::UrlEncode => {
825 urlencoding::decode(filename_stem)
827 .map(|s| s.into_owned())
828 .map_err(|e| MigrationError::FilenameEncoding {
829 id: filename_stem.to_string(),
830 reason: format!("Failed to URL-decode filename: {}", e),
831 })
832 }
833 FilenameEncoding::Base64 => {
834 URL_SAFE_NO_PAD
836 .decode(filename_stem.as_bytes())
837 .map_err(|e| MigrationError::FilenameEncoding {
838 id: filename_stem.to_string(),
839 reason: format!("Failed to Base64-decode filename: {}", e),
840 })
841 .and_then(|bytes| {
842 String::from_utf8(bytes).map_err(|e| MigrationError::FilenameEncoding {
843 id: filename_stem.to_string(),
844 reason: format!(
845 "Failed to convert Base64-decoded bytes to UTF-8: {}",
846 e
847 ),
848 })
849 })
850 }
851 }
852 }
853}
854
855fn json_to_toml(json_value: &serde_json::Value) -> Result<toml::Value, MigrationError> {
859 let json_str = serde_json::to_string(json_value)
860 .map_err(|e| MigrationError::SerializationError(e.to_string()))?;
861 let toml_value: toml::Value = serde_json::from_str(&json_str)
862 .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
863 Ok(toml_value)
864}
865
866fn toml_to_json(toml_value: toml::Value) -> Result<serde_json::Value, MigrationError> {
870 let json_str = serde_json::to_string(&toml_value)
871 .map_err(|e| MigrationError::SerializationError(e.to_string()))?;
872 let json_value: serde_json::Value = serde_json::from_str(&json_str)
873 .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
874 Ok(json_value)
875}
876
877#[cfg(feature = "async")]
882pub use async_impl::AsyncDirStorage;
883
884#[cfg(feature = "async")]
885mod async_impl {
886 use crate::{AppPaths, MigrationError, Migrator};
887 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
888 use base64::Engine;
889 use std::path::{Path, PathBuf};
890 use tokio::io::AsyncWriteExt;
891
892 use super::{json_to_toml, toml_to_json, DirStorageStrategy, FilenameEncoding, FormatStrategy};
893
894 pub struct AsyncDirStorage {
899 base_path: PathBuf,
901 migrator: Migrator,
903 strategy: DirStorageStrategy,
905 }
906
907 impl AsyncDirStorage {
908 pub async fn new(
927 paths: AppPaths,
928 domain_name: &str,
929 migrator: Migrator,
930 strategy: DirStorageStrategy,
931 ) -> Result<Self, MigrationError> {
932 let base_path = paths.data_dir()?.join(domain_name);
934
935 if !tokio::fs::try_exists(&base_path).await.unwrap_or(false) {
937 tokio::fs::create_dir_all(&base_path).await.map_err(|e| {
938 MigrationError::IoError {
939 path: base_path.display().to_string(),
940 error: e.to_string(),
941 }
942 })?;
943 }
944
945 Ok(Self {
946 base_path,
947 migrator,
948 strategy,
949 })
950 }
951
952 pub async fn save<T>(
974 &self,
975 entity_name: &str,
976 id: &str,
977 entity: T,
978 ) -> Result<(), MigrationError>
979 where
980 T: serde::Serialize,
981 {
982 let json_string = self.migrator.save_domain_flat(entity_name, entity)?;
984
985 let versioned_value: serde_json::Value = serde_json::from_str(&json_string)
987 .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
988
989 let content = self.serialize_content(&versioned_value)?;
991
992 let file_path = self.id_to_path(id)?;
994
995 self.atomic_write(&file_path, &content).await?;
997
998 Ok(())
999 }
1000
1001 pub async fn load<D>(&self, entity_name: &str, id: &str) -> Result<D, MigrationError>
1023 where
1024 D: serde::de::DeserializeOwned,
1025 {
1026 let file_path = self.id_to_path(id)?;
1028
1029 if !tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
1031 return Err(MigrationError::IoError {
1032 path: file_path.display().to_string(),
1033 error: "File not found".to_string(),
1034 });
1035 }
1036
1037 let content = tokio::fs::read_to_string(&file_path).await.map_err(|e| {
1039 MigrationError::IoError {
1040 path: file_path.display().to_string(),
1041 error: e.to_string(),
1042 }
1043 })?;
1044
1045 let value = self.deserialize_content(&content)?;
1047
1048 self.migrator.load_flat_from(entity_name, value)
1050 }
1051
1052 pub async fn list_ids(&self) -> Result<Vec<String>, MigrationError> {
1064 let mut entries = tokio::fs::read_dir(&self.base_path).await.map_err(|e| {
1066 MigrationError::IoError {
1067 path: self.base_path.display().to_string(),
1068 error: e.to_string(),
1069 }
1070 })?;
1071
1072 let extension = self.strategy.get_extension();
1073 let mut ids = Vec::new();
1074
1075 while let Some(entry) =
1076 entries
1077 .next_entry()
1078 .await
1079 .map_err(|e| MigrationError::IoError {
1080 path: self.base_path.display().to_string(),
1081 error: e.to_string(),
1082 })?
1083 {
1084 let path = entry.path();
1085
1086 let metadata =
1088 tokio::fs::metadata(&path)
1089 .await
1090 .map_err(|e| MigrationError::IoError {
1091 path: path.display().to_string(),
1092 error: e.to_string(),
1093 })?;
1094
1095 if metadata.is_file() {
1096 if let Some(ext) = path.extension() {
1097 if ext == extension.as_str() {
1098 if let Some(id) = self.path_to_id(&path)? {
1100 ids.push(id);
1101 }
1102 }
1103 }
1104 }
1105 }
1106
1107 ids.sort();
1109 Ok(ids)
1110 }
1111
1112 pub async fn load_all<D>(
1127 &self,
1128 entity_name: &str,
1129 ) -> Result<Vec<(String, D)>, MigrationError>
1130 where
1131 D: serde::de::DeserializeOwned,
1132 {
1133 let ids = self.list_ids().await?;
1134 let mut results = Vec::new();
1135
1136 for id in ids {
1137 let entity = self.load(entity_name, &id).await?;
1138 results.push((id, entity));
1139 }
1140
1141 Ok(results)
1142 }
1143
1144 pub async fn exists(&self, id: &str) -> Result<bool, MigrationError> {
1154 let file_path = self.id_to_path(id)?;
1155
1156 if !tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
1157 return Ok(false);
1158 }
1159
1160 let metadata =
1161 tokio::fs::metadata(&file_path)
1162 .await
1163 .map_err(|e| MigrationError::IoError {
1164 path: file_path.display().to_string(),
1165 error: e.to_string(),
1166 })?;
1167
1168 Ok(metadata.is_file())
1169 }
1170
1171 pub async fn delete(&self, id: &str) -> Result<(), MigrationError> {
1185 let file_path = self.id_to_path(id)?;
1186
1187 if tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
1188 tokio::fs::remove_file(&file_path)
1189 .await
1190 .map_err(|e| MigrationError::IoError {
1191 path: file_path.display().to_string(),
1192 error: e.to_string(),
1193 })?;
1194 }
1195
1196 Ok(())
1197 }
1198
1199 pub fn base_path(&self) -> &Path {
1205 &self.base_path
1206 }
1207
1208 fn id_to_path(&self, id: &str) -> Result<PathBuf, MigrationError> {
1214 let encoded_id = self.encode_id(id)?;
1215 let extension = self.strategy.get_extension();
1216 let filename = format!("{}.{}", encoded_id, extension);
1217 Ok(self.base_path.join(filename))
1218 }
1219
1220 fn encode_id(&self, id: &str) -> Result<String, MigrationError> {
1222 match self.strategy.filename_encoding {
1223 FilenameEncoding::Direct => {
1224 if id
1226 .chars()
1227 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1228 {
1229 Ok(id.to_string())
1230 } else {
1231 Err(MigrationError::FilenameEncoding {
1232 id: id.to_string(),
1233 reason: "ID contains invalid characters for Direct encoding. Only alphanumeric, '-', and '_' are allowed.".to_string(),
1234 })
1235 }
1236 }
1237 FilenameEncoding::UrlEncode => {
1238 Ok(urlencoding::encode(id).into_owned())
1240 }
1241 FilenameEncoding::Base64 => {
1242 Ok(URL_SAFE_NO_PAD.encode(id.as_bytes()))
1244 }
1245 }
1246 }
1247
1248 fn serialize_content(&self, value: &serde_json::Value) -> Result<String, MigrationError> {
1250 match self.strategy.format {
1251 FormatStrategy::Json => serde_json::to_string_pretty(value)
1252 .map_err(|e| MigrationError::SerializationError(e.to_string())),
1253 FormatStrategy::Toml => {
1254 let toml_value = json_to_toml(value)?;
1255 toml::to_string_pretty(&toml_value)
1256 .map_err(|e| MigrationError::TomlSerializeError(e.to_string()))
1257 }
1258 }
1259 }
1260
1261 async fn atomic_write(&self, path: &Path, content: &str) -> Result<(), MigrationError> {
1266 if let Some(parent) = path.parent() {
1268 if !tokio::fs::try_exists(parent).await.unwrap_or(false) {
1269 tokio::fs::create_dir_all(parent).await.map_err(|e| {
1270 MigrationError::IoError {
1271 path: parent.display().to_string(),
1272 error: e.to_string(),
1273 }
1274 })?;
1275 }
1276 }
1277
1278 let tmp_path = self.get_temp_path(path)?;
1280
1281 let mut tmp_file =
1283 tokio::fs::File::create(&tmp_path)
1284 .await
1285 .map_err(|e| MigrationError::IoError {
1286 path: tmp_path.display().to_string(),
1287 error: e.to_string(),
1288 })?;
1289
1290 tmp_file
1291 .write_all(content.as_bytes())
1292 .await
1293 .map_err(|e| MigrationError::IoError {
1294 path: tmp_path.display().to_string(),
1295 error: e.to_string(),
1296 })?;
1297
1298 tmp_file
1300 .sync_all()
1301 .await
1302 .map_err(|e| MigrationError::IoError {
1303 path: tmp_path.display().to_string(),
1304 error: e.to_string(),
1305 })?;
1306
1307 drop(tmp_file);
1308
1309 self.atomic_rename(&tmp_path, path).await?;
1311
1312 if self.strategy.atomic_write.cleanup_tmp_files {
1314 let _ = self.cleanup_temp_files(path).await;
1315 }
1316
1317 Ok(())
1318 }
1319
1320 fn get_temp_path(&self, target_path: &Path) -> Result<PathBuf, MigrationError> {
1322 let parent = target_path.parent().ok_or_else(|| {
1323 MigrationError::PathResolution("Path has no parent directory".to_string())
1324 })?;
1325
1326 let file_name = target_path.file_name().ok_or_else(|| {
1327 MigrationError::PathResolution("Path has no file name".to_string())
1328 })?;
1329
1330 let tmp_name = format!(
1331 ".{}.tmp.{}",
1332 file_name.to_string_lossy(),
1333 std::process::id()
1334 );
1335 Ok(parent.join(tmp_name))
1336 }
1337
1338 async fn atomic_rename(
1340 &self,
1341 tmp_path: &Path,
1342 target_path: &Path,
1343 ) -> Result<(), MigrationError> {
1344 let mut last_error = None;
1345
1346 for attempt in 0..self.strategy.atomic_write.retry_count {
1347 match tokio::fs::rename(tmp_path, target_path).await {
1348 Ok(()) => return Ok(()),
1349 Err(e) => {
1350 last_error = Some(e);
1351 if attempt + 1 < self.strategy.atomic_write.retry_count {
1352 tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
1354 }
1355 }
1356 }
1357 }
1358
1359 Err(MigrationError::IoError {
1360 path: target_path.display().to_string(),
1361 error: format!(
1362 "Failed to rename after {} attempts: {}",
1363 self.strategy.atomic_write.retry_count,
1364 last_error.unwrap()
1365 ),
1366 })
1367 }
1368
1369 async fn cleanup_temp_files(&self, target_path: &Path) -> std::io::Result<()> {
1371 let parent = match target_path.parent() {
1372 Some(p) => p,
1373 None => return Ok(()),
1374 };
1375
1376 let file_name = match target_path.file_name() {
1377 Some(f) => f.to_string_lossy(),
1378 None => return Ok(()),
1379 };
1380
1381 let prefix = format!(".{}.tmp.", file_name);
1382
1383 let mut entries = tokio::fs::read_dir(parent).await?;
1384 while let Some(entry) = entries.next_entry().await? {
1385 if let Ok(name) = entry.file_name().into_string() {
1386 if name.starts_with(&prefix) {
1387 let _ = tokio::fs::remove_file(entry.path()).await;
1389 }
1390 }
1391 }
1392
1393 Ok(())
1394 }
1395
1396 fn deserialize_content(&self, content: &str) -> Result<serde_json::Value, MigrationError> {
1398 match self.strategy.format {
1399 FormatStrategy::Json => serde_json::from_str(content)
1400 .map_err(|e| MigrationError::DeserializationError(e.to_string())),
1401 FormatStrategy::Toml => {
1402 let toml_value: toml::Value = toml::from_str(content)
1403 .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
1404 toml_to_json(toml_value)
1405 }
1406 }
1407 }
1408
1409 fn path_to_id(&self, path: &Path) -> Result<Option<String>, MigrationError> {
1411 let file_stem = match path.file_stem() {
1413 Some(stem) => stem.to_string_lossy(),
1414 None => return Ok(None),
1415 };
1416
1417 let id = self.decode_id(&file_stem)?;
1419 Ok(Some(id))
1420 }
1421
1422 fn decode_id(&self, filename_stem: &str) -> Result<String, MigrationError> {
1424 match self.strategy.filename_encoding {
1425 FilenameEncoding::Direct => {
1426 Ok(filename_stem.to_string())
1428 }
1429 FilenameEncoding::UrlEncode => {
1430 urlencoding::decode(filename_stem)
1432 .map(|s| s.into_owned())
1433 .map_err(|e| MigrationError::FilenameEncoding {
1434 id: filename_stem.to_string(),
1435 reason: format!("Failed to URL-decode filename: {}", e),
1436 })
1437 }
1438 FilenameEncoding::Base64 => {
1439 URL_SAFE_NO_PAD
1441 .decode(filename_stem.as_bytes())
1442 .map_err(|e| MigrationError::FilenameEncoding {
1443 id: filename_stem.to_string(),
1444 reason: format!("Failed to Base64-decode filename: {}", e),
1445 })
1446 .and_then(|bytes| {
1447 String::from_utf8(bytes).map_err(|e| MigrationError::FilenameEncoding {
1448 id: filename_stem.to_string(),
1449 reason: format!(
1450 "Failed to convert Base64-decoded bytes to UTF-8: {}",
1451 e
1452 ),
1453 })
1454 })
1455 }
1456 }
1457 }
1458 }
1459
1460 #[cfg(all(test, feature = "async"))]
1462 mod async_tests {
1463 use super::*;
1464 use crate::{FromDomain, IntoDomain, MigratesTo, Versioned};
1465 use serde::{Deserialize, Serialize};
1466 use tempfile::TempDir;
1467
1468 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1470 struct SessionV1_0_0 {
1471 id: String,
1472 user_id: String,
1473 }
1474
1475 impl Versioned for SessionV1_0_0 {
1476 const VERSION: &'static str = "1.0.0";
1477 }
1478
1479 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1480 struct SessionV1_1_0 {
1481 id: String,
1482 user_id: String,
1483 created_at: Option<String>,
1484 }
1485
1486 impl Versioned for SessionV1_1_0 {
1487 const VERSION: &'static str = "1.1.0";
1488 }
1489
1490 impl MigratesTo<SessionV1_1_0> for SessionV1_0_0 {
1491 fn migrate(self) -> SessionV1_1_0 {
1492 SessionV1_1_0 {
1493 id: self.id,
1494 user_id: self.user_id,
1495 created_at: None,
1496 }
1497 }
1498 }
1499
1500 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1501 struct SessionEntity {
1502 id: String,
1503 user_id: String,
1504 created_at: Option<String>,
1505 }
1506
1507 impl IntoDomain<SessionEntity> for SessionV1_1_0 {
1508 fn into_domain(self) -> SessionEntity {
1509 SessionEntity {
1510 id: self.id,
1511 user_id: self.user_id,
1512 created_at: self.created_at,
1513 }
1514 }
1515 }
1516
1517 impl FromDomain<SessionEntity> for SessionV1_1_0 {
1518 fn from_domain(domain: SessionEntity) -> Self {
1519 SessionV1_1_0 {
1520 id: domain.id,
1521 user_id: domain.user_id,
1522 created_at: domain.created_at,
1523 }
1524 }
1525 }
1526
1527 fn setup_session_migrator() -> Migrator {
1528 let path = Migrator::define("session")
1529 .from::<SessionV1_0_0>()
1530 .step::<SessionV1_1_0>()
1531 .into_with_save::<SessionEntity>();
1532
1533 let mut migrator = Migrator::new();
1534 migrator.register(path).unwrap();
1535 migrator
1536 }
1537
1538 #[tokio::test]
1539 async fn test_async_dir_storage_new_creates_directory() {
1540 let temp_dir = TempDir::new().unwrap();
1541 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1542 temp_dir.path().to_path_buf(),
1543 ));
1544
1545 let migrator = Migrator::new();
1546 let strategy = DirStorageStrategy::default();
1547
1548 let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1549 .await
1550 .unwrap();
1551
1552 assert!(storage.base_path.exists());
1554 assert!(storage.base_path.is_dir());
1555 assert!(storage.base_path.ends_with("data/testapp/sessions"));
1556 }
1557
1558 #[tokio::test]
1559 async fn test_async_dir_storage_save_and_load_roundtrip() {
1560 let temp_dir = TempDir::new().unwrap();
1561 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1562 temp_dir.path().to_path_buf(),
1563 ));
1564
1565 let migrator = setup_session_migrator();
1566 let strategy = DirStorageStrategy::default();
1567 let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1568 .await
1569 .unwrap();
1570
1571 let sessions = vec![
1573 SessionEntity {
1574 id: "session-1".to_string(),
1575 user_id: "user-1".to_string(),
1576 created_at: Some("2024-01-01".to_string()),
1577 },
1578 SessionEntity {
1579 id: "session-2".to_string(),
1580 user_id: "user-2".to_string(),
1581 created_at: None,
1582 },
1583 SessionEntity {
1584 id: "session-3".to_string(),
1585 user_id: "user-3".to_string(),
1586 created_at: Some("2024-03-01".to_string()),
1587 },
1588 ];
1589
1590 for session in &sessions {
1592 storage
1593 .save("session", &session.id, session.clone())
1594 .await
1595 .unwrap();
1596 }
1597
1598 for session in &sessions {
1600 let loaded: SessionEntity = storage.load("session", &session.id).await.unwrap();
1601 assert_eq!(loaded.id, session.id);
1602 assert_eq!(loaded.user_id, session.user_id);
1603 assert_eq!(loaded.created_at, session.created_at);
1604 }
1605 }
1606
1607 #[tokio::test]
1608 async fn test_async_dir_storage_list_ids() {
1609 let temp_dir = TempDir::new().unwrap();
1610 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1611 temp_dir.path().to_path_buf(),
1612 ));
1613
1614 let migrator = setup_session_migrator();
1615 let strategy = DirStorageStrategy::default();
1616 let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1617 .await
1618 .unwrap();
1619
1620 let ids = vec!["session-c", "session-a", "session-b"];
1622 for id in &ids {
1623 let session = SessionEntity {
1624 id: id.to_string(),
1625 user_id: "user".to_string(),
1626 created_at: None,
1627 };
1628 storage.save("session", id, session).await.unwrap();
1629 }
1630
1631 let listed_ids = storage.list_ids().await.unwrap();
1633 assert_eq!(listed_ids.len(), 3);
1634 assert_eq!(listed_ids, vec!["session-a", "session-b", "session-c"]);
1636 }
1637
1638 #[tokio::test]
1639 async fn test_async_dir_storage_load_all() {
1640 let temp_dir = TempDir::new().unwrap();
1641 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1642 temp_dir.path().to_path_buf(),
1643 ));
1644
1645 let migrator = setup_session_migrator();
1646 let strategy = DirStorageStrategy::default();
1647 let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1648 .await
1649 .unwrap();
1650
1651 let sessions = vec![
1653 SessionEntity {
1654 id: "session-x".to_string(),
1655 user_id: "user-x".to_string(),
1656 created_at: Some("2024-01-01".to_string()),
1657 },
1658 SessionEntity {
1659 id: "session-y".to_string(),
1660 user_id: "user-y".to_string(),
1661 created_at: None,
1662 },
1663 SessionEntity {
1664 id: "session-z".to_string(),
1665 user_id: "user-z".to_string(),
1666 created_at: Some("2024-03-01".to_string()),
1667 },
1668 ];
1669
1670 for session in &sessions {
1671 storage
1672 .save("session", &session.id, session.clone())
1673 .await
1674 .unwrap();
1675 }
1676
1677 let results: Vec<(String, SessionEntity)> = storage.load_all("session").await.unwrap();
1679 assert_eq!(results.len(), 3);
1680
1681 for (id, loaded) in &results {
1683 let original = sessions.iter().find(|s| &s.id == id).unwrap();
1684 assert_eq!(loaded.id, original.id);
1685 assert_eq!(loaded.user_id, original.user_id);
1686 assert_eq!(loaded.created_at, original.created_at);
1687 }
1688 }
1689
1690 #[tokio::test]
1691 async fn test_async_dir_storage_delete() {
1692 let temp_dir = TempDir::new().unwrap();
1693 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1694 temp_dir.path().to_path_buf(),
1695 ));
1696
1697 let migrator = setup_session_migrator();
1698 let strategy = DirStorageStrategy::default();
1699 let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1700 .await
1701 .unwrap();
1702
1703 let session = SessionEntity {
1705 id: "session-delete".to_string(),
1706 user_id: "user-delete".to_string(),
1707 created_at: None,
1708 };
1709 storage
1710 .save("session", "session-delete", session)
1711 .await
1712 .unwrap();
1713
1714 assert!(storage.exists("session-delete").await.unwrap());
1716
1717 storage.delete("session-delete").await.unwrap();
1719
1720 assert!(!storage.exists("session-delete").await.unwrap());
1722 }
1723
1724 #[tokio::test]
1725 async fn test_async_dir_storage_filename_encoding_url_roundtrip() {
1726 let temp_dir = TempDir::new().unwrap();
1727 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1728 temp_dir.path().to_path_buf(),
1729 ));
1730
1731 let migrator = setup_session_migrator();
1732 let strategy =
1733 DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
1734 let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1735 .await
1736 .unwrap();
1737
1738 let complex_id = "user@example.com/path?query=1";
1740 let session = SessionEntity {
1741 id: complex_id.to_string(),
1742 user_id: "user-special".to_string(),
1743 created_at: Some("2024-05-01".to_string()),
1744 };
1745
1746 storage
1748 .save("session", complex_id, session.clone())
1749 .await
1750 .unwrap();
1751
1752 let encoded_id = urlencoding::encode(complex_id);
1754 let file_path = storage.base_path.join(format!("{}.json", encoded_id));
1755 assert!(file_path.exists());
1756
1757 let loaded: SessionEntity = storage.load("session", complex_id).await.unwrap();
1759 assert_eq!(loaded.id, session.id);
1760 assert_eq!(loaded.user_id, session.user_id);
1761 assert_eq!(loaded.created_at, session.created_at);
1762
1763 let ids = storage.list_ids().await.unwrap();
1765 assert_eq!(ids.len(), 1);
1766 assert_eq!(ids[0], complex_id);
1767 }
1768
1769 #[tokio::test]
1770 async fn test_async_dir_storage_filename_encoding_base64_roundtrip() {
1771 let temp_dir = TempDir::new().unwrap();
1772 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1773 temp_dir.path().to_path_buf(),
1774 ));
1775
1776 let migrator = setup_session_migrator();
1777 let strategy =
1778 DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
1779 let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1780 .await
1781 .unwrap();
1782
1783 let complex_id = "user@example.com/path?query=1&special=!@#$%";
1785 let session = SessionEntity {
1786 id: complex_id.to_string(),
1787 user_id: "user-base64".to_string(),
1788 created_at: Some("2024-06-01".to_string()),
1789 };
1790
1791 storage
1793 .save("session", complex_id, session.clone())
1794 .await
1795 .unwrap();
1796
1797 let encoded_id = URL_SAFE_NO_PAD.encode(complex_id.as_bytes());
1799 let file_path = storage.base_path.join(format!("{}.json", encoded_id));
1800 assert!(file_path.exists());
1801
1802 let loaded: SessionEntity = storage.load("session", complex_id).await.unwrap();
1804 assert_eq!(loaded.id, session.id);
1805 assert_eq!(loaded.user_id, session.user_id);
1806 assert_eq!(loaded.created_at, session.created_at);
1807
1808 let ids = storage.list_ids().await.unwrap();
1810 assert_eq!(ids.len(), 1);
1811 assert_eq!(ids[0], complex_id);
1812 }
1813 }
1814}
1815
1816#[cfg(test)]
1817mod tests {
1818 use super::*;
1819 use tempfile::TempDir;
1820
1821 #[test]
1822 fn test_filename_encoding_default() {
1823 assert_eq!(FilenameEncoding::default(), FilenameEncoding::Direct);
1824 }
1825
1826 #[test]
1827 fn test_dir_storage_strategy_default() {
1828 let strategy = DirStorageStrategy::default();
1829 assert_eq!(strategy.format, FormatStrategy::Json);
1830 assert_eq!(strategy.extension, None);
1831 assert_eq!(strategy.filename_encoding, FilenameEncoding::Direct);
1832 }
1833
1834 #[test]
1835 fn test_dir_storage_strategy_builder() {
1836 let strategy = DirStorageStrategy::new()
1837 .with_format(FormatStrategy::Toml)
1838 .with_extension("data")
1839 .with_filename_encoding(FilenameEncoding::Base64)
1840 .with_retry_count(5)
1841 .with_cleanup(false);
1842
1843 assert_eq!(strategy.format, FormatStrategy::Toml);
1844 assert_eq!(strategy.extension, Some("data".to_string()));
1845 assert_eq!(strategy.filename_encoding, FilenameEncoding::Base64);
1846 assert_eq!(strategy.atomic_write.retry_count, 5);
1847 assert!(!strategy.atomic_write.cleanup_tmp_files);
1848 }
1849
1850 #[test]
1851 fn test_dir_storage_strategy_get_extension() {
1852 let strategy1 = DirStorageStrategy::default();
1854 assert_eq!(strategy1.get_extension(), "json");
1855
1856 let strategy2 = DirStorageStrategy::default().with_format(FormatStrategy::Toml);
1858 assert_eq!(strategy2.get_extension(), "toml");
1859
1860 let strategy3 = DirStorageStrategy::default().with_extension("custom");
1862 assert_eq!(strategy3.get_extension(), "custom");
1863 }
1864
1865 #[test]
1866 fn test_dir_storage_new_creates_directory() {
1867 let temp_dir = TempDir::new().unwrap();
1868 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1869 temp_dir.path().to_path_buf(),
1870 ));
1871
1872 let migrator = Migrator::new();
1873 let strategy = DirStorageStrategy::default();
1874
1875 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1876
1877 assert!(storage.base_path.exists());
1879 assert!(storage.base_path.is_dir());
1880 assert!(storage.base_path.ends_with("data/testapp/sessions"));
1881 }
1882
1883 #[test]
1884 fn test_dir_storage_new_idempotent() {
1885 let temp_dir = TempDir::new().unwrap();
1886 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1887 temp_dir.path().to_path_buf(),
1888 ));
1889
1890 let migrator1 = Migrator::new();
1891 let migrator2 = Migrator::new();
1892 let strategy = DirStorageStrategy::default();
1893
1894 let storage1 =
1896 DirStorage::new(paths.clone(), "sessions", migrator1, strategy.clone()).unwrap();
1897 let storage2 = DirStorage::new(paths, "sessions", migrator2, strategy).unwrap();
1898
1899 assert_eq!(storage1.base_path, storage2.base_path);
1901 }
1902
1903 use crate::{FromDomain, IntoDomain, MigratesTo, Versioned};
1905 use serde::{Deserialize, Serialize};
1906
1907 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1908 struct SessionV1_0_0 {
1909 id: String,
1910 user_id: String,
1911 }
1912
1913 impl Versioned for SessionV1_0_0 {
1914 const VERSION: &'static str = "1.0.0";
1915 }
1916
1917 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1918 struct SessionV1_1_0 {
1919 id: String,
1920 user_id: String,
1921 created_at: Option<String>,
1922 }
1923
1924 impl Versioned for SessionV1_1_0 {
1925 const VERSION: &'static str = "1.1.0";
1926 }
1927
1928 impl MigratesTo<SessionV1_1_0> for SessionV1_0_0 {
1929 fn migrate(self) -> SessionV1_1_0 {
1930 SessionV1_1_0 {
1931 id: self.id,
1932 user_id: self.user_id,
1933 created_at: None,
1934 }
1935 }
1936 }
1937
1938 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1939 struct SessionEntity {
1940 id: String,
1941 user_id: String,
1942 created_at: Option<String>,
1943 }
1944
1945 impl IntoDomain<SessionEntity> for SessionV1_1_0 {
1946 fn into_domain(self) -> SessionEntity {
1947 SessionEntity {
1948 id: self.id,
1949 user_id: self.user_id,
1950 created_at: self.created_at,
1951 }
1952 }
1953 }
1954
1955 impl FromDomain<SessionEntity> for SessionV1_1_0 {
1956 fn from_domain(domain: SessionEntity) -> Self {
1957 SessionV1_1_0 {
1958 id: domain.id,
1959 user_id: domain.user_id,
1960 created_at: domain.created_at,
1961 }
1962 }
1963 }
1964
1965 fn setup_session_migrator() -> Migrator {
1966 let path = Migrator::define("session")
1967 .from::<SessionV1_0_0>()
1968 .step::<SessionV1_1_0>()
1969 .into_with_save::<SessionEntity>();
1970
1971 let mut migrator = Migrator::new();
1972 migrator.register(path).unwrap();
1973 migrator
1974 }
1975
1976 #[test]
1977 fn test_dir_storage_save_json() {
1978 let temp_dir = TempDir::new().unwrap();
1979 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1980 temp_dir.path().to_path_buf(),
1981 ));
1982
1983 let migrator = setup_session_migrator();
1984 let strategy = DirStorageStrategy::default().with_format(FormatStrategy::Json);
1985 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1986
1987 let session = SessionEntity {
1989 id: "session-123".to_string(),
1990 user_id: "user-456".to_string(),
1991 created_at: Some("2024-01-01T00:00:00Z".to_string()),
1992 };
1993
1994 storage.save("session", "session-123", session).unwrap();
1996
1997 let file_path = storage.base_path.join("session-123.json");
1999 assert!(file_path.exists());
2000
2001 let content = std::fs::read_to_string(&file_path).unwrap();
2003 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
2004 assert_eq!(json["version"], "1.1.0");
2005 assert_eq!(json["id"], "session-123");
2006 assert_eq!(json["user_id"], "user-456");
2007 }
2008
2009 #[test]
2010 fn test_dir_storage_save_toml() {
2011 let temp_dir = TempDir::new().unwrap();
2012 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2013 temp_dir.path().to_path_buf(),
2014 ));
2015
2016 let migrator = setup_session_migrator();
2017 let strategy = DirStorageStrategy::default().with_format(FormatStrategy::Toml);
2018 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2019
2020 let session = SessionEntity {
2022 id: "session-789".to_string(),
2023 user_id: "user-101".to_string(),
2024 created_at: Some("2024-01-15T10:30:00Z".to_string()),
2025 };
2026
2027 storage.save("session", "session-789", session).unwrap();
2029
2030 let file_path = storage.base_path.join("session-789.toml");
2032 assert!(file_path.exists());
2033
2034 let content = std::fs::read_to_string(&file_path).unwrap();
2036 let toml: toml::Value = toml::from_str(&content).unwrap();
2037 assert_eq!(toml["version"].as_str().unwrap(), "1.1.0");
2038 assert_eq!(toml["id"].as_str().unwrap(), "session-789");
2039 assert_eq!(toml["created_at"].as_str().unwrap(), "2024-01-15T10:30:00Z");
2040 }
2041
2042 #[test]
2043 fn test_dir_storage_save_with_invalid_id() {
2044 let temp_dir = TempDir::new().unwrap();
2045 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2046 temp_dir.path().to_path_buf(),
2047 ));
2048
2049 let migrator = setup_session_migrator();
2050 let strategy = DirStorageStrategy::default();
2051 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2052
2053 let session = SessionEntity {
2054 id: "invalid/id".to_string(),
2055 user_id: "user-456".to_string(),
2056 created_at: None,
2057 };
2058
2059 let result = storage.save("session", "invalid/id", session);
2061 assert!(result.is_err());
2062 assert!(matches!(
2063 result.unwrap_err(),
2064 crate::MigrationError::FilenameEncoding { .. }
2065 ));
2066 }
2067
2068 #[test]
2069 fn test_dir_storage_save_with_custom_extension() {
2070 let temp_dir = TempDir::new().unwrap();
2071 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2072 temp_dir.path().to_path_buf(),
2073 ));
2074
2075 let migrator = setup_session_migrator();
2076 let strategy = DirStorageStrategy::default()
2077 .with_format(FormatStrategy::Json)
2078 .with_extension("data");
2079 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2080
2081 let session = SessionEntity {
2082 id: "session-custom".to_string(),
2083 user_id: "user-999".to_string(),
2084 created_at: None,
2085 };
2086
2087 storage.save("session", "session-custom", session).unwrap();
2088
2089 let file_path = storage.base_path.join("session-custom.data");
2091 assert!(file_path.exists());
2092 }
2093
2094 #[test]
2095 fn test_dir_storage_save_overwrites_existing() {
2096 let temp_dir = TempDir::new().unwrap();
2097 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2098 temp_dir.path().to_path_buf(),
2099 ));
2100
2101 let migrator = setup_session_migrator();
2102 let strategy = DirStorageStrategy::default();
2103 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2104
2105 let session1 = SessionEntity {
2107 id: "session-overwrite".to_string(),
2108 user_id: "user-111".to_string(),
2109 created_at: Some("2024-01-01".to_string()),
2110 };
2111 storage
2112 .save("session", "session-overwrite", session1)
2113 .unwrap();
2114
2115 let session2 = SessionEntity {
2117 id: "session-overwrite".to_string(),
2118 user_id: "user-222".to_string(),
2119 created_at: Some("2024-01-02".to_string()),
2120 };
2121 storage
2122 .save("session", "session-overwrite", session2)
2123 .unwrap();
2124
2125 let file_path = storage.base_path.join("session-overwrite.json");
2127 let content = std::fs::read_to_string(&file_path).unwrap();
2128 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
2129 assert_eq!(json["user_id"], "user-222");
2130 assert_eq!(json["created_at"], "2024-01-02");
2131 }
2132
2133 #[test]
2134 fn test_dir_storage_load_success() {
2135 let temp_dir = TempDir::new().unwrap();
2136 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2137 temp_dir.path().to_path_buf(),
2138 ));
2139
2140 let migrator = setup_session_migrator();
2141 let strategy = DirStorageStrategy::default();
2142 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2143
2144 let session = SessionEntity {
2146 id: "session-load".to_string(),
2147 user_id: "user-999".to_string(),
2148 created_at: Some("2024-02-01".to_string()),
2149 };
2150 storage
2151 .save("session", "session-load", session.clone())
2152 .unwrap();
2153
2154 let loaded: SessionEntity = storage.load("session", "session-load").unwrap();
2156 assert_eq!(loaded.id, session.id);
2157 assert_eq!(loaded.user_id, session.user_id);
2158 assert_eq!(loaded.created_at, session.created_at);
2159 }
2160
2161 #[test]
2162 fn test_dir_storage_load_not_found() {
2163 let temp_dir = TempDir::new().unwrap();
2164 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2165 temp_dir.path().to_path_buf(),
2166 ));
2167
2168 let migrator = setup_session_migrator();
2169 let strategy = DirStorageStrategy::default();
2170 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2171
2172 let result: Result<SessionEntity, _> = storage.load("session", "non-existent");
2174 assert!(result.is_err());
2175 assert!(matches!(
2176 result.unwrap_err(),
2177 MigrationError::IoError { .. }
2178 ));
2179 }
2180
2181 #[test]
2182 fn test_dir_storage_save_and_load_roundtrip() {
2183 let temp_dir = TempDir::new().unwrap();
2184 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2185 temp_dir.path().to_path_buf(),
2186 ));
2187
2188 let migrator = setup_session_migrator();
2189 let strategy = DirStorageStrategy::default();
2190 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2191
2192 let sessions = vec![
2194 SessionEntity {
2195 id: "session-1".to_string(),
2196 user_id: "user-1".to_string(),
2197 created_at: Some("2024-01-01".to_string()),
2198 },
2199 SessionEntity {
2200 id: "session-2".to_string(),
2201 user_id: "user-2".to_string(),
2202 created_at: None,
2203 },
2204 SessionEntity {
2205 id: "session-3".to_string(),
2206 user_id: "user-3".to_string(),
2207 created_at: Some("2024-03-01".to_string()),
2208 },
2209 ];
2210
2211 for session in &sessions {
2213 storage
2214 .save("session", &session.id, session.clone())
2215 .unwrap();
2216 }
2217
2218 for session in &sessions {
2220 let loaded: SessionEntity = storage.load("session", &session.id).unwrap();
2221 assert_eq!(loaded.id, session.id);
2222 assert_eq!(loaded.user_id, session.user_id);
2223 assert_eq!(loaded.created_at, session.created_at);
2224 }
2225 }
2226
2227 #[test]
2228 fn test_dir_storage_list_ids_empty() {
2229 let temp_dir = TempDir::new().unwrap();
2230 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2231 temp_dir.path().to_path_buf(),
2232 ));
2233
2234 let migrator = setup_session_migrator();
2235 let strategy = DirStorageStrategy::default();
2236 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2237
2238 let ids = storage.list_ids().unwrap();
2240 assert!(ids.is_empty());
2241 }
2242
2243 #[test]
2244 fn test_dir_storage_list_ids() {
2245 let temp_dir = TempDir::new().unwrap();
2246 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2247 temp_dir.path().to_path_buf(),
2248 ));
2249
2250 let migrator = setup_session_migrator();
2251 let strategy = DirStorageStrategy::default();
2252 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2253
2254 let ids = vec!["session-c", "session-a", "session-b"];
2256 for id in &ids {
2257 let session = SessionEntity {
2258 id: id.to_string(),
2259 user_id: "user".to_string(),
2260 created_at: None,
2261 };
2262 storage.save("session", id, session).unwrap();
2263 }
2264
2265 let listed_ids = storage.list_ids().unwrap();
2267 assert_eq!(listed_ids.len(), 3);
2268 assert_eq!(listed_ids, vec!["session-a", "session-b", "session-c"]);
2270 }
2271
2272 #[test]
2273 fn test_dir_storage_load_all_empty() {
2274 let temp_dir = TempDir::new().unwrap();
2275 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2276 temp_dir.path().to_path_buf(),
2277 ));
2278
2279 let migrator = setup_session_migrator();
2280 let strategy = DirStorageStrategy::default();
2281 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2282
2283 let results: Vec<(String, SessionEntity)> = storage.load_all("session").unwrap();
2285 assert!(results.is_empty());
2286 }
2287
2288 #[test]
2289 fn test_dir_storage_load_all() {
2290 let temp_dir = TempDir::new().unwrap();
2291 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2292 temp_dir.path().to_path_buf(),
2293 ));
2294
2295 let migrator = setup_session_migrator();
2296 let strategy = DirStorageStrategy::default();
2297 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2298
2299 let sessions = vec![
2301 SessionEntity {
2302 id: "session-x".to_string(),
2303 user_id: "user-x".to_string(),
2304 created_at: Some("2024-01-01".to_string()),
2305 },
2306 SessionEntity {
2307 id: "session-y".to_string(),
2308 user_id: "user-y".to_string(),
2309 created_at: None,
2310 },
2311 SessionEntity {
2312 id: "session-z".to_string(),
2313 user_id: "user-z".to_string(),
2314 created_at: Some("2024-03-01".to_string()),
2315 },
2316 ];
2317
2318 for session in &sessions {
2319 storage
2320 .save("session", &session.id, session.clone())
2321 .unwrap();
2322 }
2323
2324 let results: Vec<(String, SessionEntity)> = storage.load_all("session").unwrap();
2326 assert_eq!(results.len(), 3);
2327
2328 for (id, loaded) in &results {
2330 let original = sessions.iter().find(|s| &s.id == id).unwrap();
2331 assert_eq!(loaded.id, original.id);
2332 assert_eq!(loaded.user_id, original.user_id);
2333 assert_eq!(loaded.created_at, original.created_at);
2334 }
2335 }
2336
2337 #[test]
2338 fn test_dir_storage_exists() {
2339 let temp_dir = TempDir::new().unwrap();
2340 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2341 temp_dir.path().to_path_buf(),
2342 ));
2343
2344 let migrator = setup_session_migrator();
2345 let strategy = DirStorageStrategy::default();
2346 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2347
2348 assert!(!storage.exists("session-exists").unwrap());
2350
2351 let session = SessionEntity {
2353 id: "session-exists".to_string(),
2354 user_id: "user-exists".to_string(),
2355 created_at: None,
2356 };
2357 storage.save("session", "session-exists", session).unwrap();
2358
2359 assert!(storage.exists("session-exists").unwrap());
2361 }
2362
2363 #[test]
2364 fn test_dir_storage_delete() {
2365 let temp_dir = TempDir::new().unwrap();
2366 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2367 temp_dir.path().to_path_buf(),
2368 ));
2369
2370 let migrator = setup_session_migrator();
2371 let strategy = DirStorageStrategy::default();
2372 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2373
2374 let session = SessionEntity {
2376 id: "session-delete".to_string(),
2377 user_id: "user-delete".to_string(),
2378 created_at: None,
2379 };
2380 storage.save("session", "session-delete", session).unwrap();
2381
2382 assert!(storage.exists("session-delete").unwrap());
2384
2385 storage.delete("session-delete").unwrap();
2387
2388 assert!(!storage.exists("session-delete").unwrap());
2390 }
2391
2392 #[test]
2393 fn test_dir_storage_delete_idempotent() {
2394 let temp_dir = TempDir::new().unwrap();
2395 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2396 temp_dir.path().to_path_buf(),
2397 ));
2398
2399 let migrator = setup_session_migrator();
2400 let strategy = DirStorageStrategy::default();
2401 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2402
2403 storage.delete("non-existent").unwrap();
2405
2406 storage.delete("non-existent").unwrap();
2408 }
2409
2410 #[test]
2411 fn test_dir_storage_load_toml() {
2412 let temp_dir = TempDir::new().unwrap();
2413 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2414 temp_dir.path().to_path_buf(),
2415 ));
2416
2417 let migrator = setup_session_migrator();
2418 let strategy = DirStorageStrategy::default().with_format(FormatStrategy::Toml);
2419 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2420
2421 let session = SessionEntity {
2423 id: "session-toml".to_string(),
2424 user_id: "user-toml".to_string(),
2425 created_at: Some("2024-04-01".to_string()),
2426 };
2427 storage
2428 .save("session", "session-toml", session.clone())
2429 .unwrap();
2430
2431 let loaded: SessionEntity = storage.load("session", "session-toml").unwrap();
2433 assert_eq!(loaded.id, session.id);
2434 assert_eq!(loaded.user_id, session.user_id);
2435 assert_eq!(loaded.created_at, session.created_at);
2436 }
2437
2438 #[test]
2439 fn test_dir_storage_list_ids_with_custom_extension() {
2440 let temp_dir = TempDir::new().unwrap();
2441 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2442 temp_dir.path().to_path_buf(),
2443 ));
2444
2445 let migrator = setup_session_migrator();
2446 let strategy = DirStorageStrategy::default().with_extension("data");
2447 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2448
2449 let session = SessionEntity {
2451 id: "session-ext".to_string(),
2452 user_id: "user-ext".to_string(),
2453 created_at: None,
2454 };
2455 storage.save("session", "session-ext", session).unwrap();
2456
2457 let ids = storage.list_ids().unwrap();
2459 assert_eq!(ids.len(), 1);
2460 assert_eq!(ids[0], "session-ext");
2461 }
2462
2463 #[test]
2464 fn test_dir_storage_load_all_atomic_failure() {
2465 let temp_dir = TempDir::new().unwrap();
2466 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2467 temp_dir.path().to_path_buf(),
2468 ));
2469
2470 let migrator = setup_session_migrator();
2471 let strategy = DirStorageStrategy::default();
2472 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2473
2474 let session1 = SessionEntity {
2476 id: "session-1".to_string(),
2477 user_id: "user-1".to_string(),
2478 created_at: None,
2479 };
2480 storage.save("session", "session-1", session1).unwrap();
2481
2482 let corrupted_path = storage.base_path.join("session-corrupted.json");
2484 std::fs::write(&corrupted_path, "invalid json {{{").unwrap();
2485
2486 let result: Result<Vec<(String, SessionEntity)>, _> = storage.load_all("session");
2488 assert!(result.is_err());
2489 }
2490
2491 #[test]
2492 fn test_dir_storage_filename_encoding_url_roundtrip() {
2493 let temp_dir = TempDir::new().unwrap();
2494 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2495 temp_dir.path().to_path_buf(),
2496 ));
2497
2498 let migrator = setup_session_migrator();
2499 let strategy =
2500 DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
2501 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2502
2503 let complex_id = "user@example.com/path?query=1";
2505 let session = SessionEntity {
2506 id: complex_id.to_string(),
2507 user_id: "user-special".to_string(),
2508 created_at: Some("2024-05-01".to_string()),
2509 };
2510
2511 storage
2513 .save("session", complex_id, session.clone())
2514 .unwrap();
2515
2516 let encoded_id = urlencoding::encode(complex_id);
2518 let file_path = storage.base_path.join(format!("{}.json", encoded_id));
2519 assert!(file_path.exists());
2520
2521 let loaded: SessionEntity = storage.load("session", complex_id).unwrap();
2523 assert_eq!(loaded.id, session.id);
2524 assert_eq!(loaded.user_id, session.user_id);
2525 assert_eq!(loaded.created_at, session.created_at);
2526
2527 let ids = storage.list_ids().unwrap();
2529 assert_eq!(ids.len(), 1);
2530 assert_eq!(ids[0], complex_id);
2531 }
2532
2533 #[test]
2534 fn test_dir_storage_filename_encoding_base64_roundtrip() {
2535 let temp_dir = TempDir::new().unwrap();
2536 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2537 temp_dir.path().to_path_buf(),
2538 ));
2539
2540 let migrator = setup_session_migrator();
2541 let strategy =
2542 DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
2543 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2544
2545 let complex_id = "user@example.com/path?query=1&special=!@#$%";
2547 let session = SessionEntity {
2548 id: complex_id.to_string(),
2549 user_id: "user-base64".to_string(),
2550 created_at: Some("2024-06-01".to_string()),
2551 };
2552
2553 storage
2555 .save("session", complex_id, session.clone())
2556 .unwrap();
2557
2558 let encoded_id = URL_SAFE_NO_PAD.encode(complex_id.as_bytes());
2560 let file_path = storage.base_path.join(format!("{}.json", encoded_id));
2561 assert!(file_path.exists());
2562
2563 let loaded: SessionEntity = storage.load("session", complex_id).unwrap();
2565 assert_eq!(loaded.id, session.id);
2566 assert_eq!(loaded.user_id, session.user_id);
2567 assert_eq!(loaded.created_at, session.created_at);
2568
2569 let ids = storage.list_ids().unwrap();
2571 assert_eq!(ids.len(), 1);
2572 assert_eq!(ids[0], complex_id);
2573 }
2574
2575 #[test]
2576 fn test_decode_id_error_handling() {
2577 let temp_dir = TempDir::new().unwrap();
2578 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2579 temp_dir.path().to_path_buf(),
2580 ));
2581
2582 let migrator_url = setup_session_migrator();
2586 let strategy_url =
2587 DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
2588 let storage_url =
2589 DirStorage::new(paths.clone(), "sessions_url", migrator_url, strategy_url).unwrap();
2590
2591 let invalid_url_encoded = "%C0%C1"; let result = storage_url.decode_id(invalid_url_encoded);
2594 assert!(result.is_err());
2595 if let Err(MigrationError::FilenameEncoding { id, reason }) = result {
2596 assert_eq!(id, invalid_url_encoded);
2597 assert!(reason.contains("Failed to URL-decode filename"));
2598 }
2599
2600 let migrator_base64 = setup_session_migrator();
2602 let strategy_base64 =
2603 DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
2604 let storage_base64 =
2605 DirStorage::new(paths, "sessions_base64", migrator_base64, strategy_base64).unwrap();
2606
2607 let invalid_base64 = "!!!invalid@@@";
2609 let result = storage_base64.decode_id(invalid_base64);
2610 assert!(result.is_err());
2611 if let Err(MigrationError::FilenameEncoding { id, reason }) = result {
2612 assert_eq!(id, invalid_base64);
2613 assert!(reason.contains("Failed to Base64-decode filename"));
2614 }
2615
2616 let invalid_utf8_bytes = vec![0xFF, 0xFE, 0xFD];
2619 let valid_base64_invalid_utf8 = URL_SAFE_NO_PAD.encode(&invalid_utf8_bytes);
2620 let result = storage_base64.decode_id(&valid_base64_invalid_utf8);
2621 assert!(result.is_err());
2622 if let Err(MigrationError::FilenameEncoding { id, reason }) = result {
2623 assert_eq!(id, valid_base64_invalid_utf8);
2624 assert!(reason.contains("Failed to convert Base64-decoded bytes to UTF-8"));
2625 }
2626 }
2627
2628 #[test]
2629 fn test_dir_storage_base_path() {
2630 let temp_dir = TempDir::new().unwrap();
2631 let domain_name = "test_sessions";
2632 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2633 temp_dir.path().to_path_buf(),
2634 ));
2635
2636 let migrator = Migrator::new();
2637 let strategy = DirStorageStrategy::default();
2638
2639 let storage = DirStorage::new(paths, domain_name, migrator, strategy).unwrap();
2640
2641 let returned_path = storage.base_path();
2643 assert!(returned_path.ends_with(domain_name));
2644 assert!(returned_path.exists());
2645 }
2646}