use crate::errors::{IoOperationKind, StoreError};
use std::path::{Path, PathBuf};
pub fn get_temp_path(target_path: &Path) -> Result<PathBuf, StoreError> {
let parent = target_path.parent().ok_or_else(|| StoreError::IoError {
operation: IoOperationKind::Create,
path: target_path.display().to_string(),
context: Some("path has no parent directory".to_string()),
error: "cannot determine parent for temporary file".to_string(),
})?;
let file_name = target_path.file_name().ok_or_else(|| StoreError::IoError {
operation: IoOperationKind::Create,
path: target_path.display().to_string(),
context: Some("path has no file name".to_string()),
error: "cannot determine filename for temporary file".to_string(),
})?;
let tmp_name = format!(
".{}.tmp.{}",
file_name.to_string_lossy(),
std::process::id()
);
Ok(parent.join(tmp_name))
}
pub fn atomic_rename(
tmp_path: &Path,
target_path: &Path,
retry_count: usize,
) -> Result<(), StoreError> {
let mut last_error = None;
for attempt in 0..retry_count {
match std::fs::rename(tmp_path, target_path) {
Ok(()) => return Ok(()),
Err(e) => {
last_error = Some(e);
if attempt + 1 < retry_count {
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
}
}
Err(StoreError::IoError {
operation: IoOperationKind::Rename,
path: target_path.display().to_string(),
context: Some(format!("after {} retries", retry_count)),
error: last_error
.map(|e| e.to_string())
.unwrap_or_else(|| "unknown error after retries".to_string()),
})
}
pub fn cleanup_temp_files(target_path: &Path) -> std::io::Result<()> {
let parent = match target_path.parent() {
Some(p) => p,
None => return Ok(()),
};
let file_name = match target_path.file_name() {
Some(f) => f.to_string_lossy().into_owned(),
None => return Ok(()),
};
let prefix = format!(".{}.tmp.", file_name);
if let Ok(entries) = std::fs::read_dir(parent) {
for entry in entries.flatten() {
if let Ok(name) = entry.file_name().into_string() {
if name.starts_with(&prefix) {
let _ = std::fs::remove_file(entry.path());
}
}
}
}
Ok(())
}
#[cfg(feature = "async")]
pub mod async_io {
use crate::errors::{IoOperationKind, StoreError};
use std::path::Path;
pub async fn atomic_rename(
tmp_path: &Path,
target_path: &Path,
retry_count: usize,
) -> Result<(), StoreError> {
let mut last_error = None;
for attempt in 0..retry_count {
match tokio::fs::rename(tmp_path, target_path).await {
Ok(()) => return Ok(()),
Err(e) => {
last_error = Some(e);
if attempt + 1 < retry_count {
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
}
}
}
}
Err(StoreError::IoError {
operation: IoOperationKind::Rename,
path: target_path.display().to_string(),
context: Some(format!("after {} retries (async)", retry_count)),
error: last_error
.map(|e| e.to_string())
.unwrap_or_else(|| "unknown error after retries".to_string()),
})
}
pub async fn cleanup_temp_files(target_path: &Path) -> std::io::Result<()> {
let parent = match target_path.parent() {
Some(p) => p,
None => return Ok(()),
};
let file_name = match target_path.file_name() {
Some(f) => f.to_string_lossy().into_owned(),
None => return Ok(()),
};
let prefix = format!(".{}.tmp.", file_name);
let mut entries = match tokio::fs::read_dir(parent).await {
Ok(e) => e,
Err(_) => return Ok(()),
};
while let Ok(Some(entry)) = entries.next_entry().await {
if let Ok(name) = entry.file_name().into_string() {
if name.starts_with(&prefix) {
let _ = tokio::fs::remove_file(entry.path()).await;
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_get_temp_path_format() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("config.toml");
let tmp = get_temp_path(&target).unwrap();
let name = tmp.file_name().unwrap().to_string_lossy();
assert!(
name.starts_with(".config.toml.tmp."),
"tmp name should start with .<filename>.tmp., got: {}",
name
);
assert_eq!(tmp.parent().unwrap(), dir.path());
}
#[test]
fn test_get_temp_path_no_parent_errors() {
let bare = std::path::PathBuf::from("/");
let result = get_temp_path(&bare);
assert!(result.is_err(), "root path should produce an error");
}
#[test]
fn test_atomic_rename_success() {
let dir = TempDir::new().unwrap();
let src = dir.path().join("src.tmp");
let dst = dir.path().join("dst.toml");
fs::write(&src, "data").unwrap();
atomic_rename(&src, &dst, 3).unwrap();
assert!(dst.exists());
assert!(!src.exists());
assert_eq!(fs::read_to_string(&dst).unwrap(), "data");
}
#[test]
fn test_atomic_rename_fails_when_src_missing() {
let dir = TempDir::new().unwrap();
let src = dir.path().join("nonexistent.tmp");
let dst = dir.path().join("dst.toml");
let result = atomic_rename(&src, &dst, 1);
assert!(result.is_err(), "should fail when src does not exist");
if let Err(StoreError::IoError { operation, .. }) = result {
assert_eq!(operation, IoOperationKind::Rename);
} else {
panic!("expected StoreError::IoError(Rename)");
}
}
#[test]
fn test_cleanup_temp_files_removes_matching() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("data.toml");
let stale1 = dir.path().join(".data.toml.tmp.11111");
let stale2 = dir.path().join(".data.toml.tmp.22222");
let other = dir.path().join("other.toml");
fs::write(&stale1, "s1").unwrap();
fs::write(&stale2, "s2").unwrap();
fs::write(&other, "keep").unwrap();
cleanup_temp_files(&target).unwrap();
assert!(!stale1.exists(), "stale1 should be removed");
assert!(!stale2.exists(), "stale2 should be removed");
assert!(other.exists(), "other.toml should be kept");
}
#[test]
fn test_cleanup_temp_files_no_matches_is_ok() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("config.toml");
cleanup_temp_files(&target).unwrap();
}
}