1use crate::{errors::IoOperationKind, 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, Default)]
55pub enum FilenameEncoding {
56 #[default]
58 Direct,
59 UrlEncode,
61 Base64,
63}
64
65#[derive(Debug, Clone)]
67pub struct DirStorageStrategy {
68 pub format: FormatStrategy,
70 pub atomic_write: AtomicWriteConfig,
72 pub extension: Option<String>,
74 pub filename_encoding: FilenameEncoding,
76}
77
78impl Default for DirStorageStrategy {
79 fn default() -> Self {
80 Self {
81 format: FormatStrategy::Json,
82 atomic_write: AtomicWriteConfig::default(),
83 extension: None,
84 filename_encoding: FilenameEncoding::default(),
85 }
86 }
87}
88
89impl DirStorageStrategy {
90 #[allow(dead_code)]
92 pub fn new() -> Self {
93 Self::default()
94 }
95
96 #[allow(dead_code)]
98 pub fn with_format(mut self, format: FormatStrategy) -> Self {
99 self.format = format;
100 self
101 }
102
103 #[allow(dead_code)]
105 pub fn with_extension(mut self, ext: impl Into<String>) -> Self {
106 self.extension = Some(ext.into());
107 self
108 }
109
110 #[allow(dead_code)]
112 pub fn with_filename_encoding(mut self, encoding: FilenameEncoding) -> Self {
113 self.filename_encoding = encoding;
114 self
115 }
116
117 #[allow(dead_code)]
119 pub fn with_retry_count(mut self, count: usize) -> Self {
120 self.atomic_write.retry_count = count;
121 self
122 }
123
124 #[allow(dead_code)]
126 pub fn with_cleanup(mut self, cleanup: bool) -> Self {
127 self.atomic_write.cleanup_tmp_files = cleanup;
128 self
129 }
130
131 fn get_extension(&self) -> String {
133 self.extension.clone().unwrap_or_else(|| match self.format {
134 FormatStrategy::Json => "json".to_string(),
135 FormatStrategy::Toml => "toml".to_string(),
136 })
137 }
138}
139
140pub struct DirStorage {
148 base_path: PathBuf,
150 migrator: Migrator,
152 strategy: DirStorageStrategy,
154}
155
156impl DirStorage {
157 pub fn new(
188 paths: AppPaths,
189 domain_name: &str,
190 migrator: Migrator,
191 strategy: DirStorageStrategy,
192 ) -> Result<Self, MigrationError> {
193 let base_path = paths.data_dir()?.join(domain_name);
195
196 if !base_path.exists() {
198 std::fs::create_dir_all(&base_path).map_err(|e| MigrationError::IoError {
199 operation: IoOperationKind::CreateDir,
200 path: base_path.display().to_string(),
201 context: Some("storage base directory".to_string()),
202 error: e.to_string(),
203 })?;
204 }
205
206 Ok(Self {
207 base_path,
208 migrator,
209 strategy,
210 })
211 }
212
213 pub fn save<T>(&self, entity_name: &str, id: &str, entity: T) -> Result<(), MigrationError>
245 where
246 T: serde::Serialize,
247 {
248 let json_string = self.migrator.save_domain_flat(entity_name, entity)?;
250
251 let versioned_value: serde_json::Value = serde_json::from_str(&json_string)
253 .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
254
255 let content = self.serialize_content(&versioned_value)?;
257
258 let file_path = self.id_to_path(id)?;
260
261 self.atomic_write(&file_path, &content)?;
263
264 Ok(())
265 }
266
267 fn id_to_path(&self, id: &str) -> Result<PathBuf, MigrationError> {
284 let encoded_id = self.encode_id(id)?;
285 let extension = self.strategy.get_extension();
286 let filename = format!("{}.{}", encoded_id, extension);
287 Ok(self.base_path.join(filename))
288 }
289
290 fn encode_id(&self, id: &str) -> Result<String, MigrationError> {
308 match self.strategy.filename_encoding {
309 FilenameEncoding::Direct => {
310 if id
312 .chars()
313 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
314 {
315 Ok(id.to_string())
316 } else {
317 Err(MigrationError::FilenameEncoding {
318 id: id.to_string(),
319 reason: "ID contains invalid characters for Direct encoding. Only alphanumeric, '-', and '_' are allowed.".to_string(),
320 })
321 }
322 }
323 FilenameEncoding::UrlEncode => {
324 Ok(urlencoding::encode(id).into_owned())
326 }
327 FilenameEncoding::Base64 => {
328 Ok(URL_SAFE_NO_PAD.encode(id.as_bytes()))
330 }
331 }
332 }
333
334 fn serialize_content(&self, value: &serde_json::Value) -> Result<String, MigrationError> {
348 match self.strategy.format {
349 FormatStrategy::Json => serde_json::to_string_pretty(value)
350 .map_err(|e| MigrationError::SerializationError(e.to_string())),
351 FormatStrategy::Toml => {
352 let toml_value = json_to_toml(value)?;
353 toml::to_string_pretty(&toml_value)
354 .map_err(|e| MigrationError::TomlSerializeError(e.to_string()))
355 }
356 }
357 }
358
359 fn atomic_write(&self, path: &Path, content: &str) -> Result<(), MigrationError> {
382 if let Some(parent) = path.parent() {
384 if !parent.exists() {
385 fs::create_dir_all(parent).map_err(|e| MigrationError::IoError {
386 operation: IoOperationKind::CreateDir,
387 path: parent.display().to_string(),
388 context: Some("parent directory".to_string()),
389 error: e.to_string(),
390 })?;
391 }
392 }
393
394 let tmp_path = self.get_temp_path(path)?;
396
397 let mut tmp_file = File::create(&tmp_path).map_err(|e| MigrationError::IoError {
399 operation: IoOperationKind::Create,
400 path: tmp_path.display().to_string(),
401 context: Some("temporary file".to_string()),
402 error: e.to_string(),
403 })?;
404
405 tmp_file
406 .write_all(content.as_bytes())
407 .map_err(|e| MigrationError::IoError {
408 operation: IoOperationKind::Write,
409 path: tmp_path.display().to_string(),
410 context: Some("temporary file".to_string()),
411 error: e.to_string(),
412 })?;
413
414 tmp_file.sync_all().map_err(|e| MigrationError::IoError {
416 operation: IoOperationKind::Sync,
417 path: tmp_path.display().to_string(),
418 context: Some("temporary file".to_string()),
419 error: e.to_string(),
420 })?;
421
422 drop(tmp_file);
423
424 self.atomic_rename(&tmp_path, path)?;
426
427 if self.strategy.atomic_write.cleanup_tmp_files {
429 let _ = self.cleanup_temp_files(path);
430 }
431
432 Ok(())
433 }
434
435 fn get_temp_path(&self, target_path: &Path) -> Result<PathBuf, MigrationError> {
452 let parent = target_path.parent().ok_or_else(|| {
453 MigrationError::PathResolution("Path has no parent directory".to_string())
454 })?;
455
456 let file_name = target_path
457 .file_name()
458 .ok_or_else(|| MigrationError::PathResolution("Path has no file name".to_string()))?;
459
460 let tmp_name = format!(
461 ".{}.tmp.{}",
462 file_name.to_string_lossy(),
463 std::process::id()
464 );
465 Ok(parent.join(tmp_name))
466 }
467
468 fn atomic_rename(&self, tmp_path: &Path, target_path: &Path) -> Result<(), MigrationError> {
482 let mut last_error = None;
483
484 for attempt in 0..self.strategy.atomic_write.retry_count {
485 match fs::rename(tmp_path, target_path) {
486 Ok(()) => return Ok(()),
487 Err(e) => {
488 last_error = Some(e);
489 if attempt + 1 < self.strategy.atomic_write.retry_count {
490 std::thread::sleep(std::time::Duration::from_millis(10));
492 }
493 }
494 }
495 }
496
497 Err(MigrationError::IoError {
498 operation: IoOperationKind::Rename,
499 path: target_path.display().to_string(),
500 context: Some(format!(
501 "after {} retries",
502 self.strategy.atomic_write.retry_count
503 )),
504 error: last_error.unwrap().to_string(),
505 })
506 }
507
508 fn cleanup_temp_files(&self, target_path: &Path) -> std::io::Result<()> {
517 let parent = match target_path.parent() {
518 Some(p) => p,
519 None => return Ok(()),
520 };
521
522 let file_name = match target_path.file_name() {
523 Some(f) => f.to_string_lossy(),
524 None => return Ok(()),
525 };
526
527 let prefix = format!(".{}.tmp.", file_name);
528
529 if let Ok(entries) = fs::read_dir(parent) {
530 for entry in entries.flatten() {
531 if let Ok(name) = entry.file_name().into_string() {
532 if name.starts_with(&prefix) {
533 let _ = fs::remove_file(entry.path());
535 }
536 }
537 }
538 }
539
540 Ok(())
541 }
542
543 pub fn load<D>(&self, entity_name: &str, id: &str) -> Result<D, MigrationError>
571 where
572 D: serde::de::DeserializeOwned,
573 {
574 let file_path = self.id_to_path(id)?;
576
577 if !file_path.exists() {
579 return Err(MigrationError::IoError {
580 operation: IoOperationKind::Read,
581 path: file_path.display().to_string(),
582 context: None,
583 error: "File not found".to_string(),
584 });
585 }
586
587 let content = fs::read_to_string(&file_path).map_err(|e| MigrationError::IoError {
589 operation: IoOperationKind::Read,
590 path: file_path.display().to_string(),
591 context: None,
592 error: e.to_string(),
593 })?;
594
595 let value = self.deserialize_content(&content)?;
597
598 self.migrator.load_flat_from(entity_name, value)
600 }
601
602 pub fn list_ids(&self) -> Result<Vec<String>, MigrationError> {
623 let entries = fs::read_dir(&self.base_path).map_err(|e| MigrationError::IoError {
625 operation: IoOperationKind::ReadDir,
626 path: self.base_path.display().to_string(),
627 context: None,
628 error: e.to_string(),
629 })?;
630
631 let extension = self.strategy.get_extension();
632 let mut ids = Vec::new();
633
634 for entry in entries {
635 let entry = entry.map_err(|e| MigrationError::IoError {
636 operation: IoOperationKind::ReadDir,
637 path: self.base_path.display().to_string(),
638 context: Some("directory entry".to_string()),
639 error: e.to_string(),
640 })?;
641
642 let path = entry.path();
643
644 if path.is_file() {
646 if let Some(ext) = path.extension() {
647 if ext == extension.as_str() {
648 if let Some(id) = self.path_to_id(&path)? {
650 ids.push(id);
651 }
652 }
653 }
654 }
655 }
656
657 ids.sort();
659 Ok(ids)
660 }
661
662 pub fn load_all<D>(&self, entity_name: &str) -> Result<Vec<(String, D)>, MigrationError>
686 where
687 D: serde::de::DeserializeOwned,
688 {
689 let ids = self.list_ids()?;
690 let mut results = Vec::new();
691
692 for id in ids {
693 let entity = self.load(entity_name, &id)?;
694 results.push((id, entity));
695 }
696
697 Ok(results)
698 }
699
700 pub fn exists(&self, id: &str) -> Result<bool, MigrationError> {
718 let file_path = self.id_to_path(id)?;
719 Ok(file_path.exists() && file_path.is_file())
720 }
721
722 pub fn delete(&self, id: &str) -> Result<(), MigrationError> {
742 let file_path = self.id_to_path(id)?;
743
744 if file_path.exists() {
745 fs::remove_file(&file_path).map_err(|e| MigrationError::IoError {
746 operation: IoOperationKind::Delete,
747 path: file_path.display().to_string(),
748 context: None,
749 error: e.to_string(),
750 })?;
751 }
752
753 Ok(())
754 }
755
756 pub fn base_path(&self) -> &Path {
762 &self.base_path
763 }
764
765 fn deserialize_content(&self, content: &str) -> Result<serde_json::Value, MigrationError> {
779 match self.strategy.format {
780 FormatStrategy::Json => serde_json::from_str(content)
781 .map_err(|e| MigrationError::DeserializationError(e.to_string())),
782 FormatStrategy::Toml => {
783 let toml_value: toml::Value = toml::from_str(content)
784 .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
785 toml_to_json(toml_value)
786 }
787 }
788 }
789
790 fn path_to_id(&self, path: &Path) -> Result<Option<String>, MigrationError> {
804 let file_stem = match path.file_stem() {
806 Some(stem) => stem.to_string_lossy(),
807 None => return Ok(None),
808 };
809
810 let id = self.decode_id(&file_stem)?;
812 Ok(Some(id))
813 }
814
815 fn decode_id(&self, filename_stem: &str) -> Result<String, MigrationError> {
835 match self.strategy.filename_encoding {
836 FilenameEncoding::Direct => {
837 Ok(filename_stem.to_string())
839 }
840 FilenameEncoding::UrlEncode => {
841 urlencoding::decode(filename_stem)
843 .map(|s| s.into_owned())
844 .map_err(|e| MigrationError::FilenameEncoding {
845 id: filename_stem.to_string(),
846 reason: format!("Failed to URL-decode filename: {}", e),
847 })
848 }
849 FilenameEncoding::Base64 => {
850 URL_SAFE_NO_PAD
852 .decode(filename_stem.as_bytes())
853 .map_err(|e| MigrationError::FilenameEncoding {
854 id: filename_stem.to_string(),
855 reason: format!("Failed to Base64-decode filename: {}", e),
856 })
857 .and_then(|bytes| {
858 String::from_utf8(bytes).map_err(|e| MigrationError::FilenameEncoding {
859 id: filename_stem.to_string(),
860 reason: format!(
861 "Failed to convert Base64-decoded bytes to UTF-8: {}",
862 e
863 ),
864 })
865 })
866 }
867 }
868 }
869}
870
871fn json_to_toml(json_value: &serde_json::Value) -> Result<toml::Value, MigrationError> {
875 let json_str = serde_json::to_string(json_value)
876 .map_err(|e| MigrationError::SerializationError(e.to_string()))?;
877 let toml_value: toml::Value = serde_json::from_str(&json_str)
878 .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
879 Ok(toml_value)
880}
881
882fn toml_to_json(toml_value: toml::Value) -> Result<serde_json::Value, MigrationError> {
886 let json_str = serde_json::to_string(&toml_value)
887 .map_err(|e| MigrationError::SerializationError(e.to_string()))?;
888 let json_value: serde_json::Value = serde_json::from_str(&json_str)
889 .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
890 Ok(json_value)
891}
892
893#[cfg(feature = "async")]
898pub use async_impl::AsyncDirStorage;
899
900#[cfg(feature = "async")]
901mod async_impl {
902 use crate::{errors::IoOperationKind, AppPaths, MigrationError, Migrator};
903 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
904 use base64::Engine;
905 use std::path::{Path, PathBuf};
906 use tokio::io::AsyncWriteExt;
907
908 use super::{json_to_toml, toml_to_json, DirStorageStrategy, FilenameEncoding, FormatStrategy};
909
910 pub struct AsyncDirStorage {
915 base_path: PathBuf,
917 migrator: Migrator,
919 strategy: DirStorageStrategy,
921 }
922
923 impl AsyncDirStorage {
924 pub async fn new(
943 paths: AppPaths,
944 domain_name: &str,
945 migrator: Migrator,
946 strategy: DirStorageStrategy,
947 ) -> Result<Self, MigrationError> {
948 let base_path = paths.data_dir()?.join(domain_name);
950
951 if !tokio::fs::try_exists(&base_path).await.unwrap_or(false) {
953 tokio::fs::create_dir_all(&base_path).await.map_err(|e| {
954 MigrationError::IoError {
955 operation: IoOperationKind::CreateDir,
956 path: base_path.display().to_string(),
957 context: Some("storage base directory (async)".to_string()),
958 error: e.to_string(),
959 }
960 })?;
961 }
962
963 Ok(Self {
964 base_path,
965 migrator,
966 strategy,
967 })
968 }
969
970 pub async fn save<T>(
992 &self,
993 entity_name: &str,
994 id: &str,
995 entity: T,
996 ) -> Result<(), MigrationError>
997 where
998 T: serde::Serialize,
999 {
1000 let json_string = self.migrator.save_domain_flat(entity_name, entity)?;
1002
1003 let versioned_value: serde_json::Value = serde_json::from_str(&json_string)
1005 .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
1006
1007 let content = self.serialize_content(&versioned_value)?;
1009
1010 let file_path = self.id_to_path(id)?;
1012
1013 self.atomic_write(&file_path, &content).await?;
1015
1016 Ok(())
1017 }
1018
1019 pub async fn load<D>(&self, entity_name: &str, id: &str) -> Result<D, MigrationError>
1041 where
1042 D: serde::de::DeserializeOwned,
1043 {
1044 let file_path = self.id_to_path(id)?;
1046
1047 if !tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
1049 return Err(MigrationError::IoError {
1050 operation: IoOperationKind::Read,
1051 path: file_path.display().to_string(),
1052 context: Some("async".to_string()),
1053 error: "File not found".to_string(),
1054 });
1055 }
1056
1057 let content = tokio::fs::read_to_string(&file_path).await.map_err(|e| {
1059 MigrationError::IoError {
1060 operation: IoOperationKind::Read,
1061 path: file_path.display().to_string(),
1062 context: Some("async".to_string()),
1063 error: e.to_string(),
1064 }
1065 })?;
1066
1067 let value = self.deserialize_content(&content)?;
1069
1070 self.migrator.load_flat_from(entity_name, value)
1072 }
1073
1074 pub async fn list_ids(&self) -> Result<Vec<String>, MigrationError> {
1086 let mut entries = tokio::fs::read_dir(&self.base_path).await.map_err(|e| {
1088 MigrationError::IoError {
1089 operation: IoOperationKind::ReadDir,
1090 path: self.base_path.display().to_string(),
1091 context: Some("async".to_string()),
1092 error: e.to_string(),
1093 }
1094 })?;
1095
1096 let extension = self.strategy.get_extension();
1097 let mut ids = Vec::new();
1098
1099 while let Some(entry) =
1100 entries
1101 .next_entry()
1102 .await
1103 .map_err(|e| MigrationError::IoError {
1104 operation: IoOperationKind::ReadDir,
1105 path: self.base_path.display().to_string(),
1106 context: Some("directory entry (async)".to_string()),
1107 error: e.to_string(),
1108 })?
1109 {
1110 let path = entry.path();
1111
1112 let metadata =
1114 tokio::fs::metadata(&path)
1115 .await
1116 .map_err(|e| MigrationError::IoError {
1117 operation: IoOperationKind::Read,
1118 path: path.display().to_string(),
1119 context: Some("metadata (async)".to_string()),
1120 error: e.to_string(),
1121 })?;
1122
1123 if metadata.is_file() {
1124 if let Some(ext) = path.extension() {
1125 if ext == extension.as_str() {
1126 if let Some(id) = self.path_to_id(&path)? {
1128 ids.push(id);
1129 }
1130 }
1131 }
1132 }
1133 }
1134
1135 ids.sort();
1137 Ok(ids)
1138 }
1139
1140 pub async fn load_all<D>(
1155 &self,
1156 entity_name: &str,
1157 ) -> Result<Vec<(String, D)>, MigrationError>
1158 where
1159 D: serde::de::DeserializeOwned,
1160 {
1161 let ids = self.list_ids().await?;
1162 let mut results = Vec::new();
1163
1164 for id in ids {
1165 let entity = self.load(entity_name, &id).await?;
1166 results.push((id, entity));
1167 }
1168
1169 Ok(results)
1170 }
1171
1172 pub async fn exists(&self, id: &str) -> Result<bool, MigrationError> {
1182 let file_path = self.id_to_path(id)?;
1183
1184 if !tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
1185 return Ok(false);
1186 }
1187
1188 let metadata =
1189 tokio::fs::metadata(&file_path)
1190 .await
1191 .map_err(|e| MigrationError::IoError {
1192 operation: IoOperationKind::Read,
1193 path: file_path.display().to_string(),
1194 context: Some("metadata (async)".to_string()),
1195 error: e.to_string(),
1196 })?;
1197
1198 Ok(metadata.is_file())
1199 }
1200
1201 pub async fn delete(&self, id: &str) -> Result<(), MigrationError> {
1215 let file_path = self.id_to_path(id)?;
1216
1217 if tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
1218 tokio::fs::remove_file(&file_path)
1219 .await
1220 .map_err(|e| MigrationError::IoError {
1221 operation: IoOperationKind::Delete,
1222 path: file_path.display().to_string(),
1223 context: Some("async".to_string()),
1224 error: e.to_string(),
1225 })?;
1226 }
1227
1228 Ok(())
1229 }
1230
1231 pub fn base_path(&self) -> &Path {
1237 &self.base_path
1238 }
1239
1240 fn id_to_path(&self, id: &str) -> Result<PathBuf, MigrationError> {
1246 let encoded_id = self.encode_id(id)?;
1247 let extension = self.strategy.get_extension();
1248 let filename = format!("{}.{}", encoded_id, extension);
1249 Ok(self.base_path.join(filename))
1250 }
1251
1252 fn encode_id(&self, id: &str) -> Result<String, MigrationError> {
1254 match self.strategy.filename_encoding {
1255 FilenameEncoding::Direct => {
1256 if id
1258 .chars()
1259 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1260 {
1261 Ok(id.to_string())
1262 } else {
1263 Err(MigrationError::FilenameEncoding {
1264 id: id.to_string(),
1265 reason: "ID contains invalid characters for Direct encoding. Only alphanumeric, '-', and '_' are allowed.".to_string(),
1266 })
1267 }
1268 }
1269 FilenameEncoding::UrlEncode => {
1270 Ok(urlencoding::encode(id).into_owned())
1272 }
1273 FilenameEncoding::Base64 => {
1274 Ok(URL_SAFE_NO_PAD.encode(id.as_bytes()))
1276 }
1277 }
1278 }
1279
1280 fn serialize_content(&self, value: &serde_json::Value) -> Result<String, MigrationError> {
1282 match self.strategy.format {
1283 FormatStrategy::Json => serde_json::to_string_pretty(value)
1284 .map_err(|e| MigrationError::SerializationError(e.to_string())),
1285 FormatStrategy::Toml => {
1286 let toml_value = json_to_toml(value)?;
1287 toml::to_string_pretty(&toml_value)
1288 .map_err(|e| MigrationError::TomlSerializeError(e.to_string()))
1289 }
1290 }
1291 }
1292
1293 async fn atomic_write(&self, path: &Path, content: &str) -> Result<(), MigrationError> {
1298 if let Some(parent) = path.parent() {
1300 if !tokio::fs::try_exists(parent).await.unwrap_or(false) {
1301 tokio::fs::create_dir_all(parent).await.map_err(|e| {
1302 MigrationError::IoError {
1303 operation: IoOperationKind::CreateDir,
1304 path: parent.display().to_string(),
1305 context: Some("parent directory (async)".to_string()),
1306 error: e.to_string(),
1307 }
1308 })?;
1309 }
1310 }
1311
1312 let tmp_path = self.get_temp_path(path)?;
1314
1315 let mut tmp_file =
1317 tokio::fs::File::create(&tmp_path)
1318 .await
1319 .map_err(|e| MigrationError::IoError {
1320 operation: IoOperationKind::Create,
1321 path: tmp_path.display().to_string(),
1322 context: Some("temporary file (async)".to_string()),
1323 error: e.to_string(),
1324 })?;
1325
1326 tmp_file
1327 .write_all(content.as_bytes())
1328 .await
1329 .map_err(|e| MigrationError::IoError {
1330 operation: IoOperationKind::Write,
1331 path: tmp_path.display().to_string(),
1332 context: Some("temporary file (async)".to_string()),
1333 error: e.to_string(),
1334 })?;
1335
1336 tmp_file
1338 .sync_all()
1339 .await
1340 .map_err(|e| MigrationError::IoError {
1341 operation: IoOperationKind::Sync,
1342 path: tmp_path.display().to_string(),
1343 context: Some("temporary file (async)".to_string()),
1344 error: e.to_string(),
1345 })?;
1346
1347 drop(tmp_file);
1348
1349 self.atomic_rename(&tmp_path, path).await?;
1351
1352 if self.strategy.atomic_write.cleanup_tmp_files {
1354 let _ = self.cleanup_temp_files(path).await;
1355 }
1356
1357 Ok(())
1358 }
1359
1360 fn get_temp_path(&self, target_path: &Path) -> Result<PathBuf, MigrationError> {
1362 let parent = target_path.parent().ok_or_else(|| {
1363 MigrationError::PathResolution("Path has no parent directory".to_string())
1364 })?;
1365
1366 let file_name = target_path.file_name().ok_or_else(|| {
1367 MigrationError::PathResolution("Path has no file name".to_string())
1368 })?;
1369
1370 let tmp_name = format!(
1371 ".{}.tmp.{}",
1372 file_name.to_string_lossy(),
1373 std::process::id()
1374 );
1375 Ok(parent.join(tmp_name))
1376 }
1377
1378 async fn atomic_rename(
1380 &self,
1381 tmp_path: &Path,
1382 target_path: &Path,
1383 ) -> Result<(), MigrationError> {
1384 let mut last_error = None;
1385
1386 for attempt in 0..self.strategy.atomic_write.retry_count {
1387 match tokio::fs::rename(tmp_path, target_path).await {
1388 Ok(()) => return Ok(()),
1389 Err(e) => {
1390 last_error = Some(e);
1391 if attempt + 1 < self.strategy.atomic_write.retry_count {
1392 tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
1394 }
1395 }
1396 }
1397 }
1398
1399 Err(MigrationError::IoError {
1400 operation: IoOperationKind::Rename,
1401 path: target_path.display().to_string(),
1402 context: Some(format!(
1403 "after {} retries (async)",
1404 self.strategy.atomic_write.retry_count
1405 )),
1406 error: last_error.unwrap().to_string(),
1407 })
1408 }
1409
1410 async fn cleanup_temp_files(&self, target_path: &Path) -> std::io::Result<()> {
1412 let parent = match target_path.parent() {
1413 Some(p) => p,
1414 None => return Ok(()),
1415 };
1416
1417 let file_name = match target_path.file_name() {
1418 Some(f) => f.to_string_lossy(),
1419 None => return Ok(()),
1420 };
1421
1422 let prefix = format!(".{}.tmp.", file_name);
1423
1424 let mut entries = tokio::fs::read_dir(parent).await?;
1425 while let Some(entry) = entries.next_entry().await? {
1426 if let Ok(name) = entry.file_name().into_string() {
1427 if name.starts_with(&prefix) {
1428 let _ = tokio::fs::remove_file(entry.path()).await;
1430 }
1431 }
1432 }
1433
1434 Ok(())
1435 }
1436
1437 fn deserialize_content(&self, content: &str) -> Result<serde_json::Value, MigrationError> {
1439 match self.strategy.format {
1440 FormatStrategy::Json => serde_json::from_str(content)
1441 .map_err(|e| MigrationError::DeserializationError(e.to_string())),
1442 FormatStrategy::Toml => {
1443 let toml_value: toml::Value = toml::from_str(content)
1444 .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
1445 toml_to_json(toml_value)
1446 }
1447 }
1448 }
1449
1450 fn path_to_id(&self, path: &Path) -> Result<Option<String>, MigrationError> {
1452 let file_stem = match path.file_stem() {
1454 Some(stem) => stem.to_string_lossy(),
1455 None => return Ok(None),
1456 };
1457
1458 let id = self.decode_id(&file_stem)?;
1460 Ok(Some(id))
1461 }
1462
1463 fn decode_id(&self, filename_stem: &str) -> Result<String, MigrationError> {
1465 match self.strategy.filename_encoding {
1466 FilenameEncoding::Direct => {
1467 Ok(filename_stem.to_string())
1469 }
1470 FilenameEncoding::UrlEncode => {
1471 urlencoding::decode(filename_stem)
1473 .map(|s| s.into_owned())
1474 .map_err(|e| MigrationError::FilenameEncoding {
1475 id: filename_stem.to_string(),
1476 reason: format!("Failed to URL-decode filename: {}", e),
1477 })
1478 }
1479 FilenameEncoding::Base64 => {
1480 URL_SAFE_NO_PAD
1482 .decode(filename_stem.as_bytes())
1483 .map_err(|e| MigrationError::FilenameEncoding {
1484 id: filename_stem.to_string(),
1485 reason: format!("Failed to Base64-decode filename: {}", e),
1486 })
1487 .and_then(|bytes| {
1488 String::from_utf8(bytes).map_err(|e| MigrationError::FilenameEncoding {
1489 id: filename_stem.to_string(),
1490 reason: format!(
1491 "Failed to convert Base64-decoded bytes to UTF-8: {}",
1492 e
1493 ),
1494 })
1495 })
1496 }
1497 }
1498 }
1499 }
1500
1501 #[cfg(all(test, feature = "async"))]
1503 mod async_tests {
1504 use super::*;
1505 use crate::{FromDomain, IntoDomain, MigratesTo, Versioned};
1506 use serde::{Deserialize, Serialize};
1507 use tempfile::TempDir;
1508
1509 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1511 struct SessionV1_0_0 {
1512 id: String,
1513 user_id: String,
1514 }
1515
1516 impl Versioned for SessionV1_0_0 {
1517 const VERSION: &'static str = "1.0.0";
1518 }
1519
1520 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1521 struct SessionV1_1_0 {
1522 id: String,
1523 user_id: String,
1524 created_at: Option<String>,
1525 }
1526
1527 impl Versioned for SessionV1_1_0 {
1528 const VERSION: &'static str = "1.1.0";
1529 }
1530
1531 impl MigratesTo<SessionV1_1_0> for SessionV1_0_0 {
1532 fn migrate(self) -> SessionV1_1_0 {
1533 SessionV1_1_0 {
1534 id: self.id,
1535 user_id: self.user_id,
1536 created_at: None,
1537 }
1538 }
1539 }
1540
1541 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1542 struct SessionEntity {
1543 id: String,
1544 user_id: String,
1545 created_at: Option<String>,
1546 }
1547
1548 impl IntoDomain<SessionEntity> for SessionV1_1_0 {
1549 fn into_domain(self) -> SessionEntity {
1550 SessionEntity {
1551 id: self.id,
1552 user_id: self.user_id,
1553 created_at: self.created_at,
1554 }
1555 }
1556 }
1557
1558 impl FromDomain<SessionEntity> for SessionV1_1_0 {
1559 fn from_domain(domain: SessionEntity) -> Self {
1560 SessionV1_1_0 {
1561 id: domain.id,
1562 user_id: domain.user_id,
1563 created_at: domain.created_at,
1564 }
1565 }
1566 }
1567
1568 fn setup_session_migrator() -> Migrator {
1569 let path = Migrator::define("session")
1570 .from::<SessionV1_0_0>()
1571 .step::<SessionV1_1_0>()
1572 .into_with_save::<SessionEntity>();
1573
1574 let mut migrator = Migrator::new();
1575 migrator.register(path).unwrap();
1576 migrator
1577 }
1578
1579 #[tokio::test]
1580 async fn test_async_dir_storage_new_creates_directory() {
1581 let temp_dir = TempDir::new().unwrap();
1582 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1583 temp_dir.path().to_path_buf(),
1584 ));
1585
1586 let migrator = Migrator::new();
1587 let strategy = DirStorageStrategy::default();
1588
1589 let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1590 .await
1591 .unwrap();
1592
1593 assert!(storage.base_path.exists());
1595 assert!(storage.base_path.is_dir());
1596 assert!(storage.base_path.ends_with("data/testapp/sessions"));
1597 }
1598
1599 #[tokio::test]
1600 async fn test_async_dir_storage_save_and_load_roundtrip() {
1601 let temp_dir = TempDir::new().unwrap();
1602 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1603 temp_dir.path().to_path_buf(),
1604 ));
1605
1606 let migrator = setup_session_migrator();
1607 let strategy = DirStorageStrategy::default();
1608 let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1609 .await
1610 .unwrap();
1611
1612 let sessions = vec![
1614 SessionEntity {
1615 id: "session-1".to_string(),
1616 user_id: "user-1".to_string(),
1617 created_at: Some("2024-01-01".to_string()),
1618 },
1619 SessionEntity {
1620 id: "session-2".to_string(),
1621 user_id: "user-2".to_string(),
1622 created_at: None,
1623 },
1624 SessionEntity {
1625 id: "session-3".to_string(),
1626 user_id: "user-3".to_string(),
1627 created_at: Some("2024-03-01".to_string()),
1628 },
1629 ];
1630
1631 for session in &sessions {
1633 storage
1634 .save("session", &session.id, session.clone())
1635 .await
1636 .unwrap();
1637 }
1638
1639 for session in &sessions {
1641 let loaded: SessionEntity = storage.load("session", &session.id).await.unwrap();
1642 assert_eq!(loaded.id, session.id);
1643 assert_eq!(loaded.user_id, session.user_id);
1644 assert_eq!(loaded.created_at, session.created_at);
1645 }
1646 }
1647
1648 #[tokio::test]
1649 async fn test_async_dir_storage_list_ids() {
1650 let temp_dir = TempDir::new().unwrap();
1651 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1652 temp_dir.path().to_path_buf(),
1653 ));
1654
1655 let migrator = setup_session_migrator();
1656 let strategy = DirStorageStrategy::default();
1657 let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1658 .await
1659 .unwrap();
1660
1661 let ids = vec!["session-c", "session-a", "session-b"];
1663 for id in &ids {
1664 let session = SessionEntity {
1665 id: id.to_string(),
1666 user_id: "user".to_string(),
1667 created_at: None,
1668 };
1669 storage.save("session", id, session).await.unwrap();
1670 }
1671
1672 let listed_ids = storage.list_ids().await.unwrap();
1674 assert_eq!(listed_ids.len(), 3);
1675 assert_eq!(listed_ids, vec!["session-a", "session-b", "session-c"]);
1677 }
1678
1679 #[tokio::test]
1680 async fn test_async_dir_storage_load_all() {
1681 let temp_dir = TempDir::new().unwrap();
1682 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1683 temp_dir.path().to_path_buf(),
1684 ));
1685
1686 let migrator = setup_session_migrator();
1687 let strategy = DirStorageStrategy::default();
1688 let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1689 .await
1690 .unwrap();
1691
1692 let sessions = vec![
1694 SessionEntity {
1695 id: "session-x".to_string(),
1696 user_id: "user-x".to_string(),
1697 created_at: Some("2024-01-01".to_string()),
1698 },
1699 SessionEntity {
1700 id: "session-y".to_string(),
1701 user_id: "user-y".to_string(),
1702 created_at: None,
1703 },
1704 SessionEntity {
1705 id: "session-z".to_string(),
1706 user_id: "user-z".to_string(),
1707 created_at: Some("2024-03-01".to_string()),
1708 },
1709 ];
1710
1711 for session in &sessions {
1712 storage
1713 .save("session", &session.id, session.clone())
1714 .await
1715 .unwrap();
1716 }
1717
1718 let results: Vec<(String, SessionEntity)> = storage.load_all("session").await.unwrap();
1720 assert_eq!(results.len(), 3);
1721
1722 for (id, loaded) in &results {
1724 let original = sessions.iter().find(|s| &s.id == id).unwrap();
1725 assert_eq!(loaded.id, original.id);
1726 assert_eq!(loaded.user_id, original.user_id);
1727 assert_eq!(loaded.created_at, original.created_at);
1728 }
1729 }
1730
1731 #[tokio::test]
1732 async fn test_async_dir_storage_delete() {
1733 let temp_dir = TempDir::new().unwrap();
1734 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1735 temp_dir.path().to_path_buf(),
1736 ));
1737
1738 let migrator = setup_session_migrator();
1739 let strategy = DirStorageStrategy::default();
1740 let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1741 .await
1742 .unwrap();
1743
1744 let session = SessionEntity {
1746 id: "session-delete".to_string(),
1747 user_id: "user-delete".to_string(),
1748 created_at: None,
1749 };
1750 storage
1751 .save("session", "session-delete", session)
1752 .await
1753 .unwrap();
1754
1755 assert!(storage.exists("session-delete").await.unwrap());
1757
1758 storage.delete("session-delete").await.unwrap();
1760
1761 assert!(!storage.exists("session-delete").await.unwrap());
1763 }
1764
1765 #[tokio::test]
1766 async fn test_async_dir_storage_filename_encoding_url_roundtrip() {
1767 let temp_dir = TempDir::new().unwrap();
1768 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1769 temp_dir.path().to_path_buf(),
1770 ));
1771
1772 let migrator = setup_session_migrator();
1773 let strategy =
1774 DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
1775 let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1776 .await
1777 .unwrap();
1778
1779 let complex_id = "user@example.com/path?query=1";
1781 let session = SessionEntity {
1782 id: complex_id.to_string(),
1783 user_id: "user-special".to_string(),
1784 created_at: Some("2024-05-01".to_string()),
1785 };
1786
1787 storage
1789 .save("session", complex_id, session.clone())
1790 .await
1791 .unwrap();
1792
1793 let encoded_id = urlencoding::encode(complex_id);
1795 let file_path = storage.base_path.join(format!("{}.json", encoded_id));
1796 assert!(file_path.exists());
1797
1798 let loaded: SessionEntity = storage.load("session", complex_id).await.unwrap();
1800 assert_eq!(loaded.id, session.id);
1801 assert_eq!(loaded.user_id, session.user_id);
1802 assert_eq!(loaded.created_at, session.created_at);
1803
1804 let ids = storage.list_ids().await.unwrap();
1806 assert_eq!(ids.len(), 1);
1807 assert_eq!(ids[0], complex_id);
1808 }
1809
1810 #[tokio::test]
1811 async fn test_async_dir_storage_filename_encoding_base64_roundtrip() {
1812 let temp_dir = TempDir::new().unwrap();
1813 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1814 temp_dir.path().to_path_buf(),
1815 ));
1816
1817 let migrator = setup_session_migrator();
1818 let strategy =
1819 DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
1820 let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1821 .await
1822 .unwrap();
1823
1824 let complex_id = "user@example.com/path?query=1&special=!@#$%";
1826 let session = SessionEntity {
1827 id: complex_id.to_string(),
1828 user_id: "user-base64".to_string(),
1829 created_at: Some("2024-06-01".to_string()),
1830 };
1831
1832 storage
1834 .save("session", complex_id, session.clone())
1835 .await
1836 .unwrap();
1837
1838 let encoded_id = URL_SAFE_NO_PAD.encode(complex_id.as_bytes());
1840 let file_path = storage.base_path.join(format!("{}.json", encoded_id));
1841 assert!(file_path.exists());
1842
1843 let loaded: SessionEntity = storage.load("session", complex_id).await.unwrap();
1845 assert_eq!(loaded.id, session.id);
1846 assert_eq!(loaded.user_id, session.user_id);
1847 assert_eq!(loaded.created_at, session.created_at);
1848
1849 let ids = storage.list_ids().await.unwrap();
1851 assert_eq!(ids.len(), 1);
1852 assert_eq!(ids[0], complex_id);
1853 }
1854 }
1855}
1856
1857#[cfg(test)]
1858mod tests {
1859 use super::*;
1860 use tempfile::TempDir;
1861
1862 #[test]
1863 fn test_filename_encoding_default() {
1864 assert_eq!(FilenameEncoding::default(), FilenameEncoding::Direct);
1865 }
1866
1867 #[test]
1868 fn test_dir_storage_strategy_default() {
1869 let strategy = DirStorageStrategy::default();
1870 assert_eq!(strategy.format, FormatStrategy::Json);
1871 assert_eq!(strategy.extension, None);
1872 assert_eq!(strategy.filename_encoding, FilenameEncoding::Direct);
1873 }
1874
1875 #[test]
1876 fn test_dir_storage_strategy_builder() {
1877 let strategy = DirStorageStrategy::new()
1878 .with_format(FormatStrategy::Toml)
1879 .with_extension("data")
1880 .with_filename_encoding(FilenameEncoding::Base64)
1881 .with_retry_count(5)
1882 .with_cleanup(false);
1883
1884 assert_eq!(strategy.format, FormatStrategy::Toml);
1885 assert_eq!(strategy.extension, Some("data".to_string()));
1886 assert_eq!(strategy.filename_encoding, FilenameEncoding::Base64);
1887 assert_eq!(strategy.atomic_write.retry_count, 5);
1888 assert!(!strategy.atomic_write.cleanup_tmp_files);
1889 }
1890
1891 #[test]
1892 fn test_dir_storage_strategy_get_extension() {
1893 let strategy1 = DirStorageStrategy::default();
1895 assert_eq!(strategy1.get_extension(), "json");
1896
1897 let strategy2 = DirStorageStrategy::default().with_format(FormatStrategy::Toml);
1899 assert_eq!(strategy2.get_extension(), "toml");
1900
1901 let strategy3 = DirStorageStrategy::default().with_extension("custom");
1903 assert_eq!(strategy3.get_extension(), "custom");
1904 }
1905
1906 #[test]
1907 fn test_dir_storage_new_creates_directory() {
1908 let temp_dir = TempDir::new().unwrap();
1909 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1910 temp_dir.path().to_path_buf(),
1911 ));
1912
1913 let migrator = Migrator::new();
1914 let strategy = DirStorageStrategy::default();
1915
1916 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1917
1918 assert!(storage.base_path.exists());
1920 assert!(storage.base_path.is_dir());
1921 assert!(storage.base_path.ends_with("data/testapp/sessions"));
1922 }
1923
1924 #[test]
1925 fn test_dir_storage_new_idempotent() {
1926 let temp_dir = TempDir::new().unwrap();
1927 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1928 temp_dir.path().to_path_buf(),
1929 ));
1930
1931 let migrator1 = Migrator::new();
1932 let migrator2 = Migrator::new();
1933 let strategy = DirStorageStrategy::default();
1934
1935 let storage1 =
1937 DirStorage::new(paths.clone(), "sessions", migrator1, strategy.clone()).unwrap();
1938 let storage2 = DirStorage::new(paths, "sessions", migrator2, strategy).unwrap();
1939
1940 assert_eq!(storage1.base_path, storage2.base_path);
1942 }
1943
1944 use crate::{FromDomain, IntoDomain, MigratesTo, Versioned};
1946 use serde::{Deserialize, Serialize};
1947
1948 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1949 struct SessionV1_0_0 {
1950 id: String,
1951 user_id: String,
1952 }
1953
1954 impl Versioned for SessionV1_0_0 {
1955 const VERSION: &'static str = "1.0.0";
1956 }
1957
1958 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1959 struct SessionV1_1_0 {
1960 id: String,
1961 user_id: String,
1962 created_at: Option<String>,
1963 }
1964
1965 impl Versioned for SessionV1_1_0 {
1966 const VERSION: &'static str = "1.1.0";
1967 }
1968
1969 impl MigratesTo<SessionV1_1_0> for SessionV1_0_0 {
1970 fn migrate(self) -> SessionV1_1_0 {
1971 SessionV1_1_0 {
1972 id: self.id,
1973 user_id: self.user_id,
1974 created_at: None,
1975 }
1976 }
1977 }
1978
1979 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1980 struct SessionEntity {
1981 id: String,
1982 user_id: String,
1983 created_at: Option<String>,
1984 }
1985
1986 impl IntoDomain<SessionEntity> for SessionV1_1_0 {
1987 fn into_domain(self) -> SessionEntity {
1988 SessionEntity {
1989 id: self.id,
1990 user_id: self.user_id,
1991 created_at: self.created_at,
1992 }
1993 }
1994 }
1995
1996 impl FromDomain<SessionEntity> for SessionV1_1_0 {
1997 fn from_domain(domain: SessionEntity) -> Self {
1998 SessionV1_1_0 {
1999 id: domain.id,
2000 user_id: domain.user_id,
2001 created_at: domain.created_at,
2002 }
2003 }
2004 }
2005
2006 fn setup_session_migrator() -> Migrator {
2007 let path = Migrator::define("session")
2008 .from::<SessionV1_0_0>()
2009 .step::<SessionV1_1_0>()
2010 .into_with_save::<SessionEntity>();
2011
2012 let mut migrator = Migrator::new();
2013 migrator.register(path).unwrap();
2014 migrator
2015 }
2016
2017 #[test]
2018 fn test_dir_storage_save_json() {
2019 let temp_dir = TempDir::new().unwrap();
2020 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2021 temp_dir.path().to_path_buf(),
2022 ));
2023
2024 let migrator = setup_session_migrator();
2025 let strategy = DirStorageStrategy::default().with_format(FormatStrategy::Json);
2026 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2027
2028 let session = SessionEntity {
2030 id: "session-123".to_string(),
2031 user_id: "user-456".to_string(),
2032 created_at: Some("2024-01-01T00:00:00Z".to_string()),
2033 };
2034
2035 storage.save("session", "session-123", session).unwrap();
2037
2038 let file_path = storage.base_path.join("session-123.json");
2040 assert!(file_path.exists());
2041
2042 let content = std::fs::read_to_string(&file_path).unwrap();
2044 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
2045 assert_eq!(json["version"], "1.1.0");
2046 assert_eq!(json["id"], "session-123");
2047 assert_eq!(json["user_id"], "user-456");
2048 }
2049
2050 #[test]
2051 fn test_dir_storage_save_toml() {
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().with_format(FormatStrategy::Toml);
2059 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2060
2061 let session = SessionEntity {
2063 id: "session-789".to_string(),
2064 user_id: "user-101".to_string(),
2065 created_at: Some("2024-01-15T10:30:00Z".to_string()),
2066 };
2067
2068 storage.save("session", "session-789", session).unwrap();
2070
2071 let file_path = storage.base_path.join("session-789.toml");
2073 assert!(file_path.exists());
2074
2075 let content = std::fs::read_to_string(&file_path).unwrap();
2077 let toml: toml::Value = toml::from_str(&content).unwrap();
2078 assert_eq!(toml["version"].as_str().unwrap(), "1.1.0");
2079 assert_eq!(toml["id"].as_str().unwrap(), "session-789");
2080 assert_eq!(toml["created_at"].as_str().unwrap(), "2024-01-15T10:30:00Z");
2081 }
2082
2083 #[test]
2084 fn test_dir_storage_save_with_invalid_id() {
2085 let temp_dir = TempDir::new().unwrap();
2086 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2087 temp_dir.path().to_path_buf(),
2088 ));
2089
2090 let migrator = setup_session_migrator();
2091 let strategy = DirStorageStrategy::default();
2092 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2093
2094 let session = SessionEntity {
2095 id: "invalid/id".to_string(),
2096 user_id: "user-456".to_string(),
2097 created_at: None,
2098 };
2099
2100 let result = storage.save("session", "invalid/id", session);
2102 assert!(result.is_err());
2103 assert!(matches!(
2104 result.unwrap_err(),
2105 crate::MigrationError::FilenameEncoding { .. }
2106 ));
2107 }
2108
2109 #[test]
2110 fn test_dir_storage_save_with_custom_extension() {
2111 let temp_dir = TempDir::new().unwrap();
2112 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2113 temp_dir.path().to_path_buf(),
2114 ));
2115
2116 let migrator = setup_session_migrator();
2117 let strategy = DirStorageStrategy::default()
2118 .with_format(FormatStrategy::Json)
2119 .with_extension("data");
2120 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2121
2122 let session = SessionEntity {
2123 id: "session-custom".to_string(),
2124 user_id: "user-999".to_string(),
2125 created_at: None,
2126 };
2127
2128 storage.save("session", "session-custom", session).unwrap();
2129
2130 let file_path = storage.base_path.join("session-custom.data");
2132 assert!(file_path.exists());
2133 }
2134
2135 #[test]
2136 fn test_dir_storage_save_overwrites_existing() {
2137 let temp_dir = TempDir::new().unwrap();
2138 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2139 temp_dir.path().to_path_buf(),
2140 ));
2141
2142 let migrator = setup_session_migrator();
2143 let strategy = DirStorageStrategy::default();
2144 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2145
2146 let session1 = SessionEntity {
2148 id: "session-overwrite".to_string(),
2149 user_id: "user-111".to_string(),
2150 created_at: Some("2024-01-01".to_string()),
2151 };
2152 storage
2153 .save("session", "session-overwrite", session1)
2154 .unwrap();
2155
2156 let session2 = SessionEntity {
2158 id: "session-overwrite".to_string(),
2159 user_id: "user-222".to_string(),
2160 created_at: Some("2024-01-02".to_string()),
2161 };
2162 storage
2163 .save("session", "session-overwrite", session2)
2164 .unwrap();
2165
2166 let file_path = storage.base_path.join("session-overwrite.json");
2168 let content = std::fs::read_to_string(&file_path).unwrap();
2169 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
2170 assert_eq!(json["user_id"], "user-222");
2171 assert_eq!(json["created_at"], "2024-01-02");
2172 }
2173
2174 #[test]
2175 fn test_dir_storage_load_success() {
2176 let temp_dir = TempDir::new().unwrap();
2177 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2178 temp_dir.path().to_path_buf(),
2179 ));
2180
2181 let migrator = setup_session_migrator();
2182 let strategy = DirStorageStrategy::default();
2183 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2184
2185 let session = SessionEntity {
2187 id: "session-load".to_string(),
2188 user_id: "user-999".to_string(),
2189 created_at: Some("2024-02-01".to_string()),
2190 };
2191 storage
2192 .save("session", "session-load", session.clone())
2193 .unwrap();
2194
2195 let loaded: SessionEntity = storage.load("session", "session-load").unwrap();
2197 assert_eq!(loaded.id, session.id);
2198 assert_eq!(loaded.user_id, session.user_id);
2199 assert_eq!(loaded.created_at, session.created_at);
2200 }
2201
2202 #[test]
2203 fn test_dir_storage_load_not_found() {
2204 let temp_dir = TempDir::new().unwrap();
2205 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2206 temp_dir.path().to_path_buf(),
2207 ));
2208
2209 let migrator = setup_session_migrator();
2210 let strategy = DirStorageStrategy::default();
2211 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2212
2213 let result: Result<SessionEntity, _> = storage.load("session", "non-existent");
2215 assert!(result.is_err());
2216 assert!(matches!(
2217 result.unwrap_err(),
2218 MigrationError::IoError { .. }
2219 ));
2220 }
2221
2222 #[test]
2223 fn test_dir_storage_save_and_load_roundtrip() {
2224 let temp_dir = TempDir::new().unwrap();
2225 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2226 temp_dir.path().to_path_buf(),
2227 ));
2228
2229 let migrator = setup_session_migrator();
2230 let strategy = DirStorageStrategy::default();
2231 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2232
2233 let sessions = vec![
2235 SessionEntity {
2236 id: "session-1".to_string(),
2237 user_id: "user-1".to_string(),
2238 created_at: Some("2024-01-01".to_string()),
2239 },
2240 SessionEntity {
2241 id: "session-2".to_string(),
2242 user_id: "user-2".to_string(),
2243 created_at: None,
2244 },
2245 SessionEntity {
2246 id: "session-3".to_string(),
2247 user_id: "user-3".to_string(),
2248 created_at: Some("2024-03-01".to_string()),
2249 },
2250 ];
2251
2252 for session in &sessions {
2254 storage
2255 .save("session", &session.id, session.clone())
2256 .unwrap();
2257 }
2258
2259 for session in &sessions {
2261 let loaded: SessionEntity = storage.load("session", &session.id).unwrap();
2262 assert_eq!(loaded.id, session.id);
2263 assert_eq!(loaded.user_id, session.user_id);
2264 assert_eq!(loaded.created_at, session.created_at);
2265 }
2266 }
2267
2268 #[test]
2269 fn test_dir_storage_list_ids_empty() {
2270 let temp_dir = TempDir::new().unwrap();
2271 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2272 temp_dir.path().to_path_buf(),
2273 ));
2274
2275 let migrator = setup_session_migrator();
2276 let strategy = DirStorageStrategy::default();
2277 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2278
2279 let ids = storage.list_ids().unwrap();
2281 assert!(ids.is_empty());
2282 }
2283
2284 #[test]
2285 fn test_dir_storage_list_ids() {
2286 let temp_dir = TempDir::new().unwrap();
2287 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2288 temp_dir.path().to_path_buf(),
2289 ));
2290
2291 let migrator = setup_session_migrator();
2292 let strategy = DirStorageStrategy::default();
2293 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2294
2295 let ids = vec!["session-c", "session-a", "session-b"];
2297 for id in &ids {
2298 let session = SessionEntity {
2299 id: id.to_string(),
2300 user_id: "user".to_string(),
2301 created_at: None,
2302 };
2303 storage.save("session", id, session).unwrap();
2304 }
2305
2306 let listed_ids = storage.list_ids().unwrap();
2308 assert_eq!(listed_ids.len(), 3);
2309 assert_eq!(listed_ids, vec!["session-a", "session-b", "session-c"]);
2311 }
2312
2313 #[test]
2314 fn test_dir_storage_load_all_empty() {
2315 let temp_dir = TempDir::new().unwrap();
2316 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2317 temp_dir.path().to_path_buf(),
2318 ));
2319
2320 let migrator = setup_session_migrator();
2321 let strategy = DirStorageStrategy::default();
2322 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2323
2324 let results: Vec<(String, SessionEntity)> = storage.load_all("session").unwrap();
2326 assert!(results.is_empty());
2327 }
2328
2329 #[test]
2330 fn test_dir_storage_load_all() {
2331 let temp_dir = TempDir::new().unwrap();
2332 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2333 temp_dir.path().to_path_buf(),
2334 ));
2335
2336 let migrator = setup_session_migrator();
2337 let strategy = DirStorageStrategy::default();
2338 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2339
2340 let sessions = vec![
2342 SessionEntity {
2343 id: "session-x".to_string(),
2344 user_id: "user-x".to_string(),
2345 created_at: Some("2024-01-01".to_string()),
2346 },
2347 SessionEntity {
2348 id: "session-y".to_string(),
2349 user_id: "user-y".to_string(),
2350 created_at: None,
2351 },
2352 SessionEntity {
2353 id: "session-z".to_string(),
2354 user_id: "user-z".to_string(),
2355 created_at: Some("2024-03-01".to_string()),
2356 },
2357 ];
2358
2359 for session in &sessions {
2360 storage
2361 .save("session", &session.id, session.clone())
2362 .unwrap();
2363 }
2364
2365 let results: Vec<(String, SessionEntity)> = storage.load_all("session").unwrap();
2367 assert_eq!(results.len(), 3);
2368
2369 for (id, loaded) in &results {
2371 let original = sessions.iter().find(|s| &s.id == id).unwrap();
2372 assert_eq!(loaded.id, original.id);
2373 assert_eq!(loaded.user_id, original.user_id);
2374 assert_eq!(loaded.created_at, original.created_at);
2375 }
2376 }
2377
2378 #[test]
2379 fn test_dir_storage_exists() {
2380 let temp_dir = TempDir::new().unwrap();
2381 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2382 temp_dir.path().to_path_buf(),
2383 ));
2384
2385 let migrator = setup_session_migrator();
2386 let strategy = DirStorageStrategy::default();
2387 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2388
2389 assert!(!storage.exists("session-exists").unwrap());
2391
2392 let session = SessionEntity {
2394 id: "session-exists".to_string(),
2395 user_id: "user-exists".to_string(),
2396 created_at: None,
2397 };
2398 storage.save("session", "session-exists", session).unwrap();
2399
2400 assert!(storage.exists("session-exists").unwrap());
2402 }
2403
2404 #[test]
2405 fn test_dir_storage_delete() {
2406 let temp_dir = TempDir::new().unwrap();
2407 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2408 temp_dir.path().to_path_buf(),
2409 ));
2410
2411 let migrator = setup_session_migrator();
2412 let strategy = DirStorageStrategy::default();
2413 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2414
2415 let session = SessionEntity {
2417 id: "session-delete".to_string(),
2418 user_id: "user-delete".to_string(),
2419 created_at: None,
2420 };
2421 storage.save("session", "session-delete", session).unwrap();
2422
2423 assert!(storage.exists("session-delete").unwrap());
2425
2426 storage.delete("session-delete").unwrap();
2428
2429 assert!(!storage.exists("session-delete").unwrap());
2431 }
2432
2433 #[test]
2434 fn test_dir_storage_delete_idempotent() {
2435 let temp_dir = TempDir::new().unwrap();
2436 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2437 temp_dir.path().to_path_buf(),
2438 ));
2439
2440 let migrator = setup_session_migrator();
2441 let strategy = DirStorageStrategy::default();
2442 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2443
2444 storage.delete("non-existent").unwrap();
2446
2447 storage.delete("non-existent").unwrap();
2449 }
2450
2451 #[test]
2452 fn test_dir_storage_load_toml() {
2453 let temp_dir = TempDir::new().unwrap();
2454 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2455 temp_dir.path().to_path_buf(),
2456 ));
2457
2458 let migrator = setup_session_migrator();
2459 let strategy = DirStorageStrategy::default().with_format(FormatStrategy::Toml);
2460 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2461
2462 let session = SessionEntity {
2464 id: "session-toml".to_string(),
2465 user_id: "user-toml".to_string(),
2466 created_at: Some("2024-04-01".to_string()),
2467 };
2468 storage
2469 .save("session", "session-toml", session.clone())
2470 .unwrap();
2471
2472 let loaded: SessionEntity = storage.load("session", "session-toml").unwrap();
2474 assert_eq!(loaded.id, session.id);
2475 assert_eq!(loaded.user_id, session.user_id);
2476 assert_eq!(loaded.created_at, session.created_at);
2477 }
2478
2479 #[test]
2480 fn test_dir_storage_list_ids_with_custom_extension() {
2481 let temp_dir = TempDir::new().unwrap();
2482 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2483 temp_dir.path().to_path_buf(),
2484 ));
2485
2486 let migrator = setup_session_migrator();
2487 let strategy = DirStorageStrategy::default().with_extension("data");
2488 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2489
2490 let session = SessionEntity {
2492 id: "session-ext".to_string(),
2493 user_id: "user-ext".to_string(),
2494 created_at: None,
2495 };
2496 storage.save("session", "session-ext", session).unwrap();
2497
2498 let ids = storage.list_ids().unwrap();
2500 assert_eq!(ids.len(), 1);
2501 assert_eq!(ids[0], "session-ext");
2502 }
2503
2504 #[test]
2505 fn test_dir_storage_load_all_atomic_failure() {
2506 let temp_dir = TempDir::new().unwrap();
2507 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2508 temp_dir.path().to_path_buf(),
2509 ));
2510
2511 let migrator = setup_session_migrator();
2512 let strategy = DirStorageStrategy::default();
2513 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2514
2515 let session1 = SessionEntity {
2517 id: "session-1".to_string(),
2518 user_id: "user-1".to_string(),
2519 created_at: None,
2520 };
2521 storage.save("session", "session-1", session1).unwrap();
2522
2523 let corrupted_path = storage.base_path.join("session-corrupted.json");
2525 std::fs::write(&corrupted_path, "invalid json {{{").unwrap();
2526
2527 let result: Result<Vec<(String, SessionEntity)>, _> = storage.load_all("session");
2529 assert!(result.is_err());
2530 }
2531
2532 #[test]
2533 fn test_dir_storage_filename_encoding_url_roundtrip() {
2534 let temp_dir = TempDir::new().unwrap();
2535 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2536 temp_dir.path().to_path_buf(),
2537 ));
2538
2539 let migrator = setup_session_migrator();
2540 let strategy =
2541 DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
2542 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2543
2544 let complex_id = "user@example.com/path?query=1";
2546 let session = SessionEntity {
2547 id: complex_id.to_string(),
2548 user_id: "user-special".to_string(),
2549 created_at: Some("2024-05-01".to_string()),
2550 };
2551
2552 storage
2554 .save("session", complex_id, session.clone())
2555 .unwrap();
2556
2557 let encoded_id = urlencoding::encode(complex_id);
2559 let file_path = storage.base_path.join(format!("{}.json", encoded_id));
2560 assert!(file_path.exists());
2561
2562 let loaded: SessionEntity = storage.load("session", complex_id).unwrap();
2564 assert_eq!(loaded.id, session.id);
2565 assert_eq!(loaded.user_id, session.user_id);
2566 assert_eq!(loaded.created_at, session.created_at);
2567
2568 let ids = storage.list_ids().unwrap();
2570 assert_eq!(ids.len(), 1);
2571 assert_eq!(ids[0], complex_id);
2572 }
2573
2574 #[test]
2575 fn test_dir_storage_filename_encoding_base64_roundtrip() {
2576 let temp_dir = TempDir::new().unwrap();
2577 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2578 temp_dir.path().to_path_buf(),
2579 ));
2580
2581 let migrator = setup_session_migrator();
2582 let strategy =
2583 DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
2584 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2585
2586 let complex_id = "user@example.com/path?query=1&special=!@#$%";
2588 let session = SessionEntity {
2589 id: complex_id.to_string(),
2590 user_id: "user-base64".to_string(),
2591 created_at: Some("2024-06-01".to_string()),
2592 };
2593
2594 storage
2596 .save("session", complex_id, session.clone())
2597 .unwrap();
2598
2599 let encoded_id = URL_SAFE_NO_PAD.encode(complex_id.as_bytes());
2601 let file_path = storage.base_path.join(format!("{}.json", encoded_id));
2602 assert!(file_path.exists());
2603
2604 let loaded: SessionEntity = storage.load("session", complex_id).unwrap();
2606 assert_eq!(loaded.id, session.id);
2607 assert_eq!(loaded.user_id, session.user_id);
2608 assert_eq!(loaded.created_at, session.created_at);
2609
2610 let ids = storage.list_ids().unwrap();
2612 assert_eq!(ids.len(), 1);
2613 assert_eq!(ids[0], complex_id);
2614 }
2615
2616 #[test]
2617 fn test_decode_id_error_handling() {
2618 let temp_dir = TempDir::new().unwrap();
2619 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2620 temp_dir.path().to_path_buf(),
2621 ));
2622
2623 let migrator_url = setup_session_migrator();
2627 let strategy_url =
2628 DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
2629 let storage_url =
2630 DirStorage::new(paths.clone(), "sessions_url", migrator_url, strategy_url).unwrap();
2631
2632 let invalid_url_encoded = "%C0%C1"; let result = storage_url.decode_id(invalid_url_encoded);
2635 assert!(result.is_err());
2636 if let Err(MigrationError::FilenameEncoding { id, reason }) = result {
2637 assert_eq!(id, invalid_url_encoded);
2638 assert!(reason.contains("Failed to URL-decode filename"));
2639 }
2640
2641 let migrator_base64 = setup_session_migrator();
2643 let strategy_base64 =
2644 DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
2645 let storage_base64 =
2646 DirStorage::new(paths, "sessions_base64", migrator_base64, strategy_base64).unwrap();
2647
2648 let invalid_base64 = "!!!invalid@@@";
2650 let result = storage_base64.decode_id(invalid_base64);
2651 assert!(result.is_err());
2652 if let Err(MigrationError::FilenameEncoding { id, reason }) = result {
2653 assert_eq!(id, invalid_base64);
2654 assert!(reason.contains("Failed to Base64-decode filename"));
2655 }
2656
2657 let invalid_utf8_bytes = vec![0xFF, 0xFE, 0xFD];
2660 let valid_base64_invalid_utf8 = URL_SAFE_NO_PAD.encode(&invalid_utf8_bytes);
2661 let result = storage_base64.decode_id(&valid_base64_invalid_utf8);
2662 assert!(result.is_err());
2663 if let Err(MigrationError::FilenameEncoding { id, reason }) = result {
2664 assert_eq!(id, valid_base64_invalid_utf8);
2665 assert!(reason.contains("Failed to convert Base64-decoded bytes to UTF-8"));
2666 }
2667 }
2668
2669 #[test]
2670 fn test_dir_storage_base_path() {
2671 let temp_dir = TempDir::new().unwrap();
2672 let domain_name = "test_sessions";
2673 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2674 temp_dir.path().to_path_buf(),
2675 ));
2676
2677 let migrator = Migrator::new();
2678 let strategy = DirStorageStrategy::default();
2679
2680 let storage = DirStorage::new(paths, domain_name, migrator, strategy).unwrap();
2681
2682 let returned_path = storage.base_path();
2684 assert!(returned_path.ends_with(domain_name));
2685 assert!(returned_path.exists());
2686 }
2687}