use crate::errors::{IoOperationKind, StoreError};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
static TMP_SEQ: AtomicU64 = AtomicU64::new(0);
pub const TMP_CLEANUP_MIN_AGE: Duration = Duration::from_secs(60);
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(),
TMP_SEQ.fetch_add(1, Ordering::Relaxed)
);
Ok(parent.join(tmp_name))
}
pub fn sync_parent_dir(target_path: &Path) -> Result<(), StoreError> {
#[cfg(unix)]
if let Some(parent) = target_path.parent() {
if !parent.as_os_str().is_empty() {
let map_err = |e: std::io::Error| StoreError::IoError {
operation: IoOperationKind::Sync,
path: parent.display().to_string(),
context: Some("parent directory".to_string()),
error: e.to_string(),
};
let dir = std::fs::File::open(parent).map_err(map_err)?;
dir.sync_all().map_err(map_err)?;
}
}
#[cfg(not(unix))]
let _ = target_path;
Ok(())
}
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<()> {
cleanup_temp_files_with_min_age(target_path, TMP_CLEANUP_MIN_AGE)
}
pub fn cleanup_temp_files_with_min_age(
target_path: &Path,
min_age: Duration,
) -> 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) && is_older_than(&entry.path(), min_age) {
let _ = std::fs::remove_file(entry.path());
}
}
}
}
Ok(())
}
fn is_older_than(path: &Path, min_age: Duration) -> bool {
std::fs::metadata(path)
.and_then(|m| m.modified())
.ok()
.and_then(|mtime| mtime.elapsed().ok())
.map(|age| age >= min_age)
.unwrap_or(false)
}
#[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<()> {
cleanup_temp_files_with_min_age(target_path, super::TMP_CLEANUP_MIN_AGE).await
}
pub async fn cleanup_temp_files_with_min_age(
target_path: &Path,
min_age: std::time::Duration,
) -> 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) && super::is_older_than(&entry.path(), min_age) {
let _ = tokio::fs::remove_file(entry.path()).await;
}
}
}
Ok(())
}
pub async fn sync_parent_dir(target_path: &Path) -> Result<(), StoreError> {
#[cfg(unix)]
if let Some(parent) = target_path.parent() {
if !parent.as_os_str().is_empty() {
let map_err = |e: std::io::Error| StoreError::IoError {
operation: IoOperationKind::Sync,
path: parent.display().to_string(),
context: Some("parent directory (async)".to_string()),
error: e.to_string(),
};
let dir = tokio::fs::File::open(parent).await.map_err(map_err)?;
dir.sync_all().await.map_err(map_err)?;
}
}
#[cfg(not(unix))]
let _ = target_path;
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_get_temp_path_unique_per_call() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("config.toml");
let tmp1 = get_temp_path(&target).unwrap();
let tmp2 = get_temp_path(&target).unwrap();
assert_ne!(
tmp1, tmp2,
"concurrent writers must never share a temp path"
);
}
#[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-0");
let stale2 = dir.path().join(".data.toml.tmp.22222-0");
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_with_min_age(&target, Duration::ZERO).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_keeps_fresh_files() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("data.toml");
let in_flight = dir.path().join(".data.toml.tmp.33333-0");
fs::write(&in_flight, "in-flight").unwrap();
cleanup_temp_files(&target).unwrap();
assert!(
in_flight.exists(),
"files younger than TMP_CLEANUP_MIN_AGE must 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();
}
}