use std::path::{Path, PathBuf};
use tokio::fs::{self, File, OpenOptions};
use tokio::io::AsyncWriteExt;
use uuid::Uuid;
pub async fn write_atomic(path: &Path, content: &[u8]) -> std::io::Result<()> {
let parent = path.parent().unwrap_or(Path::new("."));
let temp_path = parent.join(format!(
".nika-tmp-{}-{}",
std::process::id(),
Uuid::new_v4().simple()
));
let mut file = File::create(&temp_path).await?;
file.write_all(content).await?;
file.flush().await?;
file.sync_all().await?;
drop(file);
match fs::rename(&temp_path, path).await {
Ok(()) => Ok(()),
Err(e) => {
let _ = fs::remove_file(&temp_path).await;
Err(e)
}
}
}
pub async fn write_append(path: &Path, content: &[u8]) -> std::io::Result<()> {
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.await?;
file.write_all(content).await?;
file.flush().await?;
file.sync_all().await?;
Ok(())
}
pub async fn write_unique(path: &Path, content: &[u8]) -> std::io::Result<PathBuf> {
if !path_exists(path).await {
write_atomic(path, content).await?;
return Ok(path.to_path_buf());
}
let stem = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let ext = path
.extension()
.map(|e| format!(".{}", e.to_string_lossy()))
.unwrap_or_default();
let parent = path.parent().unwrap_or(Path::new("."));
for i in 1..1000 {
let new_path = parent.join(format!("{}-{}{}", stem, i, ext));
if !path_exists(&new_path).await {
write_atomic(&new_path, content).await?;
return Ok(new_path);
}
}
Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"Could not generate unique filename after 1000 attempts",
))
}
pub async fn write_fail(path: &Path, content: &[u8]) -> std::io::Result<()> {
let mut file = OpenOptions::new()
.write(true)
.create_new(true) .open(path)
.await?;
file.write_all(content).await?;
file.flush().await?;
file.sync_all().await?;
Ok(())
}
async fn path_exists(path: &Path) -> bool {
fs::metadata(path).await.is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_write_atomic_creates_file() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("test.txt");
write_atomic(&path, b"Hello, World!").await.unwrap();
let content = fs::read_to_string(&path).await.unwrap();
assert_eq!(content, "Hello, World!");
}
#[tokio::test]
async fn test_write_atomic_overwrites_existing() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("test.txt");
fs::write(&path, "original").await.unwrap();
write_atomic(&path, b"updated").await.unwrap();
let content = fs::read_to_string(&path).await.unwrap();
assert_eq!(content, "updated");
}
#[tokio::test]
async fn test_write_atomic_no_temp_file_left() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("test.txt");
write_atomic(&path, b"content").await.unwrap();
let entries: Vec<_> = std::fs::read_dir(temp_dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with(".nika-tmp-"))
.collect();
assert!(entries.is_empty(), "Temp files should be cleaned up");
}
#[tokio::test]
async fn test_write_atomic_binary_content() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("binary.bin");
let binary_data: Vec<u8> = (0..=255).collect();
write_atomic(&path, &binary_data).await.unwrap();
let content = fs::read(&path).await.unwrap();
assert_eq!(content, binary_data);
}
#[tokio::test]
async fn test_write_append_creates_new_file() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("log.txt");
write_append(&path, b"line 1\n").await.unwrap();
let content = fs::read_to_string(&path).await.unwrap();
assert_eq!(content, "line 1\n");
}
#[tokio::test]
async fn test_write_append_appends_to_existing() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("log.txt");
write_append(&path, b"line 1\n").await.unwrap();
write_append(&path, b"line 2\n").await.unwrap();
write_append(&path, b"line 3\n").await.unwrap();
let content = fs::read_to_string(&path).await.unwrap();
assert_eq!(content, "line 1\nline 2\nline 3\n");
}
#[tokio::test]
async fn test_write_unique_uses_original_if_available() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("data.json");
let actual = write_unique(&path, b"{}").await.unwrap();
assert_eq!(actual, path);
assert!(path.exists());
}
#[tokio::test]
async fn test_write_unique_generates_suffix() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("data.json");
fs::write(&path, "original").await.unwrap();
let actual = write_unique(&path, b"new").await.unwrap();
assert_eq!(actual, temp_dir.path().join("data-1.json"));
assert!(actual.exists());
let original_content = fs::read_to_string(&path).await.unwrap();
assert_eq!(original_content, "original");
}
#[tokio::test]
async fn test_write_unique_increments_suffix() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("file.txt");
fs::write(&path, "0").await.unwrap();
fs::write(temp_dir.path().join("file-1.txt"), "1")
.await
.unwrap();
fs::write(temp_dir.path().join("file-2.txt"), "2")
.await
.unwrap();
let actual = write_unique(&path, b"3").await.unwrap();
assert_eq!(actual, temp_dir.path().join("file-3.txt"));
}
#[tokio::test]
async fn test_write_unique_no_extension() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("README");
fs::write(&path, "original").await.unwrap();
let actual = write_unique(&path, b"new").await.unwrap();
assert_eq!(actual, temp_dir.path().join("README-1"));
}
#[tokio::test]
async fn test_write_fail_creates_new_file() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("new.txt");
write_fail(&path, b"content").await.unwrap();
let content = fs::read_to_string(&path).await.unwrap();
assert_eq!(content, "content");
}
#[tokio::test]
async fn test_write_fail_errors_if_exists() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("existing.txt");
fs::write(&path, "existing").await.unwrap();
let result = write_fail(&path, b"new").await;
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
std::io::ErrorKind::AlreadyExists
);
let content = fs::read_to_string(&path).await.unwrap();
assert_eq!(content, "existing");
}
#[tokio::test]
async fn test_write_fail_atomic_check() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("race.txt");
let path1 = path.clone();
let path2 = path.clone();
let (r1, r2) = tokio::join!(
write_fail(&path1, b"writer 1"),
write_fail(&path2, b"writer 2"),
);
let successes = [r1.is_ok(), r2.is_ok()];
assert_eq!(
successes.iter().filter(|&&x| x).count(),
1,
"Exactly one writer should succeed"
);
}
#[tokio::test]
async fn test_write_atomic_empty_content() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("empty.txt");
write_atomic(&path, b"").await.unwrap();
let content = fs::read(&path).await.unwrap();
assert!(content.is_empty());
}
#[tokio::test]
async fn test_write_atomic_large_content() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("large.bin");
let large_content: Vec<u8> = (0..1024 * 1024).map(|i| (i % 256) as u8).collect();
write_atomic(&path, &large_content).await.unwrap();
let content = fs::read(&path).await.unwrap();
assert_eq!(content.len(), 1024 * 1024);
assert_eq!(content, large_content);
}
#[tokio::test]
async fn test_write_to_nested_existing_dir() {
let temp_dir = TempDir::new().unwrap();
let nested = temp_dir.path().join("a/b/c");
fs::create_dir_all(&nested).await.unwrap();
let path = nested.join("file.txt");
write_atomic(&path, b"nested").await.unwrap();
assert!(path.exists());
}
}