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(feature = "async")]
873pub use async_impl::AsyncDirStorage;
874
875#[cfg(feature = "async")]
876mod async_impl {
877 use crate::{AppPaths, MigrationError, Migrator};
878 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
879 use base64::Engine;
880 use std::path::{Path, PathBuf};
881 use tokio::io::AsyncWriteExt;
882
883 use super::{json_to_toml, toml_to_json, DirStorageStrategy, FilenameEncoding, FormatStrategy};
884
885 pub struct AsyncDirStorage {
890 base_path: PathBuf,
892 migrator: Migrator,
894 strategy: DirStorageStrategy,
896 }
897
898 impl AsyncDirStorage {
899 pub async fn new(
918 paths: AppPaths,
919 domain_name: &str,
920 migrator: Migrator,
921 strategy: DirStorageStrategy,
922 ) -> Result<Self, MigrationError> {
923 let base_path = paths.data_dir()?.join(domain_name);
925
926 if !tokio::fs::try_exists(&base_path).await.unwrap_or(false) {
928 tokio::fs::create_dir_all(&base_path).await.map_err(|e| {
929 MigrationError::IoError {
930 path: base_path.display().to_string(),
931 error: e.to_string(),
932 }
933 })?;
934 }
935
936 Ok(Self {
937 base_path,
938 migrator,
939 strategy,
940 })
941 }
942
943 pub async fn save<T>(
965 &self,
966 entity_name: &str,
967 id: &str,
968 entity: T,
969 ) -> Result<(), MigrationError>
970 where
971 T: serde::Serialize,
972 {
973 let json_string = self.migrator.save_domain_flat(entity_name, entity)?;
975
976 let versioned_value: serde_json::Value = serde_json::from_str(&json_string)
978 .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
979
980 let content = self.serialize_content(&versioned_value)?;
982
983 let file_path = self.id_to_path(id)?;
985
986 self.atomic_write(&file_path, &content).await?;
988
989 Ok(())
990 }
991
992 pub async fn load<D>(&self, entity_name: &str, id: &str) -> Result<D, MigrationError>
1014 where
1015 D: serde::de::DeserializeOwned,
1016 {
1017 let file_path = self.id_to_path(id)?;
1019
1020 if !tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
1022 return Err(MigrationError::IoError {
1023 path: file_path.display().to_string(),
1024 error: "File not found".to_string(),
1025 });
1026 }
1027
1028 let content = tokio::fs::read_to_string(&file_path).await.map_err(|e| {
1030 MigrationError::IoError {
1031 path: file_path.display().to_string(),
1032 error: e.to_string(),
1033 }
1034 })?;
1035
1036 let value = self.deserialize_content(&content)?;
1038
1039 self.migrator.load_flat_from(entity_name, value)
1041 }
1042
1043 pub async fn list_ids(&self) -> Result<Vec<String>, MigrationError> {
1055 let mut entries = tokio::fs::read_dir(&self.base_path).await.map_err(|e| {
1057 MigrationError::IoError {
1058 path: self.base_path.display().to_string(),
1059 error: e.to_string(),
1060 }
1061 })?;
1062
1063 let extension = self.strategy.get_extension();
1064 let mut ids = Vec::new();
1065
1066 while let Some(entry) =
1067 entries
1068 .next_entry()
1069 .await
1070 .map_err(|e| MigrationError::IoError {
1071 path: self.base_path.display().to_string(),
1072 error: e.to_string(),
1073 })?
1074 {
1075 let path = entry.path();
1076
1077 let metadata =
1079 tokio::fs::metadata(&path)
1080 .await
1081 .map_err(|e| MigrationError::IoError {
1082 path: path.display().to_string(),
1083 error: e.to_string(),
1084 })?;
1085
1086 if metadata.is_file() {
1087 if let Some(ext) = path.extension() {
1088 if ext == extension.as_str() {
1089 if let Some(id) = self.path_to_id(&path)? {
1091 ids.push(id);
1092 }
1093 }
1094 }
1095 }
1096 }
1097
1098 ids.sort();
1100 Ok(ids)
1101 }
1102
1103 pub async fn load_all<D>(
1118 &self,
1119 entity_name: &str,
1120 ) -> Result<Vec<(String, D)>, MigrationError>
1121 where
1122 D: serde::de::DeserializeOwned,
1123 {
1124 let ids = self.list_ids().await?;
1125 let mut results = Vec::new();
1126
1127 for id in ids {
1128 let entity = self.load(entity_name, &id).await?;
1129 results.push((id, entity));
1130 }
1131
1132 Ok(results)
1133 }
1134
1135 pub async fn exists(&self, id: &str) -> Result<bool, MigrationError> {
1145 let file_path = self.id_to_path(id)?;
1146
1147 if !tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
1148 return Ok(false);
1149 }
1150
1151 let metadata =
1152 tokio::fs::metadata(&file_path)
1153 .await
1154 .map_err(|e| MigrationError::IoError {
1155 path: file_path.display().to_string(),
1156 error: e.to_string(),
1157 })?;
1158
1159 Ok(metadata.is_file())
1160 }
1161
1162 pub async fn delete(&self, id: &str) -> Result<(), MigrationError> {
1176 let file_path = self.id_to_path(id)?;
1177
1178 if tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
1179 tokio::fs::remove_file(&file_path)
1180 .await
1181 .map_err(|e| MigrationError::IoError {
1182 path: file_path.display().to_string(),
1183 error: e.to_string(),
1184 })?;
1185 }
1186
1187 Ok(())
1188 }
1189
1190 fn id_to_path(&self, id: &str) -> Result<PathBuf, MigrationError> {
1196 let encoded_id = self.encode_id(id)?;
1197 let extension = self.strategy.get_extension();
1198 let filename = format!("{}.{}", encoded_id, extension);
1199 Ok(self.base_path.join(filename))
1200 }
1201
1202 fn encode_id(&self, id: &str) -> Result<String, MigrationError> {
1204 match self.strategy.filename_encoding {
1205 FilenameEncoding::Direct => {
1206 if id
1208 .chars()
1209 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1210 {
1211 Ok(id.to_string())
1212 } else {
1213 Err(MigrationError::FilenameEncoding {
1214 id: id.to_string(),
1215 reason: "ID contains invalid characters for Direct encoding. Only alphanumeric, '-', and '_' are allowed.".to_string(),
1216 })
1217 }
1218 }
1219 FilenameEncoding::UrlEncode => {
1220 Ok(urlencoding::encode(id).into_owned())
1222 }
1223 FilenameEncoding::Base64 => {
1224 Ok(URL_SAFE_NO_PAD.encode(id.as_bytes()))
1226 }
1227 }
1228 }
1229
1230 fn serialize_content(&self, value: &serde_json::Value) -> Result<String, MigrationError> {
1232 match self.strategy.format {
1233 FormatStrategy::Json => serde_json::to_string_pretty(value)
1234 .map_err(|e| MigrationError::SerializationError(e.to_string())),
1235 FormatStrategy::Toml => {
1236 let toml_value = json_to_toml(value)?;
1237 toml::to_string_pretty(&toml_value)
1238 .map_err(|e| MigrationError::TomlSerializeError(e.to_string()))
1239 }
1240 }
1241 }
1242
1243 async fn atomic_write(&self, path: &Path, content: &str) -> Result<(), MigrationError> {
1248 if let Some(parent) = path.parent() {
1250 if !tokio::fs::try_exists(parent).await.unwrap_or(false) {
1251 tokio::fs::create_dir_all(parent).await.map_err(|e| {
1252 MigrationError::IoError {
1253 path: parent.display().to_string(),
1254 error: e.to_string(),
1255 }
1256 })?;
1257 }
1258 }
1259
1260 let tmp_path = self.get_temp_path(path)?;
1262
1263 let mut tmp_file =
1265 tokio::fs::File::create(&tmp_path)
1266 .await
1267 .map_err(|e| MigrationError::IoError {
1268 path: tmp_path.display().to_string(),
1269 error: e.to_string(),
1270 })?;
1271
1272 tmp_file
1273 .write_all(content.as_bytes())
1274 .await
1275 .map_err(|e| MigrationError::IoError {
1276 path: tmp_path.display().to_string(),
1277 error: e.to_string(),
1278 })?;
1279
1280 tmp_file
1282 .sync_all()
1283 .await
1284 .map_err(|e| MigrationError::IoError {
1285 path: tmp_path.display().to_string(),
1286 error: e.to_string(),
1287 })?;
1288
1289 drop(tmp_file);
1290
1291 self.atomic_rename(&tmp_path, path).await?;
1293
1294 if self.strategy.atomic_write.cleanup_tmp_files {
1296 let _ = self.cleanup_temp_files(path).await;
1297 }
1298
1299 Ok(())
1300 }
1301
1302 fn get_temp_path(&self, target_path: &Path) -> Result<PathBuf, MigrationError> {
1304 let parent = target_path.parent().ok_or_else(|| {
1305 MigrationError::PathResolution("Path has no parent directory".to_string())
1306 })?;
1307
1308 let file_name = target_path.file_name().ok_or_else(|| {
1309 MigrationError::PathResolution("Path has no file name".to_string())
1310 })?;
1311
1312 let tmp_name = format!(
1313 ".{}.tmp.{}",
1314 file_name.to_string_lossy(),
1315 std::process::id()
1316 );
1317 Ok(parent.join(tmp_name))
1318 }
1319
1320 async fn atomic_rename(
1322 &self,
1323 tmp_path: &Path,
1324 target_path: &Path,
1325 ) -> Result<(), MigrationError> {
1326 let mut last_error = None;
1327
1328 for attempt in 0..self.strategy.atomic_write.retry_count {
1329 match tokio::fs::rename(tmp_path, target_path).await {
1330 Ok(()) => return Ok(()),
1331 Err(e) => {
1332 last_error = Some(e);
1333 if attempt + 1 < self.strategy.atomic_write.retry_count {
1334 tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
1336 }
1337 }
1338 }
1339 }
1340
1341 Err(MigrationError::IoError {
1342 path: target_path.display().to_string(),
1343 error: format!(
1344 "Failed to rename after {} attempts: {}",
1345 self.strategy.atomic_write.retry_count,
1346 last_error.unwrap()
1347 ),
1348 })
1349 }
1350
1351 async fn cleanup_temp_files(&self, target_path: &Path) -> std::io::Result<()> {
1353 let parent = match target_path.parent() {
1354 Some(p) => p,
1355 None => return Ok(()),
1356 };
1357
1358 let file_name = match target_path.file_name() {
1359 Some(f) => f.to_string_lossy(),
1360 None => return Ok(()),
1361 };
1362
1363 let prefix = format!(".{}.tmp.", file_name);
1364
1365 let mut entries = tokio::fs::read_dir(parent).await?;
1366 while let Some(entry) = entries.next_entry().await? {
1367 if let Ok(name) = entry.file_name().into_string() {
1368 if name.starts_with(&prefix) {
1369 let _ = tokio::fs::remove_file(entry.path()).await;
1371 }
1372 }
1373 }
1374
1375 Ok(())
1376 }
1377
1378 fn deserialize_content(&self, content: &str) -> Result<serde_json::Value, MigrationError> {
1380 match self.strategy.format {
1381 FormatStrategy::Json => serde_json::from_str(content)
1382 .map_err(|e| MigrationError::DeserializationError(e.to_string())),
1383 FormatStrategy::Toml => {
1384 let toml_value: toml::Value = toml::from_str(content)
1385 .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
1386 toml_to_json(toml_value)
1387 }
1388 }
1389 }
1390
1391 fn path_to_id(&self, path: &Path) -> Result<Option<String>, MigrationError> {
1393 let file_stem = match path.file_stem() {
1395 Some(stem) => stem.to_string_lossy(),
1396 None => return Ok(None),
1397 };
1398
1399 let id = self.decode_id(&file_stem)?;
1401 Ok(Some(id))
1402 }
1403
1404 fn decode_id(&self, filename_stem: &str) -> Result<String, MigrationError> {
1406 match self.strategy.filename_encoding {
1407 FilenameEncoding::Direct => {
1408 Ok(filename_stem.to_string())
1410 }
1411 FilenameEncoding::UrlEncode => {
1412 urlencoding::decode(filename_stem)
1414 .map(|s| s.into_owned())
1415 .map_err(|e| MigrationError::FilenameEncoding {
1416 id: filename_stem.to_string(),
1417 reason: format!("Failed to URL-decode filename: {}", e),
1418 })
1419 }
1420 FilenameEncoding::Base64 => {
1421 URL_SAFE_NO_PAD
1423 .decode(filename_stem.as_bytes())
1424 .map_err(|e| MigrationError::FilenameEncoding {
1425 id: filename_stem.to_string(),
1426 reason: format!("Failed to Base64-decode filename: {}", e),
1427 })
1428 .and_then(|bytes| {
1429 String::from_utf8(bytes).map_err(|e| MigrationError::FilenameEncoding {
1430 id: filename_stem.to_string(),
1431 reason: format!(
1432 "Failed to convert Base64-decoded bytes to UTF-8: {}",
1433 e
1434 ),
1435 })
1436 })
1437 }
1438 }
1439 }
1440 }
1441
1442 #[cfg(all(test, feature = "async"))]
1444 mod async_tests {
1445 use super::*;
1446 use crate::{FromDomain, IntoDomain, MigratesTo, Versioned};
1447 use serde::{Deserialize, Serialize};
1448 use tempfile::TempDir;
1449
1450 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1452 struct SessionV1_0_0 {
1453 id: String,
1454 user_id: String,
1455 }
1456
1457 impl Versioned for SessionV1_0_0 {
1458 const VERSION: &'static str = "1.0.0";
1459 }
1460
1461 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1462 struct SessionV1_1_0 {
1463 id: String,
1464 user_id: String,
1465 created_at: Option<String>,
1466 }
1467
1468 impl Versioned for SessionV1_1_0 {
1469 const VERSION: &'static str = "1.1.0";
1470 }
1471
1472 impl MigratesTo<SessionV1_1_0> for SessionV1_0_0 {
1473 fn migrate(self) -> SessionV1_1_0 {
1474 SessionV1_1_0 {
1475 id: self.id,
1476 user_id: self.user_id,
1477 created_at: None,
1478 }
1479 }
1480 }
1481
1482 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1483 struct SessionEntity {
1484 id: String,
1485 user_id: String,
1486 created_at: Option<String>,
1487 }
1488
1489 impl IntoDomain<SessionEntity> for SessionV1_1_0 {
1490 fn into_domain(self) -> SessionEntity {
1491 SessionEntity {
1492 id: self.id,
1493 user_id: self.user_id,
1494 created_at: self.created_at,
1495 }
1496 }
1497 }
1498
1499 impl FromDomain<SessionEntity> for SessionV1_1_0 {
1500 fn from_domain(domain: SessionEntity) -> Self {
1501 SessionV1_1_0 {
1502 id: domain.id,
1503 user_id: domain.user_id,
1504 created_at: domain.created_at,
1505 }
1506 }
1507 }
1508
1509 fn setup_session_migrator() -> Migrator {
1510 let path = Migrator::define("session")
1511 .from::<SessionV1_0_0>()
1512 .step::<SessionV1_1_0>()
1513 .into_with_save::<SessionEntity>();
1514
1515 let mut migrator = Migrator::new();
1516 migrator.register(path).unwrap();
1517 migrator
1518 }
1519
1520 #[tokio::test]
1521 async fn test_async_dir_storage_new_creates_directory() {
1522 let temp_dir = TempDir::new().unwrap();
1523 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1524 temp_dir.path().to_path_buf(),
1525 ));
1526
1527 let migrator = Migrator::new();
1528 let strategy = DirStorageStrategy::default();
1529
1530 let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1531 .await
1532 .unwrap();
1533
1534 assert!(storage.base_path.exists());
1536 assert!(storage.base_path.is_dir());
1537 assert!(storage.base_path.ends_with("data/testapp/sessions"));
1538 }
1539
1540 #[tokio::test]
1541 async fn test_async_dir_storage_save_and_load_roundtrip() {
1542 let temp_dir = TempDir::new().unwrap();
1543 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1544 temp_dir.path().to_path_buf(),
1545 ));
1546
1547 let migrator = setup_session_migrator();
1548 let strategy = DirStorageStrategy::default();
1549 let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1550 .await
1551 .unwrap();
1552
1553 let sessions = vec![
1555 SessionEntity {
1556 id: "session-1".to_string(),
1557 user_id: "user-1".to_string(),
1558 created_at: Some("2024-01-01".to_string()),
1559 },
1560 SessionEntity {
1561 id: "session-2".to_string(),
1562 user_id: "user-2".to_string(),
1563 created_at: None,
1564 },
1565 SessionEntity {
1566 id: "session-3".to_string(),
1567 user_id: "user-3".to_string(),
1568 created_at: Some("2024-03-01".to_string()),
1569 },
1570 ];
1571
1572 for session in &sessions {
1574 storage
1575 .save("session", &session.id, session.clone())
1576 .await
1577 .unwrap();
1578 }
1579
1580 for session in &sessions {
1582 let loaded: SessionEntity = storage.load("session", &session.id).await.unwrap();
1583 assert_eq!(loaded.id, session.id);
1584 assert_eq!(loaded.user_id, session.user_id);
1585 assert_eq!(loaded.created_at, session.created_at);
1586 }
1587 }
1588
1589 #[tokio::test]
1590 async fn test_async_dir_storage_list_ids() {
1591 let temp_dir = TempDir::new().unwrap();
1592 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1593 temp_dir.path().to_path_buf(),
1594 ));
1595
1596 let migrator = setup_session_migrator();
1597 let strategy = DirStorageStrategy::default();
1598 let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1599 .await
1600 .unwrap();
1601
1602 let ids = vec!["session-c", "session-a", "session-b"];
1604 for id in &ids {
1605 let session = SessionEntity {
1606 id: id.to_string(),
1607 user_id: "user".to_string(),
1608 created_at: None,
1609 };
1610 storage.save("session", id, session).await.unwrap();
1611 }
1612
1613 let listed_ids = storage.list_ids().await.unwrap();
1615 assert_eq!(listed_ids.len(), 3);
1616 assert_eq!(listed_ids, vec!["session-a", "session-b", "session-c"]);
1618 }
1619
1620 #[tokio::test]
1621 async fn test_async_dir_storage_load_all() {
1622 let temp_dir = TempDir::new().unwrap();
1623 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1624 temp_dir.path().to_path_buf(),
1625 ));
1626
1627 let migrator = setup_session_migrator();
1628 let strategy = DirStorageStrategy::default();
1629 let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1630 .await
1631 .unwrap();
1632
1633 let sessions = vec![
1635 SessionEntity {
1636 id: "session-x".to_string(),
1637 user_id: "user-x".to_string(),
1638 created_at: Some("2024-01-01".to_string()),
1639 },
1640 SessionEntity {
1641 id: "session-y".to_string(),
1642 user_id: "user-y".to_string(),
1643 created_at: None,
1644 },
1645 SessionEntity {
1646 id: "session-z".to_string(),
1647 user_id: "user-z".to_string(),
1648 created_at: Some("2024-03-01".to_string()),
1649 },
1650 ];
1651
1652 for session in &sessions {
1653 storage
1654 .save("session", &session.id, session.clone())
1655 .await
1656 .unwrap();
1657 }
1658
1659 let results: Vec<(String, SessionEntity)> = storage.load_all("session").await.unwrap();
1661 assert_eq!(results.len(), 3);
1662
1663 for (id, loaded) in &results {
1665 let original = sessions.iter().find(|s| &s.id == id).unwrap();
1666 assert_eq!(loaded.id, original.id);
1667 assert_eq!(loaded.user_id, original.user_id);
1668 assert_eq!(loaded.created_at, original.created_at);
1669 }
1670 }
1671
1672 #[tokio::test]
1673 async fn test_async_dir_storage_delete() {
1674 let temp_dir = TempDir::new().unwrap();
1675 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1676 temp_dir.path().to_path_buf(),
1677 ));
1678
1679 let migrator = setup_session_migrator();
1680 let strategy = DirStorageStrategy::default();
1681 let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1682 .await
1683 .unwrap();
1684
1685 let session = SessionEntity {
1687 id: "session-delete".to_string(),
1688 user_id: "user-delete".to_string(),
1689 created_at: None,
1690 };
1691 storage
1692 .save("session", "session-delete", session)
1693 .await
1694 .unwrap();
1695
1696 assert!(storage.exists("session-delete").await.unwrap());
1698
1699 storage.delete("session-delete").await.unwrap();
1701
1702 assert!(!storage.exists("session-delete").await.unwrap());
1704 }
1705
1706 #[tokio::test]
1707 async fn test_async_dir_storage_filename_encoding_url_roundtrip() {
1708 let temp_dir = TempDir::new().unwrap();
1709 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1710 temp_dir.path().to_path_buf(),
1711 ));
1712
1713 let migrator = setup_session_migrator();
1714 let strategy =
1715 DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
1716 let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1717 .await
1718 .unwrap();
1719
1720 let complex_id = "user@example.com/path?query=1";
1722 let session = SessionEntity {
1723 id: complex_id.to_string(),
1724 user_id: "user-special".to_string(),
1725 created_at: Some("2024-05-01".to_string()),
1726 };
1727
1728 storage
1730 .save("session", complex_id, session.clone())
1731 .await
1732 .unwrap();
1733
1734 let encoded_id = urlencoding::encode(complex_id);
1736 let file_path = storage.base_path.join(format!("{}.json", encoded_id));
1737 assert!(file_path.exists());
1738
1739 let loaded: SessionEntity = storage.load("session", complex_id).await.unwrap();
1741 assert_eq!(loaded.id, session.id);
1742 assert_eq!(loaded.user_id, session.user_id);
1743 assert_eq!(loaded.created_at, session.created_at);
1744
1745 let ids = storage.list_ids().await.unwrap();
1747 assert_eq!(ids.len(), 1);
1748 assert_eq!(ids[0], complex_id);
1749 }
1750
1751 #[tokio::test]
1752 async fn test_async_dir_storage_filename_encoding_base64_roundtrip() {
1753 let temp_dir = TempDir::new().unwrap();
1754 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1755 temp_dir.path().to_path_buf(),
1756 ));
1757
1758 let migrator = setup_session_migrator();
1759 let strategy =
1760 DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
1761 let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1762 .await
1763 .unwrap();
1764
1765 let complex_id = "user@example.com/path?query=1&special=!@#$%";
1767 let session = SessionEntity {
1768 id: complex_id.to_string(),
1769 user_id: "user-base64".to_string(),
1770 created_at: Some("2024-06-01".to_string()),
1771 };
1772
1773 storage
1775 .save("session", complex_id, session.clone())
1776 .await
1777 .unwrap();
1778
1779 let encoded_id = URL_SAFE_NO_PAD.encode(complex_id.as_bytes());
1781 let file_path = storage.base_path.join(format!("{}.json", encoded_id));
1782 assert!(file_path.exists());
1783
1784 let loaded: SessionEntity = storage.load("session", complex_id).await.unwrap();
1786 assert_eq!(loaded.id, session.id);
1787 assert_eq!(loaded.user_id, session.user_id);
1788 assert_eq!(loaded.created_at, session.created_at);
1789
1790 let ids = storage.list_ids().await.unwrap();
1792 assert_eq!(ids.len(), 1);
1793 assert_eq!(ids[0], complex_id);
1794 }
1795 }
1796}
1797
1798#[cfg(test)]
1799mod tests {
1800 use super::*;
1801 use tempfile::TempDir;
1802
1803 #[test]
1804 fn test_filename_encoding_default() {
1805 assert_eq!(FilenameEncoding::default(), FilenameEncoding::Direct);
1806 }
1807
1808 #[test]
1809 fn test_dir_storage_strategy_default() {
1810 let strategy = DirStorageStrategy::default();
1811 assert_eq!(strategy.format, FormatStrategy::Json);
1812 assert_eq!(strategy.extension, None);
1813 assert_eq!(strategy.filename_encoding, FilenameEncoding::Direct);
1814 }
1815
1816 #[test]
1817 fn test_dir_storage_strategy_builder() {
1818 let strategy = DirStorageStrategy::new()
1819 .with_format(FormatStrategy::Toml)
1820 .with_extension("data")
1821 .with_filename_encoding(FilenameEncoding::Base64)
1822 .with_retry_count(5)
1823 .with_cleanup(false);
1824
1825 assert_eq!(strategy.format, FormatStrategy::Toml);
1826 assert_eq!(strategy.extension, Some("data".to_string()));
1827 assert_eq!(strategy.filename_encoding, FilenameEncoding::Base64);
1828 assert_eq!(strategy.atomic_write.retry_count, 5);
1829 assert!(!strategy.atomic_write.cleanup_tmp_files);
1830 }
1831
1832 #[test]
1833 fn test_dir_storage_strategy_get_extension() {
1834 let strategy1 = DirStorageStrategy::default();
1836 assert_eq!(strategy1.get_extension(), "json");
1837
1838 let strategy2 = DirStorageStrategy::default().with_format(FormatStrategy::Toml);
1840 assert_eq!(strategy2.get_extension(), "toml");
1841
1842 let strategy3 = DirStorageStrategy::default().with_extension("custom");
1844 assert_eq!(strategy3.get_extension(), "custom");
1845 }
1846
1847 #[test]
1848 fn test_dir_storage_new_creates_directory() {
1849 let temp_dir = TempDir::new().unwrap();
1850 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1851 temp_dir.path().to_path_buf(),
1852 ));
1853
1854 let migrator = Migrator::new();
1855 let strategy = DirStorageStrategy::default();
1856
1857 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1858
1859 assert!(storage.base_path.exists());
1861 assert!(storage.base_path.is_dir());
1862 assert!(storage.base_path.ends_with("data/testapp/sessions"));
1863 }
1864
1865 #[test]
1866 fn test_dir_storage_new_idempotent() {
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 migrator1 = Migrator::new();
1873 let migrator2 = Migrator::new();
1874 let strategy = DirStorageStrategy::default();
1875
1876 let storage1 =
1878 DirStorage::new(paths.clone(), "sessions", migrator1, strategy.clone()).unwrap();
1879 let storage2 = DirStorage::new(paths, "sessions", migrator2, strategy).unwrap();
1880
1881 assert_eq!(storage1.base_path, storage2.base_path);
1883 }
1884
1885 use crate::{FromDomain, IntoDomain, MigratesTo, Versioned};
1887 use serde::{Deserialize, Serialize};
1888
1889 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1890 struct SessionV1_0_0 {
1891 id: String,
1892 user_id: String,
1893 }
1894
1895 impl Versioned for SessionV1_0_0 {
1896 const VERSION: &'static str = "1.0.0";
1897 }
1898
1899 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1900 struct SessionV1_1_0 {
1901 id: String,
1902 user_id: String,
1903 created_at: Option<String>,
1904 }
1905
1906 impl Versioned for SessionV1_1_0 {
1907 const VERSION: &'static str = "1.1.0";
1908 }
1909
1910 impl MigratesTo<SessionV1_1_0> for SessionV1_0_0 {
1911 fn migrate(self) -> SessionV1_1_0 {
1912 SessionV1_1_0 {
1913 id: self.id,
1914 user_id: self.user_id,
1915 created_at: None,
1916 }
1917 }
1918 }
1919
1920 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1921 struct SessionEntity {
1922 id: String,
1923 user_id: String,
1924 created_at: Option<String>,
1925 }
1926
1927 impl IntoDomain<SessionEntity> for SessionV1_1_0 {
1928 fn into_domain(self) -> SessionEntity {
1929 SessionEntity {
1930 id: self.id,
1931 user_id: self.user_id,
1932 created_at: self.created_at,
1933 }
1934 }
1935 }
1936
1937 impl FromDomain<SessionEntity> for SessionV1_1_0 {
1938 fn from_domain(domain: SessionEntity) -> Self {
1939 SessionV1_1_0 {
1940 id: domain.id,
1941 user_id: domain.user_id,
1942 created_at: domain.created_at,
1943 }
1944 }
1945 }
1946
1947 fn setup_session_migrator() -> Migrator {
1948 let path = Migrator::define("session")
1949 .from::<SessionV1_0_0>()
1950 .step::<SessionV1_1_0>()
1951 .into_with_save::<SessionEntity>();
1952
1953 let mut migrator = Migrator::new();
1954 migrator.register(path).unwrap();
1955 migrator
1956 }
1957
1958 #[test]
1959 fn test_dir_storage_save_json() {
1960 let temp_dir = TempDir::new().unwrap();
1961 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1962 temp_dir.path().to_path_buf(),
1963 ));
1964
1965 let migrator = setup_session_migrator();
1966 let strategy = DirStorageStrategy::default().with_format(FormatStrategy::Json);
1967 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1968
1969 let session = SessionEntity {
1971 id: "session-123".to_string(),
1972 user_id: "user-456".to_string(),
1973 created_at: Some("2024-01-01T00:00:00Z".to_string()),
1974 };
1975
1976 storage.save("session", "session-123", session).unwrap();
1978
1979 let file_path = storage.base_path.join("session-123.json");
1981 assert!(file_path.exists());
1982
1983 let content = std::fs::read_to_string(&file_path).unwrap();
1985 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1986 assert_eq!(json["version"], "1.1.0");
1987 assert_eq!(json["id"], "session-123");
1988 assert_eq!(json["user_id"], "user-456");
1989 }
1990
1991 #[test]
1992 fn test_dir_storage_save_toml() {
1993 let temp_dir = TempDir::new().unwrap();
1994 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1995 temp_dir.path().to_path_buf(),
1996 ));
1997
1998 let migrator = setup_session_migrator();
1999 let strategy = DirStorageStrategy::default().with_format(FormatStrategy::Toml);
2000 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2001
2002 let session = SessionEntity {
2004 id: "session-789".to_string(),
2005 user_id: "user-101".to_string(),
2006 created_at: Some("2024-01-15T10:30:00Z".to_string()),
2007 };
2008
2009 storage.save("session", "session-789", session).unwrap();
2011
2012 let file_path = storage.base_path.join("session-789.toml");
2014 assert!(file_path.exists());
2015
2016 let content = std::fs::read_to_string(&file_path).unwrap();
2018 let toml: toml::Value = toml::from_str(&content).unwrap();
2019 assert_eq!(toml["version"].as_str().unwrap(), "1.1.0");
2020 assert_eq!(toml["id"].as_str().unwrap(), "session-789");
2021 assert_eq!(toml["created_at"].as_str().unwrap(), "2024-01-15T10:30:00Z");
2022 }
2023
2024 #[test]
2025 fn test_dir_storage_save_with_invalid_id() {
2026 let temp_dir = TempDir::new().unwrap();
2027 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2028 temp_dir.path().to_path_buf(),
2029 ));
2030
2031 let migrator = setup_session_migrator();
2032 let strategy = DirStorageStrategy::default();
2033 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2034
2035 let session = SessionEntity {
2036 id: "invalid/id".to_string(),
2037 user_id: "user-456".to_string(),
2038 created_at: None,
2039 };
2040
2041 let result = storage.save("session", "invalid/id", session);
2043 assert!(result.is_err());
2044 assert!(matches!(
2045 result.unwrap_err(),
2046 crate::MigrationError::FilenameEncoding { .. }
2047 ));
2048 }
2049
2050 #[test]
2051 fn test_dir_storage_save_with_custom_extension() {
2052 let temp_dir = TempDir::new().unwrap();
2053 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2054 temp_dir.path().to_path_buf(),
2055 ));
2056
2057 let migrator = setup_session_migrator();
2058 let strategy = DirStorageStrategy::default()
2059 .with_format(FormatStrategy::Json)
2060 .with_extension("data");
2061 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2062
2063 let session = SessionEntity {
2064 id: "session-custom".to_string(),
2065 user_id: "user-999".to_string(),
2066 created_at: None,
2067 };
2068
2069 storage.save("session", "session-custom", session).unwrap();
2070
2071 let file_path = storage.base_path.join("session-custom.data");
2073 assert!(file_path.exists());
2074 }
2075
2076 #[test]
2077 fn test_dir_storage_save_overwrites_existing() {
2078 let temp_dir = TempDir::new().unwrap();
2079 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2080 temp_dir.path().to_path_buf(),
2081 ));
2082
2083 let migrator = setup_session_migrator();
2084 let strategy = DirStorageStrategy::default();
2085 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2086
2087 let session1 = SessionEntity {
2089 id: "session-overwrite".to_string(),
2090 user_id: "user-111".to_string(),
2091 created_at: Some("2024-01-01".to_string()),
2092 };
2093 storage
2094 .save("session", "session-overwrite", session1)
2095 .unwrap();
2096
2097 let session2 = SessionEntity {
2099 id: "session-overwrite".to_string(),
2100 user_id: "user-222".to_string(),
2101 created_at: Some("2024-01-02".to_string()),
2102 };
2103 storage
2104 .save("session", "session-overwrite", session2)
2105 .unwrap();
2106
2107 let file_path = storage.base_path.join("session-overwrite.json");
2109 let content = std::fs::read_to_string(&file_path).unwrap();
2110 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
2111 assert_eq!(json["user_id"], "user-222");
2112 assert_eq!(json["created_at"], "2024-01-02");
2113 }
2114
2115 #[test]
2116 fn test_dir_storage_load_success() {
2117 let temp_dir = TempDir::new().unwrap();
2118 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2119 temp_dir.path().to_path_buf(),
2120 ));
2121
2122 let migrator = setup_session_migrator();
2123 let strategy = DirStorageStrategy::default();
2124 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2125
2126 let session = SessionEntity {
2128 id: "session-load".to_string(),
2129 user_id: "user-999".to_string(),
2130 created_at: Some("2024-02-01".to_string()),
2131 };
2132 storage
2133 .save("session", "session-load", session.clone())
2134 .unwrap();
2135
2136 let loaded: SessionEntity = storage.load("session", "session-load").unwrap();
2138 assert_eq!(loaded.id, session.id);
2139 assert_eq!(loaded.user_id, session.user_id);
2140 assert_eq!(loaded.created_at, session.created_at);
2141 }
2142
2143 #[test]
2144 fn test_dir_storage_load_not_found() {
2145 let temp_dir = TempDir::new().unwrap();
2146 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2147 temp_dir.path().to_path_buf(),
2148 ));
2149
2150 let migrator = setup_session_migrator();
2151 let strategy = DirStorageStrategy::default();
2152 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2153
2154 let result: Result<SessionEntity, _> = storage.load("session", "non-existent");
2156 assert!(result.is_err());
2157 assert!(matches!(
2158 result.unwrap_err(),
2159 MigrationError::IoError { .. }
2160 ));
2161 }
2162
2163 #[test]
2164 fn test_dir_storage_save_and_load_roundtrip() {
2165 let temp_dir = TempDir::new().unwrap();
2166 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2167 temp_dir.path().to_path_buf(),
2168 ));
2169
2170 let migrator = setup_session_migrator();
2171 let strategy = DirStorageStrategy::default();
2172 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2173
2174 let sessions = vec![
2176 SessionEntity {
2177 id: "session-1".to_string(),
2178 user_id: "user-1".to_string(),
2179 created_at: Some("2024-01-01".to_string()),
2180 },
2181 SessionEntity {
2182 id: "session-2".to_string(),
2183 user_id: "user-2".to_string(),
2184 created_at: None,
2185 },
2186 SessionEntity {
2187 id: "session-3".to_string(),
2188 user_id: "user-3".to_string(),
2189 created_at: Some("2024-03-01".to_string()),
2190 },
2191 ];
2192
2193 for session in &sessions {
2195 storage
2196 .save("session", &session.id, session.clone())
2197 .unwrap();
2198 }
2199
2200 for session in &sessions {
2202 let loaded: SessionEntity = storage.load("session", &session.id).unwrap();
2203 assert_eq!(loaded.id, session.id);
2204 assert_eq!(loaded.user_id, session.user_id);
2205 assert_eq!(loaded.created_at, session.created_at);
2206 }
2207 }
2208
2209 #[test]
2210 fn test_dir_storage_list_ids_empty() {
2211 let temp_dir = TempDir::new().unwrap();
2212 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2213 temp_dir.path().to_path_buf(),
2214 ));
2215
2216 let migrator = setup_session_migrator();
2217 let strategy = DirStorageStrategy::default();
2218 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2219
2220 let ids = storage.list_ids().unwrap();
2222 assert!(ids.is_empty());
2223 }
2224
2225 #[test]
2226 fn test_dir_storage_list_ids() {
2227 let temp_dir = TempDir::new().unwrap();
2228 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2229 temp_dir.path().to_path_buf(),
2230 ));
2231
2232 let migrator = setup_session_migrator();
2233 let strategy = DirStorageStrategy::default();
2234 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2235
2236 let ids = vec!["session-c", "session-a", "session-b"];
2238 for id in &ids {
2239 let session = SessionEntity {
2240 id: id.to_string(),
2241 user_id: "user".to_string(),
2242 created_at: None,
2243 };
2244 storage.save("session", id, session).unwrap();
2245 }
2246
2247 let listed_ids = storage.list_ids().unwrap();
2249 assert_eq!(listed_ids.len(), 3);
2250 assert_eq!(listed_ids, vec!["session-a", "session-b", "session-c"]);
2252 }
2253
2254 #[test]
2255 fn test_dir_storage_load_all_empty() {
2256 let temp_dir = TempDir::new().unwrap();
2257 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2258 temp_dir.path().to_path_buf(),
2259 ));
2260
2261 let migrator = setup_session_migrator();
2262 let strategy = DirStorageStrategy::default();
2263 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2264
2265 let results: Vec<(String, SessionEntity)> = storage.load_all("session").unwrap();
2267 assert!(results.is_empty());
2268 }
2269
2270 #[test]
2271 fn test_dir_storage_load_all() {
2272 let temp_dir = TempDir::new().unwrap();
2273 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2274 temp_dir.path().to_path_buf(),
2275 ));
2276
2277 let migrator = setup_session_migrator();
2278 let strategy = DirStorageStrategy::default();
2279 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2280
2281 let sessions = vec![
2283 SessionEntity {
2284 id: "session-x".to_string(),
2285 user_id: "user-x".to_string(),
2286 created_at: Some("2024-01-01".to_string()),
2287 },
2288 SessionEntity {
2289 id: "session-y".to_string(),
2290 user_id: "user-y".to_string(),
2291 created_at: None,
2292 },
2293 SessionEntity {
2294 id: "session-z".to_string(),
2295 user_id: "user-z".to_string(),
2296 created_at: Some("2024-03-01".to_string()),
2297 },
2298 ];
2299
2300 for session in &sessions {
2301 storage
2302 .save("session", &session.id, session.clone())
2303 .unwrap();
2304 }
2305
2306 let results: Vec<(String, SessionEntity)> = storage.load_all("session").unwrap();
2308 assert_eq!(results.len(), 3);
2309
2310 for (id, loaded) in &results {
2312 let original = sessions.iter().find(|s| &s.id == id).unwrap();
2313 assert_eq!(loaded.id, original.id);
2314 assert_eq!(loaded.user_id, original.user_id);
2315 assert_eq!(loaded.created_at, original.created_at);
2316 }
2317 }
2318
2319 #[test]
2320 fn test_dir_storage_exists() {
2321 let temp_dir = TempDir::new().unwrap();
2322 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2323 temp_dir.path().to_path_buf(),
2324 ));
2325
2326 let migrator = setup_session_migrator();
2327 let strategy = DirStorageStrategy::default();
2328 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2329
2330 assert!(!storage.exists("session-exists").unwrap());
2332
2333 let session = SessionEntity {
2335 id: "session-exists".to_string(),
2336 user_id: "user-exists".to_string(),
2337 created_at: None,
2338 };
2339 storage.save("session", "session-exists", session).unwrap();
2340
2341 assert!(storage.exists("session-exists").unwrap());
2343 }
2344
2345 #[test]
2346 fn test_dir_storage_delete() {
2347 let temp_dir = TempDir::new().unwrap();
2348 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2349 temp_dir.path().to_path_buf(),
2350 ));
2351
2352 let migrator = setup_session_migrator();
2353 let strategy = DirStorageStrategy::default();
2354 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2355
2356 let session = SessionEntity {
2358 id: "session-delete".to_string(),
2359 user_id: "user-delete".to_string(),
2360 created_at: None,
2361 };
2362 storage.save("session", "session-delete", session).unwrap();
2363
2364 assert!(storage.exists("session-delete").unwrap());
2366
2367 storage.delete("session-delete").unwrap();
2369
2370 assert!(!storage.exists("session-delete").unwrap());
2372 }
2373
2374 #[test]
2375 fn test_dir_storage_delete_idempotent() {
2376 let temp_dir = TempDir::new().unwrap();
2377 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2378 temp_dir.path().to_path_buf(),
2379 ));
2380
2381 let migrator = setup_session_migrator();
2382 let strategy = DirStorageStrategy::default();
2383 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2384
2385 storage.delete("non-existent").unwrap();
2387
2388 storage.delete("non-existent").unwrap();
2390 }
2391
2392 #[test]
2393 fn test_dir_storage_load_toml() {
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().with_format(FormatStrategy::Toml);
2401 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2402
2403 let session = SessionEntity {
2405 id: "session-toml".to_string(),
2406 user_id: "user-toml".to_string(),
2407 created_at: Some("2024-04-01".to_string()),
2408 };
2409 storage
2410 .save("session", "session-toml", session.clone())
2411 .unwrap();
2412
2413 let loaded: SessionEntity = storage.load("session", "session-toml").unwrap();
2415 assert_eq!(loaded.id, session.id);
2416 assert_eq!(loaded.user_id, session.user_id);
2417 assert_eq!(loaded.created_at, session.created_at);
2418 }
2419
2420 #[test]
2421 fn test_dir_storage_list_ids_with_custom_extension() {
2422 let temp_dir = TempDir::new().unwrap();
2423 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2424 temp_dir.path().to_path_buf(),
2425 ));
2426
2427 let migrator = setup_session_migrator();
2428 let strategy = DirStorageStrategy::default().with_extension("data");
2429 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2430
2431 let session = SessionEntity {
2433 id: "session-ext".to_string(),
2434 user_id: "user-ext".to_string(),
2435 created_at: None,
2436 };
2437 storage.save("session", "session-ext", session).unwrap();
2438
2439 let ids = storage.list_ids().unwrap();
2441 assert_eq!(ids.len(), 1);
2442 assert_eq!(ids[0], "session-ext");
2443 }
2444
2445 #[test]
2446 fn test_dir_storage_load_all_atomic_failure() {
2447 let temp_dir = TempDir::new().unwrap();
2448 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2449 temp_dir.path().to_path_buf(),
2450 ));
2451
2452 let migrator = setup_session_migrator();
2453 let strategy = DirStorageStrategy::default();
2454 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2455
2456 let session1 = SessionEntity {
2458 id: "session-1".to_string(),
2459 user_id: "user-1".to_string(),
2460 created_at: None,
2461 };
2462 storage.save("session", "session-1", session1).unwrap();
2463
2464 let corrupted_path = storage.base_path.join("session-corrupted.json");
2466 std::fs::write(&corrupted_path, "invalid json {{{").unwrap();
2467
2468 let result: Result<Vec<(String, SessionEntity)>, _> = storage.load_all("session");
2470 assert!(result.is_err());
2471 }
2472
2473 #[test]
2474 fn test_dir_storage_filename_encoding_url_roundtrip() {
2475 let temp_dir = TempDir::new().unwrap();
2476 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2477 temp_dir.path().to_path_buf(),
2478 ));
2479
2480 let migrator = setup_session_migrator();
2481 let strategy =
2482 DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
2483 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2484
2485 let complex_id = "user@example.com/path?query=1";
2487 let session = SessionEntity {
2488 id: complex_id.to_string(),
2489 user_id: "user-special".to_string(),
2490 created_at: Some("2024-05-01".to_string()),
2491 };
2492
2493 storage
2495 .save("session", complex_id, session.clone())
2496 .unwrap();
2497
2498 let encoded_id = urlencoding::encode(complex_id);
2500 let file_path = storage.base_path.join(format!("{}.json", encoded_id));
2501 assert!(file_path.exists());
2502
2503 let loaded: SessionEntity = storage.load("session", complex_id).unwrap();
2505 assert_eq!(loaded.id, session.id);
2506 assert_eq!(loaded.user_id, session.user_id);
2507 assert_eq!(loaded.created_at, session.created_at);
2508
2509 let ids = storage.list_ids().unwrap();
2511 assert_eq!(ids.len(), 1);
2512 assert_eq!(ids[0], complex_id);
2513 }
2514
2515 #[test]
2516 fn test_dir_storage_filename_encoding_base64_roundtrip() {
2517 let temp_dir = TempDir::new().unwrap();
2518 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2519 temp_dir.path().to_path_buf(),
2520 ));
2521
2522 let migrator = setup_session_migrator();
2523 let strategy =
2524 DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
2525 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2526
2527 let complex_id = "user@example.com/path?query=1&special=!@#$%";
2529 let session = SessionEntity {
2530 id: complex_id.to_string(),
2531 user_id: "user-base64".to_string(),
2532 created_at: Some("2024-06-01".to_string()),
2533 };
2534
2535 storage
2537 .save("session", complex_id, session.clone())
2538 .unwrap();
2539
2540 let encoded_id = URL_SAFE_NO_PAD.encode(complex_id.as_bytes());
2542 let file_path = storage.base_path.join(format!("{}.json", encoded_id));
2543 assert!(file_path.exists());
2544
2545 let loaded: SessionEntity = storage.load("session", complex_id).unwrap();
2547 assert_eq!(loaded.id, session.id);
2548 assert_eq!(loaded.user_id, session.user_id);
2549 assert_eq!(loaded.created_at, session.created_at);
2550
2551 let ids = storage.list_ids().unwrap();
2553 assert_eq!(ids.len(), 1);
2554 assert_eq!(ids[0], complex_id);
2555 }
2556
2557 #[test]
2558 fn test_decode_id_error_handling() {
2559 let temp_dir = TempDir::new().unwrap();
2560 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2561 temp_dir.path().to_path_buf(),
2562 ));
2563
2564 let migrator_url = setup_session_migrator();
2568 let strategy_url =
2569 DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
2570 let storage_url =
2571 DirStorage::new(paths.clone(), "sessions_url", migrator_url, strategy_url).unwrap();
2572
2573 let invalid_url_encoded = "%C0%C1"; let result = storage_url.decode_id(invalid_url_encoded);
2576 assert!(result.is_err());
2577 if let Err(MigrationError::FilenameEncoding { id, reason }) = result {
2578 assert_eq!(id, invalid_url_encoded);
2579 assert!(reason.contains("Failed to URL-decode filename"));
2580 }
2581
2582 let migrator_base64 = setup_session_migrator();
2584 let strategy_base64 =
2585 DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
2586 let storage_base64 =
2587 DirStorage::new(paths, "sessions_base64", migrator_base64, strategy_base64).unwrap();
2588
2589 let invalid_base64 = "!!!invalid@@@";
2591 let result = storage_base64.decode_id(invalid_base64);
2592 assert!(result.is_err());
2593 if let Err(MigrationError::FilenameEncoding { id, reason }) = result {
2594 assert_eq!(id, invalid_base64);
2595 assert!(reason.contains("Failed to Base64-decode filename"));
2596 }
2597
2598 let invalid_utf8_bytes = vec![0xFF, 0xFE, 0xFD];
2601 let valid_base64_invalid_utf8 = URL_SAFE_NO_PAD.encode(&invalid_utf8_bytes);
2602 let result = storage_base64.decode_id(&valid_base64_invalid_utf8);
2603 assert!(result.is_err());
2604 if let Err(MigrationError::FilenameEncoding { id, reason }) = result {
2605 assert_eq!(id, valid_base64_invalid_utf8);
2606 assert!(reason.contains("Failed to convert Base64-decoded bytes to UTF-8"));
2607 }
2608 }
2609}