use snafu::{Backtrace, prelude::*};
use std::{
io,
path::{Path, PathBuf},
};
use tokio::{
fs::{self, OpenOptions},
io::AsyncWriteExt,
};
use crate::storage::{
BackendError, NotFoundSnafu, OtherIoSnafu, StorageError, StorageLocation, StorageResult,
};
pub(super) struct TempFileGuard {
path: PathBuf,
armed: bool,
}
impl TempFileGuard {
pub(super) fn new(path: PathBuf) -> Self {
Self { path, armed: true }
}
pub(super) fn disarm(&mut self) {
self.armed = false;
}
}
impl Drop for TempFileGuard {
fn drop(&mut self) {
if self.armed {
let _ = std::fs::remove_file(&self.path);
}
}
}
pub(super) fn join_local(location: &StorageLocation, rel: &Path) -> PathBuf {
match location {
StorageLocation::Local(root) => root.join(rel),
}
}
pub(super) async fn create_parent_dir(abs: &Path) -> StorageResult<()> {
if let Some(parent) = abs.parent() {
fs::create_dir_all(parent)
.await
.map_err(BackendError::Local)
.context(OtherIoSnafu {
path: parent.display().to_string(),
})?;
}
Ok(())
}
pub async fn write_atomic(
location: &StorageLocation,
rel_path: &Path,
contents: &[u8],
) -> StorageResult<()> {
match location {
StorageLocation::Local(_) => {
let abs = join_local(location, rel_path);
create_parent_dir(&abs).await?;
let tmp_path = abs.with_extension("tmp");
let mut guard = TempFileGuard::new(tmp_path.clone());
{
let mut file = fs::File::create(&tmp_path)
.await
.map_err(BackendError::Local)
.context(OtherIoSnafu {
path: tmp_path.display().to_string(),
})?;
file.write_all(contents)
.await
.map_err(BackendError::Local)
.context(OtherIoSnafu {
path: tmp_path.display().to_string(),
})?;
file.sync_all()
.await
.map_err(BackendError::Local)
.context(OtherIoSnafu {
path: tmp_path.display().to_string(),
})?;
}
fs::rename(&tmp_path, &abs)
.await
.map_err(BackendError::Local)
.context(OtherIoSnafu {
path: abs.display().to_string(),
})?;
guard.disarm();
Ok(())
}
}
}
pub async fn read_to_string(location: &StorageLocation, rel_path: &Path) -> StorageResult<String> {
match location {
StorageLocation::Local(_) => {
let abs = join_local(location, rel_path);
match fs::read_to_string(&abs).await {
Ok(s) => Ok(s),
Err(e) if e.kind() == io::ErrorKind::NotFound => Err(BackendError::Local(e))
.context(NotFoundSnafu {
path: abs.display().to_string(),
}),
Err(e) => Err(BackendError::Local(e)).context(OtherIoSnafu {
path: abs.display().to_string(),
}),
}
}
}
}
pub async fn write_new(
location: &StorageLocation,
rel_path: &Path,
contents: &[u8],
) -> StorageResult<()> {
match location {
StorageLocation::Local(_) => {
let abs = join_local(location, rel_path);
create_parent_dir(&abs).await?;
let path_str = abs.display().to_string();
let open_result = OpenOptions::new()
.write(true)
.create_new(true)
.open(&abs)
.await;
let mut file = match open_result {
Ok(f) => f,
Err(e) => {
let backend = BackendError::Local(e);
let storage_err = match &backend {
BackendError::Local(inner)
if inner.kind() == io::ErrorKind::AlreadyExists =>
{
StorageError::AlreadyExists {
path: path_str,
source: backend,
backtrace: Backtrace::capture(),
}
}
_ => StorageError::OtherIo {
path: path_str,
source: backend,
backtrace: Backtrace::capture(),
},
};
return Err(storage_err);
}
};
file.write_all(contents)
.await
.map_err(BackendError::Local)
.context(OtherIoSnafu {
path: abs.display().to_string(),
})?;
file.sync_all()
.await
.map_err(BackendError::Local)
.context(OtherIoSnafu {
path: abs.display().to_string(),
})?;
Ok(())
}
}
}
pub async fn read_all_bytes(location: &StorageLocation, rel_path: &Path) -> StorageResult<Vec<u8>> {
match location {
StorageLocation::Local(_) => {
let abs = join_local(location, rel_path);
let path_str = abs.display().to_string();
match fs::read(&abs).await {
Ok(bytes) => Ok(bytes),
Err(e) if e.kind() == io::ErrorKind::NotFound => {
Err(BackendError::Local(e)).context(NotFoundSnafu { path: path_str })
}
Err(e) => Err(BackendError::Local(e)).context(OtherIoSnafu { path: path_str }),
}
}
}
}
pub async fn file_size(location: &StorageLocation, rel_path: &Path) -> StorageResult<u64> {
match location {
StorageLocation::Local(_) => {
let abs = join_local(location, rel_path);
let path_str = abs.display().to_string();
let meta = fs::metadata(&abs).await;
match meta {
Ok(m) => Ok(m.len()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Err(BackendError::Local(e)).context(NotFoundSnafu { path: path_str })
}
Err(e) => Err(BackendError::Local(e)).context(OtherIoSnafu { path: path_str }),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
type TestResult = Result<(), Box<dyn std::error::Error>>;
#[tokio::test]
async fn write_atomic_creates_file_with_contents() -> TestResult {
let tmp = TempDir::new()?;
let location = StorageLocation::local(tmp.path());
let rel_path = Path::new("test.txt");
let contents = b"hello world";
write_atomic(&location, rel_path, contents).await?;
let abs = tmp.path().join(rel_path);
let read_back = tokio::fs::read_to_string(&abs).await?;
assert_eq!(read_back, "hello world");
Ok(())
}
#[tokio::test]
async fn write_atomic_creates_parent_directories() -> TestResult {
let tmp = TempDir::new()?;
let location = StorageLocation::local(tmp.path());
let rel_path = Path::new("nested/deep/dir/file.txt");
let contents = b"nested content";
write_atomic(&location, rel_path, contents).await?;
let abs = tmp.path().join(rel_path);
assert!(abs.exists());
let read_back = tokio::fs::read_to_string(&abs).await?;
assert_eq!(read_back, "nested content");
Ok(())
}
#[tokio::test]
async fn write_atomic_overwrites_existing_file() -> TestResult {
let tmp = TempDir::new()?;
let location = StorageLocation::local(tmp.path());
let rel_path = Path::new("overwrite.txt");
write_atomic(&location, rel_path, b"original").await?;
write_atomic(&location, rel_path, b"updated").await?;
let abs = tmp.path().join(rel_path);
let read_back = tokio::fs::read_to_string(&abs).await?;
assert_eq!(read_back, "updated");
Ok(())
}
#[tokio::test]
async fn write_atomic_no_leftover_tmp_file() -> TestResult {
let tmp = TempDir::new()?;
let location = StorageLocation::local(tmp.path());
let rel_path = Path::new("clean.txt");
write_atomic(&location, rel_path, b"data").await?;
let tmp_path = tmp.path().join("clean.tmp");
assert!(!tmp_path.exists());
Ok(())
}
#[tokio::test]
async fn read_to_string_returns_file_contents() -> TestResult {
let tmp = TempDir::new()?;
let location = StorageLocation::local(tmp.path());
let rel_path = Path::new("readable.txt");
let abs = tmp.path().join(rel_path);
tokio::fs::write(&abs, "file contents").await?;
let result = read_to_string(&location, rel_path).await?;
assert_eq!(result, "file contents");
Ok(())
}
#[tokio::test]
async fn read_to_string_returns_not_found_for_missing_file() -> TestResult {
let tmp = TempDir::new()?;
let location = StorageLocation::local(tmp.path());
let rel_path = Path::new("does_not_exist.txt");
let result = read_to_string(&location, rel_path).await;
assert!(result.is_err());
let err = result.expect_err("expected NotFound error");
assert!(matches!(err, StorageError::NotFound { .. }));
Ok(())
}
#[tokio::test]
async fn write_then_read_roundtrip() -> TestResult {
let tmp = TempDir::new()?;
let location = StorageLocation::local(tmp.path());
let rel_path = Path::new("roundtrip.txt");
let original = "roundtrip content 🎉";
write_atomic(&location, rel_path, original.as_bytes()).await?;
let read_back = read_to_string(&location, rel_path).await?;
assert_eq!(read_back, original);
Ok(())
}
#[tokio::test]
async fn write_new_creates_file_with_contents() -> TestResult {
let tmp = TempDir::new()?;
let location = StorageLocation::local(tmp.path());
let rel_path = Path::new("new_file.txt");
write_new(&location, rel_path, b"new content").await?;
let abs = tmp.path().join(rel_path);
let read_back = tokio::fs::read_to_string(&abs).await?;
assert_eq!(read_back, "new content");
Ok(())
}
#[tokio::test]
async fn write_new_fails_if_file_exists() -> TestResult {
let tmp = TempDir::new()?;
let location = StorageLocation::local(tmp.path());
let rel_path = Path::new("existing.txt");
write_new(&location, rel_path, b"first").await?;
let result = write_new(&location, rel_path, b"second").await;
assert!(result.is_err());
let err = result.expect_err("expected AlreadyExists error");
assert!(matches!(err, StorageError::AlreadyExists { .. }));
let read_back = read_to_string(&location, rel_path).await?;
assert_eq!(read_back, "first");
Ok(())
}
#[tokio::test]
async fn write_new_creates_parent_directories() -> TestResult {
let tmp = TempDir::new()?;
let location = StorageLocation::local(tmp.path());
let rel_path = Path::new("nested/path/new_file.txt");
write_new(&location, rel_path, b"nested new").await?;
let abs = tmp.path().join(rel_path);
assert!(abs.exists());
let read_back = tokio::fs::read_to_string(&abs).await?;
assert_eq!(read_back, "nested new");
Ok(())
}
}