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)]
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 operation: IoOperationKind::CreateDir,
205 path: base_path.display().to_string(),
206 context: Some("storage base directory".to_string()),
207 error: e.to_string(),
208 })?;
209 }
210
211 Ok(Self {
212 base_path,
213 migrator,
214 strategy,
215 })
216 }
217
218 pub fn save<T>(&self, entity_name: &str, id: &str, entity: T) -> Result<(), MigrationError>
250 where
251 T: serde::Serialize,
252 {
253 let json_string = self.migrator.save_domain_flat(entity_name, entity)?;
255
256 let versioned_value: serde_json::Value = serde_json::from_str(&json_string)
258 .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
259
260 let content = self.serialize_content(&versioned_value)?;
262
263 let file_path = self.id_to_path(id)?;
265
266 self.atomic_write(&file_path, &content)?;
268
269 Ok(())
270 }
271
272 fn id_to_path(&self, id: &str) -> Result<PathBuf, MigrationError> {
289 let encoded_id = self.encode_id(id)?;
290 let extension = self.strategy.get_extension();
291 let filename = format!("{}.{}", encoded_id, extension);
292 Ok(self.base_path.join(filename))
293 }
294
295 fn encode_id(&self, id: &str) -> Result<String, MigrationError> {
313 match self.strategy.filename_encoding {
314 FilenameEncoding::Direct => {
315 if id
317 .chars()
318 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
319 {
320 Ok(id.to_string())
321 } else {
322 Err(MigrationError::FilenameEncoding {
323 id: id.to_string(),
324 reason: "ID contains invalid characters for Direct encoding. Only alphanumeric, '-', and '_' are allowed.".to_string(),
325 })
326 }
327 }
328 FilenameEncoding::UrlEncode => {
329 Ok(urlencoding::encode(id).into_owned())
331 }
332 FilenameEncoding::Base64 => {
333 Ok(URL_SAFE_NO_PAD.encode(id.as_bytes()))
335 }
336 }
337 }
338
339 fn serialize_content(&self, value: &serde_json::Value) -> Result<String, MigrationError> {
353 match self.strategy.format {
354 FormatStrategy::Json => serde_json::to_string_pretty(value)
355 .map_err(|e| MigrationError::SerializationError(e.to_string())),
356 FormatStrategy::Toml => {
357 let toml_value = json_to_toml(value)?;
358 toml::to_string_pretty(&toml_value)
359 .map_err(|e| MigrationError::TomlSerializeError(e.to_string()))
360 }
361 }
362 }
363
364 fn atomic_write(&self, path: &Path, content: &str) -> Result<(), MigrationError> {
387 if let Some(parent) = path.parent() {
389 if !parent.exists() {
390 fs::create_dir_all(parent).map_err(|e| MigrationError::IoError {
391 operation: IoOperationKind::CreateDir,
392 path: parent.display().to_string(),
393 context: Some("parent directory".to_string()),
394 error: e.to_string(),
395 })?;
396 }
397 }
398
399 let tmp_path = self.get_temp_path(path)?;
401
402 let mut tmp_file = File::create(&tmp_path).map_err(|e| MigrationError::IoError {
404 operation: IoOperationKind::Create,
405 path: tmp_path.display().to_string(),
406 context: Some("temporary file".to_string()),
407 error: e.to_string(),
408 })?;
409
410 tmp_file
411 .write_all(content.as_bytes())
412 .map_err(|e| MigrationError::IoError {
413 operation: IoOperationKind::Write,
414 path: tmp_path.display().to_string(),
415 context: Some("temporary file".to_string()),
416 error: e.to_string(),
417 })?;
418
419 tmp_file.sync_all().map_err(|e| MigrationError::IoError {
421 operation: IoOperationKind::Sync,
422 path: tmp_path.display().to_string(),
423 context: Some("temporary file".to_string()),
424 error: e.to_string(),
425 })?;
426
427 drop(tmp_file);
428
429 self.atomic_rename(&tmp_path, path)?;
431
432 if self.strategy.atomic_write.cleanup_tmp_files {
434 let _ = self.cleanup_temp_files(path);
435 }
436
437 Ok(())
438 }
439
440 fn get_temp_path(&self, target_path: &Path) -> Result<PathBuf, MigrationError> {
457 let parent = target_path.parent().ok_or_else(|| {
458 MigrationError::PathResolution("Path has no parent directory".to_string())
459 })?;
460
461 let file_name = target_path
462 .file_name()
463 .ok_or_else(|| MigrationError::PathResolution("Path has no file name".to_string()))?;
464
465 let tmp_name = format!(
466 ".{}.tmp.{}",
467 file_name.to_string_lossy(),
468 std::process::id()
469 );
470 Ok(parent.join(tmp_name))
471 }
472
473 fn atomic_rename(&self, tmp_path: &Path, target_path: &Path) -> Result<(), MigrationError> {
487 let mut last_error = None;
488
489 for attempt in 0..self.strategy.atomic_write.retry_count {
490 match fs::rename(tmp_path, target_path) {
491 Ok(()) => return Ok(()),
492 Err(e) => {
493 last_error = Some(e);
494 if attempt + 1 < self.strategy.atomic_write.retry_count {
495 std::thread::sleep(std::time::Duration::from_millis(10));
497 }
498 }
499 }
500 }
501
502 Err(MigrationError::IoError {
503 operation: IoOperationKind::Rename,
504 path: target_path.display().to_string(),
505 context: Some(format!(
506 "after {} retries",
507 self.strategy.atomic_write.retry_count
508 )),
509 error: last_error.unwrap().to_string(),
510 })
511 }
512
513 fn cleanup_temp_files(&self, target_path: &Path) -> std::io::Result<()> {
522 let parent = match target_path.parent() {
523 Some(p) => p,
524 None => return Ok(()),
525 };
526
527 let file_name = match target_path.file_name() {
528 Some(f) => f.to_string_lossy(),
529 None => return Ok(()),
530 };
531
532 let prefix = format!(".{}.tmp.", file_name);
533
534 if let Ok(entries) = fs::read_dir(parent) {
535 for entry in entries.flatten() {
536 if let Ok(name) = entry.file_name().into_string() {
537 if name.starts_with(&prefix) {
538 let _ = fs::remove_file(entry.path());
540 }
541 }
542 }
543 }
544
545 Ok(())
546 }
547
548 pub fn load<D>(&self, entity_name: &str, id: &str) -> Result<D, MigrationError>
576 where
577 D: serde::de::DeserializeOwned,
578 {
579 let file_path = self.id_to_path(id)?;
581
582 if !file_path.exists() {
584 return Err(MigrationError::IoError {
585 operation: IoOperationKind::Read,
586 path: file_path.display().to_string(),
587 context: None,
588 error: "File not found".to_string(),
589 });
590 }
591
592 let content = fs::read_to_string(&file_path).map_err(|e| MigrationError::IoError {
594 operation: IoOperationKind::Read,
595 path: file_path.display().to_string(),
596 context: None,
597 error: e.to_string(),
598 })?;
599
600 let value = self.deserialize_content(&content)?;
602
603 self.migrator.load_flat_from(entity_name, value)
605 }
606
607 pub fn list_ids(&self) -> Result<Vec<String>, MigrationError> {
628 let entries = fs::read_dir(&self.base_path).map_err(|e| MigrationError::IoError {
630 operation: IoOperationKind::ReadDir,
631 path: self.base_path.display().to_string(),
632 context: None,
633 error: e.to_string(),
634 })?;
635
636 let extension = self.strategy.get_extension();
637 let mut ids = Vec::new();
638
639 for entry in entries {
640 let entry = entry.map_err(|e| MigrationError::IoError {
641 operation: IoOperationKind::ReadDir,
642 path: self.base_path.display().to_string(),
643 context: Some("directory entry".to_string()),
644 error: e.to_string(),
645 })?;
646
647 let path = entry.path();
648
649 if path.is_file() {
651 if let Some(ext) = path.extension() {
652 if ext == extension.as_str() {
653 if let Some(id) = self.path_to_id(&path)? {
655 ids.push(id);
656 }
657 }
658 }
659 }
660 }
661
662 ids.sort();
664 Ok(ids)
665 }
666
667 pub fn load_all<D>(&self, entity_name: &str) -> Result<Vec<(String, D)>, MigrationError>
691 where
692 D: serde::de::DeserializeOwned,
693 {
694 let ids = self.list_ids()?;
695 let mut results = Vec::new();
696
697 for id in ids {
698 let entity = self.load(entity_name, &id)?;
699 results.push((id, entity));
700 }
701
702 Ok(results)
703 }
704
705 pub fn exists(&self, id: &str) -> Result<bool, MigrationError> {
723 let file_path = self.id_to_path(id)?;
724 Ok(file_path.exists() && file_path.is_file())
725 }
726
727 pub fn delete(&self, id: &str) -> Result<(), MigrationError> {
747 let file_path = self.id_to_path(id)?;
748
749 if file_path.exists() {
750 fs::remove_file(&file_path).map_err(|e| MigrationError::IoError {
751 operation: IoOperationKind::Delete,
752 path: file_path.display().to_string(),
753 context: None,
754 error: e.to_string(),
755 })?;
756 }
757
758 Ok(())
759 }
760
761 pub fn base_path(&self) -> &Path {
767 &self.base_path
768 }
769
770 fn deserialize_content(&self, content: &str) -> Result<serde_json::Value, MigrationError> {
784 match self.strategy.format {
785 FormatStrategy::Json => serde_json::from_str(content)
786 .map_err(|e| MigrationError::DeserializationError(e.to_string())),
787 FormatStrategy::Toml => {
788 let toml_value: toml::Value = toml::from_str(content)
789 .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
790 toml_to_json(toml_value)
791 }
792 }
793 }
794
795 fn path_to_id(&self, path: &Path) -> Result<Option<String>, MigrationError> {
809 let file_stem = match path.file_stem() {
811 Some(stem) => stem.to_string_lossy(),
812 None => return Ok(None),
813 };
814
815 let id = self.decode_id(&file_stem)?;
817 Ok(Some(id))
818 }
819
820 fn decode_id(&self, filename_stem: &str) -> Result<String, MigrationError> {
840 match self.strategy.filename_encoding {
841 FilenameEncoding::Direct => {
842 Ok(filename_stem.to_string())
844 }
845 FilenameEncoding::UrlEncode => {
846 urlencoding::decode(filename_stem)
848 .map(|s| s.into_owned())
849 .map_err(|e| MigrationError::FilenameEncoding {
850 id: filename_stem.to_string(),
851 reason: format!("Failed to URL-decode filename: {}", e),
852 })
853 }
854 FilenameEncoding::Base64 => {
855 URL_SAFE_NO_PAD
857 .decode(filename_stem.as_bytes())
858 .map_err(|e| MigrationError::FilenameEncoding {
859 id: filename_stem.to_string(),
860 reason: format!("Failed to Base64-decode filename: {}", e),
861 })
862 .and_then(|bytes| {
863 String::from_utf8(bytes).map_err(|e| MigrationError::FilenameEncoding {
864 id: filename_stem.to_string(),
865 reason: format!(
866 "Failed to convert Base64-decoded bytes to UTF-8: {}",
867 e
868 ),
869 })
870 })
871 }
872 }
873 }
874}
875
876fn json_to_toml(json_value: &serde_json::Value) -> Result<toml::Value, MigrationError> {
880 let json_str = serde_json::to_string(json_value)
881 .map_err(|e| MigrationError::SerializationError(e.to_string()))?;
882 let toml_value: toml::Value = serde_json::from_str(&json_str)
883 .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
884 Ok(toml_value)
885}
886
887fn toml_to_json(toml_value: toml::Value) -> Result<serde_json::Value, MigrationError> {
891 let json_str = serde_json::to_string(&toml_value)
892 .map_err(|e| MigrationError::SerializationError(e.to_string()))?;
893 let json_value: serde_json::Value = serde_json::from_str(&json_str)
894 .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
895 Ok(json_value)
896}
897
898#[cfg(feature = "async")]
903pub use async_impl::AsyncDirStorage;
904
905#[cfg(feature = "async")]
906mod async_impl {
907 use crate::{errors::IoOperationKind, AppPaths, MigrationError, Migrator};
908 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
909 use base64::Engine;
910 use std::path::{Path, PathBuf};
911 use tokio::io::AsyncWriteExt;
912
913 use super::{json_to_toml, toml_to_json, DirStorageStrategy, FilenameEncoding, FormatStrategy};
914
915 pub struct AsyncDirStorage {
920 base_path: PathBuf,
922 migrator: Migrator,
924 strategy: DirStorageStrategy,
926 }
927
928 impl AsyncDirStorage {
929 pub async fn new(
948 paths: AppPaths,
949 domain_name: &str,
950 migrator: Migrator,
951 strategy: DirStorageStrategy,
952 ) -> Result<Self, MigrationError> {
953 let base_path = paths.data_dir()?.join(domain_name);
955
956 if !tokio::fs::try_exists(&base_path).await.unwrap_or(false) {
958 tokio::fs::create_dir_all(&base_path).await.map_err(|e| {
959 MigrationError::IoError {
960 operation: IoOperationKind::CreateDir,
961 path: base_path.display().to_string(),
962 context: Some("storage base directory (async)".to_string()),
963 error: e.to_string(),
964 }
965 })?;
966 }
967
968 Ok(Self {
969 base_path,
970 migrator,
971 strategy,
972 })
973 }
974
975 pub async fn save<T>(
997 &self,
998 entity_name: &str,
999 id: &str,
1000 entity: T,
1001 ) -> Result<(), MigrationError>
1002 where
1003 T: serde::Serialize,
1004 {
1005 let json_string = self.migrator.save_domain_flat(entity_name, entity)?;
1007
1008 let versioned_value: serde_json::Value = serde_json::from_str(&json_string)
1010 .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
1011
1012 let content = self.serialize_content(&versioned_value)?;
1014
1015 let file_path = self.id_to_path(id)?;
1017
1018 self.atomic_write(&file_path, &content).await?;
1020
1021 Ok(())
1022 }
1023
1024 pub async fn load<D>(&self, entity_name: &str, id: &str) -> Result<D, MigrationError>
1046 where
1047 D: serde::de::DeserializeOwned,
1048 {
1049 let file_path = self.id_to_path(id)?;
1051
1052 if !tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
1054 return Err(MigrationError::IoError {
1055 operation: IoOperationKind::Read,
1056 path: file_path.display().to_string(),
1057 context: Some("async".to_string()),
1058 error: "File not found".to_string(),
1059 });
1060 }
1061
1062 let content = tokio::fs::read_to_string(&file_path).await.map_err(|e| {
1064 MigrationError::IoError {
1065 operation: IoOperationKind::Read,
1066 path: file_path.display().to_string(),
1067 context: Some("async".to_string()),
1068 error: e.to_string(),
1069 }
1070 })?;
1071
1072 let value = self.deserialize_content(&content)?;
1074
1075 self.migrator.load_flat_from(entity_name, value)
1077 }
1078
1079 pub async fn list_ids(&self) -> Result<Vec<String>, MigrationError> {
1091 let mut entries = tokio::fs::read_dir(&self.base_path).await.map_err(|e| {
1093 MigrationError::IoError {
1094 operation: IoOperationKind::ReadDir,
1095 path: self.base_path.display().to_string(),
1096 context: Some("async".to_string()),
1097 error: e.to_string(),
1098 }
1099 })?;
1100
1101 let extension = self.strategy.get_extension();
1102 let mut ids = Vec::new();
1103
1104 while let Some(entry) =
1105 entries
1106 .next_entry()
1107 .await
1108 .map_err(|e| MigrationError::IoError {
1109 operation: IoOperationKind::ReadDir,
1110 path: self.base_path.display().to_string(),
1111 context: Some("directory entry (async)".to_string()),
1112 error: e.to_string(),
1113 })?
1114 {
1115 let path = entry.path();
1116
1117 let metadata =
1119 tokio::fs::metadata(&path)
1120 .await
1121 .map_err(|e| MigrationError::IoError {
1122 operation: IoOperationKind::Read,
1123 path: path.display().to_string(),
1124 context: Some("metadata (async)".to_string()),
1125 error: e.to_string(),
1126 })?;
1127
1128 if metadata.is_file() {
1129 if let Some(ext) = path.extension() {
1130 if ext == extension.as_str() {
1131 if let Some(id) = self.path_to_id(&path)? {
1133 ids.push(id);
1134 }
1135 }
1136 }
1137 }
1138 }
1139
1140 ids.sort();
1142 Ok(ids)
1143 }
1144
1145 pub async fn load_all<D>(
1160 &self,
1161 entity_name: &str,
1162 ) -> Result<Vec<(String, D)>, MigrationError>
1163 where
1164 D: serde::de::DeserializeOwned,
1165 {
1166 let ids = self.list_ids().await?;
1167 let mut results = Vec::new();
1168
1169 for id in ids {
1170 let entity = self.load(entity_name, &id).await?;
1171 results.push((id, entity));
1172 }
1173
1174 Ok(results)
1175 }
1176
1177 pub async fn exists(&self, id: &str) -> Result<bool, MigrationError> {
1187 let file_path = self.id_to_path(id)?;
1188
1189 if !tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
1190 return Ok(false);
1191 }
1192
1193 let metadata =
1194 tokio::fs::metadata(&file_path)
1195 .await
1196 .map_err(|e| MigrationError::IoError {
1197 operation: IoOperationKind::Read,
1198 path: file_path.display().to_string(),
1199 context: Some("metadata (async)".to_string()),
1200 error: e.to_string(),
1201 })?;
1202
1203 Ok(metadata.is_file())
1204 }
1205
1206 pub async fn delete(&self, id: &str) -> Result<(), MigrationError> {
1220 let file_path = self.id_to_path(id)?;
1221
1222 if tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
1223 tokio::fs::remove_file(&file_path)
1224 .await
1225 .map_err(|e| MigrationError::IoError {
1226 operation: IoOperationKind::Delete,
1227 path: file_path.display().to_string(),
1228 context: Some("async".to_string()),
1229 error: e.to_string(),
1230 })?;
1231 }
1232
1233 Ok(())
1234 }
1235
1236 pub fn base_path(&self) -> &Path {
1242 &self.base_path
1243 }
1244
1245 fn id_to_path(&self, id: &str) -> Result<PathBuf, MigrationError> {
1251 let encoded_id = self.encode_id(id)?;
1252 let extension = self.strategy.get_extension();
1253 let filename = format!("{}.{}", encoded_id, extension);
1254 Ok(self.base_path.join(filename))
1255 }
1256
1257 fn encode_id(&self, id: &str) -> Result<String, MigrationError> {
1259 match self.strategy.filename_encoding {
1260 FilenameEncoding::Direct => {
1261 if id
1263 .chars()
1264 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1265 {
1266 Ok(id.to_string())
1267 } else {
1268 Err(MigrationError::FilenameEncoding {
1269 id: id.to_string(),
1270 reason: "ID contains invalid characters for Direct encoding. Only alphanumeric, '-', and '_' are allowed.".to_string(),
1271 })
1272 }
1273 }
1274 FilenameEncoding::UrlEncode => {
1275 Ok(urlencoding::encode(id).into_owned())
1277 }
1278 FilenameEncoding::Base64 => {
1279 Ok(URL_SAFE_NO_PAD.encode(id.as_bytes()))
1281 }
1282 }
1283 }
1284
1285 fn serialize_content(&self, value: &serde_json::Value) -> Result<String, MigrationError> {
1287 match self.strategy.format {
1288 FormatStrategy::Json => serde_json::to_string_pretty(value)
1289 .map_err(|e| MigrationError::SerializationError(e.to_string())),
1290 FormatStrategy::Toml => {
1291 let toml_value = json_to_toml(value)?;
1292 toml::to_string_pretty(&toml_value)
1293 .map_err(|e| MigrationError::TomlSerializeError(e.to_string()))
1294 }
1295 }
1296 }
1297
1298 async fn atomic_write(&self, path: &Path, content: &str) -> Result<(), MigrationError> {
1303 if let Some(parent) = path.parent() {
1305 if !tokio::fs::try_exists(parent).await.unwrap_or(false) {
1306 tokio::fs::create_dir_all(parent).await.map_err(|e| {
1307 MigrationError::IoError {
1308 operation: IoOperationKind::CreateDir,
1309 path: parent.display().to_string(),
1310 context: Some("parent directory (async)".to_string()),
1311 error: e.to_string(),
1312 }
1313 })?;
1314 }
1315 }
1316
1317 let tmp_path = self.get_temp_path(path)?;
1319
1320 let mut tmp_file =
1322 tokio::fs::File::create(&tmp_path)
1323 .await
1324 .map_err(|e| MigrationError::IoError {
1325 operation: IoOperationKind::Create,
1326 path: tmp_path.display().to_string(),
1327 context: Some("temporary file (async)".to_string()),
1328 error: e.to_string(),
1329 })?;
1330
1331 tmp_file
1332 .write_all(content.as_bytes())
1333 .await
1334 .map_err(|e| MigrationError::IoError {
1335 operation: IoOperationKind::Write,
1336 path: tmp_path.display().to_string(),
1337 context: Some("temporary file (async)".to_string()),
1338 error: e.to_string(),
1339 })?;
1340
1341 tmp_file
1343 .sync_all()
1344 .await
1345 .map_err(|e| MigrationError::IoError {
1346 operation: IoOperationKind::Sync,
1347 path: tmp_path.display().to_string(),
1348 context: Some("temporary file (async)".to_string()),
1349 error: e.to_string(),
1350 })?;
1351
1352 drop(tmp_file);
1353
1354 self.atomic_rename(&tmp_path, path).await?;
1356
1357 if self.strategy.atomic_write.cleanup_tmp_files {
1359 let _ = self.cleanup_temp_files(path).await;
1360 }
1361
1362 Ok(())
1363 }
1364
1365 fn get_temp_path(&self, target_path: &Path) -> Result<PathBuf, MigrationError> {
1367 let parent = target_path.parent().ok_or_else(|| {
1368 MigrationError::PathResolution("Path has no parent directory".to_string())
1369 })?;
1370
1371 let file_name = target_path.file_name().ok_or_else(|| {
1372 MigrationError::PathResolution("Path has no file name".to_string())
1373 })?;
1374
1375 let tmp_name = format!(
1376 ".{}.tmp.{}",
1377 file_name.to_string_lossy(),
1378 std::process::id()
1379 );
1380 Ok(parent.join(tmp_name))
1381 }
1382
1383 async fn atomic_rename(
1385 &self,
1386 tmp_path: &Path,
1387 target_path: &Path,
1388 ) -> Result<(), MigrationError> {
1389 let mut last_error = None;
1390
1391 for attempt in 0..self.strategy.atomic_write.retry_count {
1392 match tokio::fs::rename(tmp_path, target_path).await {
1393 Ok(()) => return Ok(()),
1394 Err(e) => {
1395 last_error = Some(e);
1396 if attempt + 1 < self.strategy.atomic_write.retry_count {
1397 tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
1399 }
1400 }
1401 }
1402 }
1403
1404 Err(MigrationError::IoError {
1405 operation: IoOperationKind::Rename,
1406 path: target_path.display().to_string(),
1407 context: Some(format!(
1408 "after {} retries (async)",
1409 self.strategy.atomic_write.retry_count
1410 )),
1411 error: last_error.unwrap().to_string(),
1412 })
1413 }
1414
1415 async fn cleanup_temp_files(&self, target_path: &Path) -> std::io::Result<()> {
1417 let parent = match target_path.parent() {
1418 Some(p) => p,
1419 None => return Ok(()),
1420 };
1421
1422 let file_name = match target_path.file_name() {
1423 Some(f) => f.to_string_lossy(),
1424 None => return Ok(()),
1425 };
1426
1427 let prefix = format!(".{}.tmp.", file_name);
1428
1429 let mut entries = tokio::fs::read_dir(parent).await?;
1430 while let Some(entry) = entries.next_entry().await? {
1431 if let Ok(name) = entry.file_name().into_string() {
1432 if name.starts_with(&prefix) {
1433 let _ = tokio::fs::remove_file(entry.path()).await;
1435 }
1436 }
1437 }
1438
1439 Ok(())
1440 }
1441
1442 fn deserialize_content(&self, content: &str) -> Result<serde_json::Value, MigrationError> {
1444 match self.strategy.format {
1445 FormatStrategy::Json => serde_json::from_str(content)
1446 .map_err(|e| MigrationError::DeserializationError(e.to_string())),
1447 FormatStrategy::Toml => {
1448 let toml_value: toml::Value = toml::from_str(content)
1449 .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
1450 toml_to_json(toml_value)
1451 }
1452 }
1453 }
1454
1455 fn path_to_id(&self, path: &Path) -> Result<Option<String>, MigrationError> {
1457 let file_stem = match path.file_stem() {
1459 Some(stem) => stem.to_string_lossy(),
1460 None => return Ok(None),
1461 };
1462
1463 let id = self.decode_id(&file_stem)?;
1465 Ok(Some(id))
1466 }
1467
1468 fn decode_id(&self, filename_stem: &str) -> Result<String, MigrationError> {
1470 match self.strategy.filename_encoding {
1471 FilenameEncoding::Direct => {
1472 Ok(filename_stem.to_string())
1474 }
1475 FilenameEncoding::UrlEncode => {
1476 urlencoding::decode(filename_stem)
1478 .map(|s| s.into_owned())
1479 .map_err(|e| MigrationError::FilenameEncoding {
1480 id: filename_stem.to_string(),
1481 reason: format!("Failed to URL-decode filename: {}", e),
1482 })
1483 }
1484 FilenameEncoding::Base64 => {
1485 URL_SAFE_NO_PAD
1487 .decode(filename_stem.as_bytes())
1488 .map_err(|e| MigrationError::FilenameEncoding {
1489 id: filename_stem.to_string(),
1490 reason: format!("Failed to Base64-decode filename: {}", e),
1491 })
1492 .and_then(|bytes| {
1493 String::from_utf8(bytes).map_err(|e| MigrationError::FilenameEncoding {
1494 id: filename_stem.to_string(),
1495 reason: format!(
1496 "Failed to convert Base64-decoded bytes to UTF-8: {}",
1497 e
1498 ),
1499 })
1500 })
1501 }
1502 }
1503 }
1504 }
1505
1506 #[cfg(all(test, feature = "async"))]
1508 mod async_tests {
1509 use super::*;
1510 use crate::{FromDomain, IntoDomain, MigratesTo, Versioned};
1511 use serde::{Deserialize, Serialize};
1512 use tempfile::TempDir;
1513
1514 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1516 struct SessionV1_0_0 {
1517 id: String,
1518 user_id: String,
1519 }
1520
1521 impl Versioned for SessionV1_0_0 {
1522 const VERSION: &'static str = "1.0.0";
1523 }
1524
1525 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1526 struct SessionV1_1_0 {
1527 id: String,
1528 user_id: String,
1529 created_at: Option<String>,
1530 }
1531
1532 impl Versioned for SessionV1_1_0 {
1533 const VERSION: &'static str = "1.1.0";
1534 }
1535
1536 impl MigratesTo<SessionV1_1_0> for SessionV1_0_0 {
1537 fn migrate(self) -> SessionV1_1_0 {
1538 SessionV1_1_0 {
1539 id: self.id,
1540 user_id: self.user_id,
1541 created_at: None,
1542 }
1543 }
1544 }
1545
1546 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1547 struct SessionEntity {
1548 id: String,
1549 user_id: String,
1550 created_at: Option<String>,
1551 }
1552
1553 impl IntoDomain<SessionEntity> for SessionV1_1_0 {
1554 fn into_domain(self) -> SessionEntity {
1555 SessionEntity {
1556 id: self.id,
1557 user_id: self.user_id,
1558 created_at: self.created_at,
1559 }
1560 }
1561 }
1562
1563 impl FromDomain<SessionEntity> for SessionV1_1_0 {
1564 fn from_domain(domain: SessionEntity) -> Self {
1565 SessionV1_1_0 {
1566 id: domain.id,
1567 user_id: domain.user_id,
1568 created_at: domain.created_at,
1569 }
1570 }
1571 }
1572
1573 fn setup_session_migrator() -> Migrator {
1574 let path = Migrator::define("session")
1575 .from::<SessionV1_0_0>()
1576 .step::<SessionV1_1_0>()
1577 .into_with_save::<SessionEntity>();
1578
1579 let mut migrator = Migrator::new();
1580 migrator.register(path).unwrap();
1581 migrator
1582 }
1583
1584 #[tokio::test]
1585 async fn test_async_dir_storage_new_creates_directory() {
1586 let temp_dir = TempDir::new().unwrap();
1587 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1588 temp_dir.path().to_path_buf(),
1589 ));
1590
1591 let migrator = Migrator::new();
1592 let strategy = DirStorageStrategy::default();
1593
1594 let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1595 .await
1596 .unwrap();
1597
1598 assert!(storage.base_path.exists());
1600 assert!(storage.base_path.is_dir());
1601 assert!(storage.base_path.ends_with("data/testapp/sessions"));
1602 }
1603
1604 #[tokio::test]
1605 async fn test_async_dir_storage_save_and_load_roundtrip() {
1606 let temp_dir = TempDir::new().unwrap();
1607 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1608 temp_dir.path().to_path_buf(),
1609 ));
1610
1611 let migrator = setup_session_migrator();
1612 let strategy = DirStorageStrategy::default();
1613 let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1614 .await
1615 .unwrap();
1616
1617 let sessions = vec![
1619 SessionEntity {
1620 id: "session-1".to_string(),
1621 user_id: "user-1".to_string(),
1622 created_at: Some("2024-01-01".to_string()),
1623 },
1624 SessionEntity {
1625 id: "session-2".to_string(),
1626 user_id: "user-2".to_string(),
1627 created_at: None,
1628 },
1629 SessionEntity {
1630 id: "session-3".to_string(),
1631 user_id: "user-3".to_string(),
1632 created_at: Some("2024-03-01".to_string()),
1633 },
1634 ];
1635
1636 for session in &sessions {
1638 storage
1639 .save("session", &session.id, session.clone())
1640 .await
1641 .unwrap();
1642 }
1643
1644 for session in &sessions {
1646 let loaded: SessionEntity = storage.load("session", &session.id).await.unwrap();
1647 assert_eq!(loaded.id, session.id);
1648 assert_eq!(loaded.user_id, session.user_id);
1649 assert_eq!(loaded.created_at, session.created_at);
1650 }
1651 }
1652
1653 #[tokio::test]
1654 async fn test_async_dir_storage_list_ids() {
1655 let temp_dir = TempDir::new().unwrap();
1656 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1657 temp_dir.path().to_path_buf(),
1658 ));
1659
1660 let migrator = setup_session_migrator();
1661 let strategy = DirStorageStrategy::default();
1662 let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1663 .await
1664 .unwrap();
1665
1666 let ids = vec!["session-c", "session-a", "session-b"];
1668 for id in &ids {
1669 let session = SessionEntity {
1670 id: id.to_string(),
1671 user_id: "user".to_string(),
1672 created_at: None,
1673 };
1674 storage.save("session", id, session).await.unwrap();
1675 }
1676
1677 let listed_ids = storage.list_ids().await.unwrap();
1679 assert_eq!(listed_ids.len(), 3);
1680 assert_eq!(listed_ids, vec!["session-a", "session-b", "session-c"]);
1682 }
1683
1684 #[tokio::test]
1685 async fn test_async_dir_storage_load_all() {
1686 let temp_dir = TempDir::new().unwrap();
1687 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1688 temp_dir.path().to_path_buf(),
1689 ));
1690
1691 let migrator = setup_session_migrator();
1692 let strategy = DirStorageStrategy::default();
1693 let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1694 .await
1695 .unwrap();
1696
1697 let sessions = vec![
1699 SessionEntity {
1700 id: "session-x".to_string(),
1701 user_id: "user-x".to_string(),
1702 created_at: Some("2024-01-01".to_string()),
1703 },
1704 SessionEntity {
1705 id: "session-y".to_string(),
1706 user_id: "user-y".to_string(),
1707 created_at: None,
1708 },
1709 SessionEntity {
1710 id: "session-z".to_string(),
1711 user_id: "user-z".to_string(),
1712 created_at: Some("2024-03-01".to_string()),
1713 },
1714 ];
1715
1716 for session in &sessions {
1717 storage
1718 .save("session", &session.id, session.clone())
1719 .await
1720 .unwrap();
1721 }
1722
1723 let results: Vec<(String, SessionEntity)> = storage.load_all("session").await.unwrap();
1725 assert_eq!(results.len(), 3);
1726
1727 for (id, loaded) in &results {
1729 let original = sessions.iter().find(|s| &s.id == id).unwrap();
1730 assert_eq!(loaded.id, original.id);
1731 assert_eq!(loaded.user_id, original.user_id);
1732 assert_eq!(loaded.created_at, original.created_at);
1733 }
1734 }
1735
1736 #[tokio::test]
1737 async fn test_async_dir_storage_delete() {
1738 let temp_dir = TempDir::new().unwrap();
1739 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1740 temp_dir.path().to_path_buf(),
1741 ));
1742
1743 let migrator = setup_session_migrator();
1744 let strategy = DirStorageStrategy::default();
1745 let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1746 .await
1747 .unwrap();
1748
1749 let session = SessionEntity {
1751 id: "session-delete".to_string(),
1752 user_id: "user-delete".to_string(),
1753 created_at: None,
1754 };
1755 storage
1756 .save("session", "session-delete", session)
1757 .await
1758 .unwrap();
1759
1760 assert!(storage.exists("session-delete").await.unwrap());
1762
1763 storage.delete("session-delete").await.unwrap();
1765
1766 assert!(!storage.exists("session-delete").await.unwrap());
1768 }
1769
1770 #[tokio::test]
1771 async fn test_async_dir_storage_filename_encoding_url_roundtrip() {
1772 let temp_dir = TempDir::new().unwrap();
1773 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1774 temp_dir.path().to_path_buf(),
1775 ));
1776
1777 let migrator = setup_session_migrator();
1778 let strategy =
1779 DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
1780 let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1781 .await
1782 .unwrap();
1783
1784 let complex_id = "user@example.com/path?query=1";
1786 let session = SessionEntity {
1787 id: complex_id.to_string(),
1788 user_id: "user-special".to_string(),
1789 created_at: Some("2024-05-01".to_string()),
1790 };
1791
1792 storage
1794 .save("session", complex_id, session.clone())
1795 .await
1796 .unwrap();
1797
1798 let encoded_id = urlencoding::encode(complex_id);
1800 let file_path = storage.base_path.join(format!("{}.json", encoded_id));
1801 assert!(file_path.exists());
1802
1803 let loaded: SessionEntity = storage.load("session", complex_id).await.unwrap();
1805 assert_eq!(loaded.id, session.id);
1806 assert_eq!(loaded.user_id, session.user_id);
1807 assert_eq!(loaded.created_at, session.created_at);
1808
1809 let ids = storage.list_ids().await.unwrap();
1811 assert_eq!(ids.len(), 1);
1812 assert_eq!(ids[0], complex_id);
1813 }
1814
1815 #[tokio::test]
1816 async fn test_async_dir_storage_filename_encoding_base64_roundtrip() {
1817 let temp_dir = TempDir::new().unwrap();
1818 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1819 temp_dir.path().to_path_buf(),
1820 ));
1821
1822 let migrator = setup_session_migrator();
1823 let strategy =
1824 DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
1825 let storage = AsyncDirStorage::new(paths, "sessions", migrator, strategy)
1826 .await
1827 .unwrap();
1828
1829 let complex_id = "user@example.com/path?query=1&special=!@#$%";
1831 let session = SessionEntity {
1832 id: complex_id.to_string(),
1833 user_id: "user-base64".to_string(),
1834 created_at: Some("2024-06-01".to_string()),
1835 };
1836
1837 storage
1839 .save("session", complex_id, session.clone())
1840 .await
1841 .unwrap();
1842
1843 let encoded_id = URL_SAFE_NO_PAD.encode(complex_id.as_bytes());
1845 let file_path = storage.base_path.join(format!("{}.json", encoded_id));
1846 assert!(file_path.exists());
1847
1848 let loaded: SessionEntity = storage.load("session", complex_id).await.unwrap();
1850 assert_eq!(loaded.id, session.id);
1851 assert_eq!(loaded.user_id, session.user_id);
1852 assert_eq!(loaded.created_at, session.created_at);
1853
1854 let ids = storage.list_ids().await.unwrap();
1856 assert_eq!(ids.len(), 1);
1857 assert_eq!(ids[0], complex_id);
1858 }
1859 }
1860}
1861
1862#[cfg(test)]
1863mod tests {
1864 use super::*;
1865 use tempfile::TempDir;
1866
1867 #[test]
1868 fn test_filename_encoding_default() {
1869 assert_eq!(FilenameEncoding::default(), FilenameEncoding::Direct);
1870 }
1871
1872 #[test]
1873 fn test_dir_storage_strategy_default() {
1874 let strategy = DirStorageStrategy::default();
1875 assert_eq!(strategy.format, FormatStrategy::Json);
1876 assert_eq!(strategy.extension, None);
1877 assert_eq!(strategy.filename_encoding, FilenameEncoding::Direct);
1878 }
1879
1880 #[test]
1881 fn test_dir_storage_strategy_builder() {
1882 let strategy = DirStorageStrategy::new()
1883 .with_format(FormatStrategy::Toml)
1884 .with_extension("data")
1885 .with_filename_encoding(FilenameEncoding::Base64)
1886 .with_retry_count(5)
1887 .with_cleanup(false);
1888
1889 assert_eq!(strategy.format, FormatStrategy::Toml);
1890 assert_eq!(strategy.extension, Some("data".to_string()));
1891 assert_eq!(strategy.filename_encoding, FilenameEncoding::Base64);
1892 assert_eq!(strategy.atomic_write.retry_count, 5);
1893 assert!(!strategy.atomic_write.cleanup_tmp_files);
1894 }
1895
1896 #[test]
1897 fn test_dir_storage_strategy_get_extension() {
1898 let strategy1 = DirStorageStrategy::default();
1900 assert_eq!(strategy1.get_extension(), "json");
1901
1902 let strategy2 = DirStorageStrategy::default().with_format(FormatStrategy::Toml);
1904 assert_eq!(strategy2.get_extension(), "toml");
1905
1906 let strategy3 = DirStorageStrategy::default().with_extension("custom");
1908 assert_eq!(strategy3.get_extension(), "custom");
1909 }
1910
1911 #[test]
1912 fn test_dir_storage_new_creates_directory() {
1913 let temp_dir = TempDir::new().unwrap();
1914 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1915 temp_dir.path().to_path_buf(),
1916 ));
1917
1918 let migrator = Migrator::new();
1919 let strategy = DirStorageStrategy::default();
1920
1921 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
1922
1923 assert!(storage.base_path.exists());
1925 assert!(storage.base_path.is_dir());
1926 assert!(storage.base_path.ends_with("data/testapp/sessions"));
1927 }
1928
1929 #[test]
1930 fn test_dir_storage_new_idempotent() {
1931 let temp_dir = TempDir::new().unwrap();
1932 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
1933 temp_dir.path().to_path_buf(),
1934 ));
1935
1936 let migrator1 = Migrator::new();
1937 let migrator2 = Migrator::new();
1938 let strategy = DirStorageStrategy::default();
1939
1940 let storage1 =
1942 DirStorage::new(paths.clone(), "sessions", migrator1, strategy.clone()).unwrap();
1943 let storage2 = DirStorage::new(paths, "sessions", migrator2, strategy).unwrap();
1944
1945 assert_eq!(storage1.base_path, storage2.base_path);
1947 }
1948
1949 use crate::{FromDomain, IntoDomain, MigratesTo, Versioned};
1951 use serde::{Deserialize, Serialize};
1952
1953 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1954 struct SessionV1_0_0 {
1955 id: String,
1956 user_id: String,
1957 }
1958
1959 impl Versioned for SessionV1_0_0 {
1960 const VERSION: &'static str = "1.0.0";
1961 }
1962
1963 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1964 struct SessionV1_1_0 {
1965 id: String,
1966 user_id: String,
1967 created_at: Option<String>,
1968 }
1969
1970 impl Versioned for SessionV1_1_0 {
1971 const VERSION: &'static str = "1.1.0";
1972 }
1973
1974 impl MigratesTo<SessionV1_1_0> for SessionV1_0_0 {
1975 fn migrate(self) -> SessionV1_1_0 {
1976 SessionV1_1_0 {
1977 id: self.id,
1978 user_id: self.user_id,
1979 created_at: None,
1980 }
1981 }
1982 }
1983
1984 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1985 struct SessionEntity {
1986 id: String,
1987 user_id: String,
1988 created_at: Option<String>,
1989 }
1990
1991 impl IntoDomain<SessionEntity> for SessionV1_1_0 {
1992 fn into_domain(self) -> SessionEntity {
1993 SessionEntity {
1994 id: self.id,
1995 user_id: self.user_id,
1996 created_at: self.created_at,
1997 }
1998 }
1999 }
2000
2001 impl FromDomain<SessionEntity> for SessionV1_1_0 {
2002 fn from_domain(domain: SessionEntity) -> Self {
2003 SessionV1_1_0 {
2004 id: domain.id,
2005 user_id: domain.user_id,
2006 created_at: domain.created_at,
2007 }
2008 }
2009 }
2010
2011 fn setup_session_migrator() -> Migrator {
2012 let path = Migrator::define("session")
2013 .from::<SessionV1_0_0>()
2014 .step::<SessionV1_1_0>()
2015 .into_with_save::<SessionEntity>();
2016
2017 let mut migrator = Migrator::new();
2018 migrator.register(path).unwrap();
2019 migrator
2020 }
2021
2022 #[test]
2023 fn test_dir_storage_save_json() {
2024 let temp_dir = TempDir::new().unwrap();
2025 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2026 temp_dir.path().to_path_buf(),
2027 ));
2028
2029 let migrator = setup_session_migrator();
2030 let strategy = DirStorageStrategy::default().with_format(FormatStrategy::Json);
2031 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2032
2033 let session = SessionEntity {
2035 id: "session-123".to_string(),
2036 user_id: "user-456".to_string(),
2037 created_at: Some("2024-01-01T00:00:00Z".to_string()),
2038 };
2039
2040 storage.save("session", "session-123", session).unwrap();
2042
2043 let file_path = storage.base_path.join("session-123.json");
2045 assert!(file_path.exists());
2046
2047 let content = std::fs::read_to_string(&file_path).unwrap();
2049 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
2050 assert_eq!(json["version"], "1.1.0");
2051 assert_eq!(json["id"], "session-123");
2052 assert_eq!(json["user_id"], "user-456");
2053 }
2054
2055 #[test]
2056 fn test_dir_storage_save_toml() {
2057 let temp_dir = TempDir::new().unwrap();
2058 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2059 temp_dir.path().to_path_buf(),
2060 ));
2061
2062 let migrator = setup_session_migrator();
2063 let strategy = DirStorageStrategy::default().with_format(FormatStrategy::Toml);
2064 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2065
2066 let session = SessionEntity {
2068 id: "session-789".to_string(),
2069 user_id: "user-101".to_string(),
2070 created_at: Some("2024-01-15T10:30:00Z".to_string()),
2071 };
2072
2073 storage.save("session", "session-789", session).unwrap();
2075
2076 let file_path = storage.base_path.join("session-789.toml");
2078 assert!(file_path.exists());
2079
2080 let content = std::fs::read_to_string(&file_path).unwrap();
2082 let toml: toml::Value = toml::from_str(&content).unwrap();
2083 assert_eq!(toml["version"].as_str().unwrap(), "1.1.0");
2084 assert_eq!(toml["id"].as_str().unwrap(), "session-789");
2085 assert_eq!(toml["created_at"].as_str().unwrap(), "2024-01-15T10:30:00Z");
2086 }
2087
2088 #[test]
2089 fn test_dir_storage_save_with_invalid_id() {
2090 let temp_dir = TempDir::new().unwrap();
2091 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2092 temp_dir.path().to_path_buf(),
2093 ));
2094
2095 let migrator = setup_session_migrator();
2096 let strategy = DirStorageStrategy::default();
2097 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2098
2099 let session = SessionEntity {
2100 id: "invalid/id".to_string(),
2101 user_id: "user-456".to_string(),
2102 created_at: None,
2103 };
2104
2105 let result = storage.save("session", "invalid/id", session);
2107 assert!(result.is_err());
2108 assert!(matches!(
2109 result.unwrap_err(),
2110 crate::MigrationError::FilenameEncoding { .. }
2111 ));
2112 }
2113
2114 #[test]
2115 fn test_dir_storage_save_with_custom_extension() {
2116 let temp_dir = TempDir::new().unwrap();
2117 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2118 temp_dir.path().to_path_buf(),
2119 ));
2120
2121 let migrator = setup_session_migrator();
2122 let strategy = DirStorageStrategy::default()
2123 .with_format(FormatStrategy::Json)
2124 .with_extension("data");
2125 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2126
2127 let session = SessionEntity {
2128 id: "session-custom".to_string(),
2129 user_id: "user-999".to_string(),
2130 created_at: None,
2131 };
2132
2133 storage.save("session", "session-custom", session).unwrap();
2134
2135 let file_path = storage.base_path.join("session-custom.data");
2137 assert!(file_path.exists());
2138 }
2139
2140 #[test]
2141 fn test_dir_storage_save_overwrites_existing() {
2142 let temp_dir = TempDir::new().unwrap();
2143 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2144 temp_dir.path().to_path_buf(),
2145 ));
2146
2147 let migrator = setup_session_migrator();
2148 let strategy = DirStorageStrategy::default();
2149 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2150
2151 let session1 = SessionEntity {
2153 id: "session-overwrite".to_string(),
2154 user_id: "user-111".to_string(),
2155 created_at: Some("2024-01-01".to_string()),
2156 };
2157 storage
2158 .save("session", "session-overwrite", session1)
2159 .unwrap();
2160
2161 let session2 = SessionEntity {
2163 id: "session-overwrite".to_string(),
2164 user_id: "user-222".to_string(),
2165 created_at: Some("2024-01-02".to_string()),
2166 };
2167 storage
2168 .save("session", "session-overwrite", session2)
2169 .unwrap();
2170
2171 let file_path = storage.base_path.join("session-overwrite.json");
2173 let content = std::fs::read_to_string(&file_path).unwrap();
2174 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
2175 assert_eq!(json["user_id"], "user-222");
2176 assert_eq!(json["created_at"], "2024-01-02");
2177 }
2178
2179 #[test]
2180 fn test_dir_storage_load_success() {
2181 let temp_dir = TempDir::new().unwrap();
2182 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2183 temp_dir.path().to_path_buf(),
2184 ));
2185
2186 let migrator = setup_session_migrator();
2187 let strategy = DirStorageStrategy::default();
2188 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2189
2190 let session = SessionEntity {
2192 id: "session-load".to_string(),
2193 user_id: "user-999".to_string(),
2194 created_at: Some("2024-02-01".to_string()),
2195 };
2196 storage
2197 .save("session", "session-load", session.clone())
2198 .unwrap();
2199
2200 let loaded: SessionEntity = storage.load("session", "session-load").unwrap();
2202 assert_eq!(loaded.id, session.id);
2203 assert_eq!(loaded.user_id, session.user_id);
2204 assert_eq!(loaded.created_at, session.created_at);
2205 }
2206
2207 #[test]
2208 fn test_dir_storage_load_not_found() {
2209 let temp_dir = TempDir::new().unwrap();
2210 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2211 temp_dir.path().to_path_buf(),
2212 ));
2213
2214 let migrator = setup_session_migrator();
2215 let strategy = DirStorageStrategy::default();
2216 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2217
2218 let result: Result<SessionEntity, _> = storage.load("session", "non-existent");
2220 assert!(result.is_err());
2221 assert!(matches!(
2222 result.unwrap_err(),
2223 MigrationError::IoError { .. }
2224 ));
2225 }
2226
2227 #[test]
2228 fn test_dir_storage_save_and_load_roundtrip() {
2229 let temp_dir = TempDir::new().unwrap();
2230 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2231 temp_dir.path().to_path_buf(),
2232 ));
2233
2234 let migrator = setup_session_migrator();
2235 let strategy = DirStorageStrategy::default();
2236 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2237
2238 let sessions = vec![
2240 SessionEntity {
2241 id: "session-1".to_string(),
2242 user_id: "user-1".to_string(),
2243 created_at: Some("2024-01-01".to_string()),
2244 },
2245 SessionEntity {
2246 id: "session-2".to_string(),
2247 user_id: "user-2".to_string(),
2248 created_at: None,
2249 },
2250 SessionEntity {
2251 id: "session-3".to_string(),
2252 user_id: "user-3".to_string(),
2253 created_at: Some("2024-03-01".to_string()),
2254 },
2255 ];
2256
2257 for session in &sessions {
2259 storage
2260 .save("session", &session.id, session.clone())
2261 .unwrap();
2262 }
2263
2264 for session in &sessions {
2266 let loaded: SessionEntity = storage.load("session", &session.id).unwrap();
2267 assert_eq!(loaded.id, session.id);
2268 assert_eq!(loaded.user_id, session.user_id);
2269 assert_eq!(loaded.created_at, session.created_at);
2270 }
2271 }
2272
2273 #[test]
2274 fn test_dir_storage_list_ids_empty() {
2275 let temp_dir = TempDir::new().unwrap();
2276 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2277 temp_dir.path().to_path_buf(),
2278 ));
2279
2280 let migrator = setup_session_migrator();
2281 let strategy = DirStorageStrategy::default();
2282 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2283
2284 let ids = storage.list_ids().unwrap();
2286 assert!(ids.is_empty());
2287 }
2288
2289 #[test]
2290 fn test_dir_storage_list_ids() {
2291 let temp_dir = TempDir::new().unwrap();
2292 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2293 temp_dir.path().to_path_buf(),
2294 ));
2295
2296 let migrator = setup_session_migrator();
2297 let strategy = DirStorageStrategy::default();
2298 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2299
2300 let ids = vec!["session-c", "session-a", "session-b"];
2302 for id in &ids {
2303 let session = SessionEntity {
2304 id: id.to_string(),
2305 user_id: "user".to_string(),
2306 created_at: None,
2307 };
2308 storage.save("session", id, session).unwrap();
2309 }
2310
2311 let listed_ids = storage.list_ids().unwrap();
2313 assert_eq!(listed_ids.len(), 3);
2314 assert_eq!(listed_ids, vec!["session-a", "session-b", "session-c"]);
2316 }
2317
2318 #[test]
2319 fn test_dir_storage_load_all_empty() {
2320 let temp_dir = TempDir::new().unwrap();
2321 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2322 temp_dir.path().to_path_buf(),
2323 ));
2324
2325 let migrator = setup_session_migrator();
2326 let strategy = DirStorageStrategy::default();
2327 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2328
2329 let results: Vec<(String, SessionEntity)> = storage.load_all("session").unwrap();
2331 assert!(results.is_empty());
2332 }
2333
2334 #[test]
2335 fn test_dir_storage_load_all() {
2336 let temp_dir = TempDir::new().unwrap();
2337 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2338 temp_dir.path().to_path_buf(),
2339 ));
2340
2341 let migrator = setup_session_migrator();
2342 let strategy = DirStorageStrategy::default();
2343 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2344
2345 let sessions = vec![
2347 SessionEntity {
2348 id: "session-x".to_string(),
2349 user_id: "user-x".to_string(),
2350 created_at: Some("2024-01-01".to_string()),
2351 },
2352 SessionEntity {
2353 id: "session-y".to_string(),
2354 user_id: "user-y".to_string(),
2355 created_at: None,
2356 },
2357 SessionEntity {
2358 id: "session-z".to_string(),
2359 user_id: "user-z".to_string(),
2360 created_at: Some("2024-03-01".to_string()),
2361 },
2362 ];
2363
2364 for session in &sessions {
2365 storage
2366 .save("session", &session.id, session.clone())
2367 .unwrap();
2368 }
2369
2370 let results: Vec<(String, SessionEntity)> = storage.load_all("session").unwrap();
2372 assert_eq!(results.len(), 3);
2373
2374 for (id, loaded) in &results {
2376 let original = sessions.iter().find(|s| &s.id == id).unwrap();
2377 assert_eq!(loaded.id, original.id);
2378 assert_eq!(loaded.user_id, original.user_id);
2379 assert_eq!(loaded.created_at, original.created_at);
2380 }
2381 }
2382
2383 #[test]
2384 fn test_dir_storage_exists() {
2385 let temp_dir = TempDir::new().unwrap();
2386 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2387 temp_dir.path().to_path_buf(),
2388 ));
2389
2390 let migrator = setup_session_migrator();
2391 let strategy = DirStorageStrategy::default();
2392 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2393
2394 assert!(!storage.exists("session-exists").unwrap());
2396
2397 let session = SessionEntity {
2399 id: "session-exists".to_string(),
2400 user_id: "user-exists".to_string(),
2401 created_at: None,
2402 };
2403 storage.save("session", "session-exists", session).unwrap();
2404
2405 assert!(storage.exists("session-exists").unwrap());
2407 }
2408
2409 #[test]
2410 fn test_dir_storage_delete() {
2411 let temp_dir = TempDir::new().unwrap();
2412 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2413 temp_dir.path().to_path_buf(),
2414 ));
2415
2416 let migrator = setup_session_migrator();
2417 let strategy = DirStorageStrategy::default();
2418 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2419
2420 let session = SessionEntity {
2422 id: "session-delete".to_string(),
2423 user_id: "user-delete".to_string(),
2424 created_at: None,
2425 };
2426 storage.save("session", "session-delete", session).unwrap();
2427
2428 assert!(storage.exists("session-delete").unwrap());
2430
2431 storage.delete("session-delete").unwrap();
2433
2434 assert!(!storage.exists("session-delete").unwrap());
2436 }
2437
2438 #[test]
2439 fn test_dir_storage_delete_idempotent() {
2440 let temp_dir = TempDir::new().unwrap();
2441 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2442 temp_dir.path().to_path_buf(),
2443 ));
2444
2445 let migrator = setup_session_migrator();
2446 let strategy = DirStorageStrategy::default();
2447 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2448
2449 storage.delete("non-existent").unwrap();
2451
2452 storage.delete("non-existent").unwrap();
2454 }
2455
2456 #[test]
2457 fn test_dir_storage_load_toml() {
2458 let temp_dir = TempDir::new().unwrap();
2459 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2460 temp_dir.path().to_path_buf(),
2461 ));
2462
2463 let migrator = setup_session_migrator();
2464 let strategy = DirStorageStrategy::default().with_format(FormatStrategy::Toml);
2465 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2466
2467 let session = SessionEntity {
2469 id: "session-toml".to_string(),
2470 user_id: "user-toml".to_string(),
2471 created_at: Some("2024-04-01".to_string()),
2472 };
2473 storage
2474 .save("session", "session-toml", session.clone())
2475 .unwrap();
2476
2477 let loaded: SessionEntity = storage.load("session", "session-toml").unwrap();
2479 assert_eq!(loaded.id, session.id);
2480 assert_eq!(loaded.user_id, session.user_id);
2481 assert_eq!(loaded.created_at, session.created_at);
2482 }
2483
2484 #[test]
2485 fn test_dir_storage_list_ids_with_custom_extension() {
2486 let temp_dir = TempDir::new().unwrap();
2487 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2488 temp_dir.path().to_path_buf(),
2489 ));
2490
2491 let migrator = setup_session_migrator();
2492 let strategy = DirStorageStrategy::default().with_extension("data");
2493 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2494
2495 let session = SessionEntity {
2497 id: "session-ext".to_string(),
2498 user_id: "user-ext".to_string(),
2499 created_at: None,
2500 };
2501 storage.save("session", "session-ext", session).unwrap();
2502
2503 let ids = storage.list_ids().unwrap();
2505 assert_eq!(ids.len(), 1);
2506 assert_eq!(ids[0], "session-ext");
2507 }
2508
2509 #[test]
2510 fn test_dir_storage_load_all_atomic_failure() {
2511 let temp_dir = TempDir::new().unwrap();
2512 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2513 temp_dir.path().to_path_buf(),
2514 ));
2515
2516 let migrator = setup_session_migrator();
2517 let strategy = DirStorageStrategy::default();
2518 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2519
2520 let session1 = SessionEntity {
2522 id: "session-1".to_string(),
2523 user_id: "user-1".to_string(),
2524 created_at: None,
2525 };
2526 storage.save("session", "session-1", session1).unwrap();
2527
2528 let corrupted_path = storage.base_path.join("session-corrupted.json");
2530 std::fs::write(&corrupted_path, "invalid json {{{").unwrap();
2531
2532 let result: Result<Vec<(String, SessionEntity)>, _> = storage.load_all("session");
2534 assert!(result.is_err());
2535 }
2536
2537 #[test]
2538 fn test_dir_storage_filename_encoding_url_roundtrip() {
2539 let temp_dir = TempDir::new().unwrap();
2540 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2541 temp_dir.path().to_path_buf(),
2542 ));
2543
2544 let migrator = setup_session_migrator();
2545 let strategy =
2546 DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
2547 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2548
2549 let complex_id = "user@example.com/path?query=1";
2551 let session = SessionEntity {
2552 id: complex_id.to_string(),
2553 user_id: "user-special".to_string(),
2554 created_at: Some("2024-05-01".to_string()),
2555 };
2556
2557 storage
2559 .save("session", complex_id, session.clone())
2560 .unwrap();
2561
2562 let encoded_id = urlencoding::encode(complex_id);
2564 let file_path = storage.base_path.join(format!("{}.json", encoded_id));
2565 assert!(file_path.exists());
2566
2567 let loaded: SessionEntity = storage.load("session", complex_id).unwrap();
2569 assert_eq!(loaded.id, session.id);
2570 assert_eq!(loaded.user_id, session.user_id);
2571 assert_eq!(loaded.created_at, session.created_at);
2572
2573 let ids = storage.list_ids().unwrap();
2575 assert_eq!(ids.len(), 1);
2576 assert_eq!(ids[0], complex_id);
2577 }
2578
2579 #[test]
2580 fn test_dir_storage_filename_encoding_base64_roundtrip() {
2581 let temp_dir = TempDir::new().unwrap();
2582 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2583 temp_dir.path().to_path_buf(),
2584 ));
2585
2586 let migrator = setup_session_migrator();
2587 let strategy =
2588 DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
2589 let storage = DirStorage::new(paths, "sessions", migrator, strategy).unwrap();
2590
2591 let complex_id = "user@example.com/path?query=1&special=!@#$%";
2593 let session = SessionEntity {
2594 id: complex_id.to_string(),
2595 user_id: "user-base64".to_string(),
2596 created_at: Some("2024-06-01".to_string()),
2597 };
2598
2599 storage
2601 .save("session", complex_id, session.clone())
2602 .unwrap();
2603
2604 let encoded_id = URL_SAFE_NO_PAD.encode(complex_id.as_bytes());
2606 let file_path = storage.base_path.join(format!("{}.json", encoded_id));
2607 assert!(file_path.exists());
2608
2609 let loaded: SessionEntity = storage.load("session", complex_id).unwrap();
2611 assert_eq!(loaded.id, session.id);
2612 assert_eq!(loaded.user_id, session.user_id);
2613 assert_eq!(loaded.created_at, session.created_at);
2614
2615 let ids = storage.list_ids().unwrap();
2617 assert_eq!(ids.len(), 1);
2618 assert_eq!(ids[0], complex_id);
2619 }
2620
2621 #[test]
2622 fn test_decode_id_error_handling() {
2623 let temp_dir = TempDir::new().unwrap();
2624 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2625 temp_dir.path().to_path_buf(),
2626 ));
2627
2628 let migrator_url = setup_session_migrator();
2632 let strategy_url =
2633 DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::UrlEncode);
2634 let storage_url =
2635 DirStorage::new(paths.clone(), "sessions_url", migrator_url, strategy_url).unwrap();
2636
2637 let invalid_url_encoded = "%C0%C1"; let result = storage_url.decode_id(invalid_url_encoded);
2640 assert!(result.is_err());
2641 if let Err(MigrationError::FilenameEncoding { id, reason }) = result {
2642 assert_eq!(id, invalid_url_encoded);
2643 assert!(reason.contains("Failed to URL-decode filename"));
2644 }
2645
2646 let migrator_base64 = setup_session_migrator();
2648 let strategy_base64 =
2649 DirStorageStrategy::default().with_filename_encoding(FilenameEncoding::Base64);
2650 let storage_base64 =
2651 DirStorage::new(paths, "sessions_base64", migrator_base64, strategy_base64).unwrap();
2652
2653 let invalid_base64 = "!!!invalid@@@";
2655 let result = storage_base64.decode_id(invalid_base64);
2656 assert!(result.is_err());
2657 if let Err(MigrationError::FilenameEncoding { id, reason }) = result {
2658 assert_eq!(id, invalid_base64);
2659 assert!(reason.contains("Failed to Base64-decode filename"));
2660 }
2661
2662 let invalid_utf8_bytes = vec![0xFF, 0xFE, 0xFD];
2665 let valid_base64_invalid_utf8 = URL_SAFE_NO_PAD.encode(&invalid_utf8_bytes);
2666 let result = storage_base64.decode_id(&valid_base64_invalid_utf8);
2667 assert!(result.is_err());
2668 if let Err(MigrationError::FilenameEncoding { id, reason }) = result {
2669 assert_eq!(id, valid_base64_invalid_utf8);
2670 assert!(reason.contains("Failed to convert Base64-decoded bytes to UTF-8"));
2671 }
2672 }
2673
2674 #[test]
2675 fn test_dir_storage_base_path() {
2676 let temp_dir = TempDir::new().unwrap();
2677 let domain_name = "test_sessions";
2678 let paths = AppPaths::new("testapp").data_strategy(crate::PathStrategy::CustomBase(
2679 temp_dir.path().to_path_buf(),
2680 ));
2681
2682 let migrator = Migrator::new();
2683 let strategy = DirStorageStrategy::default();
2684
2685 let storage = DirStorage::new(paths, domain_name, migrator, strategy).unwrap();
2686
2687 let returned_path = storage.base_path();
2689 assert!(returned_path.ends_with(domain_name));
2690 assert!(returned_path.exists());
2691 }
2692}