use super::tools::IO;
use anyhow::Result;
use std::io::Write;
#[derive(Debug)]
pub struct FileHandle {
pub path: std::path::PathBuf,
}
impl FileHandle {
pub fn from(path: std::path::PathBuf) -> Self {
Self { path }
}
fn temp_path(&self) -> std::path::PathBuf {
let mut filename = self
.path
.file_name()
.unwrap_or_default()
.to_os_string();
filename.push(".tmp");
self.path.with_file_name(filename)
}
}
impl IO for FileHandle {
fn path(&self) -> &str {
self.path.to_str().unwrap_or("unknown")
}
fn read(&self) -> Result<String, std::io::Error> {
std::fs::read_to_string(&self.path)
}
fn write(&self, content: String) -> Result<(), std::io::Error> {
let temp = self.temp_path();
let mut file = std::fs::File::create(&temp)?;
file.write_all(content.as_bytes())?;
file.sync_all()?;
drop(file);
std::fs::rename(&temp, &self.path)
}
}
#[cfg(test)]
mod unit_tests {
use super::*;
use assert_fs::{fixture::TempDir, prelude::*};
use rstest::*;
fn assert_result<T: std::fmt::Debug + PartialEq, E1: std::fmt::Debug, E2: std::fmt::Debug>(
expected: Result<T, E1>,
actual: Result<T, E2>,
) {
if let Ok(actual) = actual {
assert_eq!(expected.expect("BAD TEST"), actual);
} else {
assert!(expected.is_err())
}
}
#[test]
fn from() {
let path = std::path::PathBuf::from("hello");
let handle = FileHandle::from(path.clone());
assert_eq!(path, handle.path);
}
#[test]
fn exposes_path_getter() {
let path_str = "hello";
let path = std::path::PathBuf::from(path_str);
let handle = FileHandle::from(path);
assert_eq!(path_str, handle.path());
}
#[rstest]
#[case::should_call_read_file("hello", Ok("hello".to_string()))]
#[case::should_propagate_error("oh dear", Err(()))]
fn read(#[case] path: &str, #[case] expected: Result<String, ()>) {
let temp_dir = TempDir::new().unwrap();
let child = temp_dir.child(path);
let path = child.path().to_path_buf();
if let Ok(expected) = expected.clone() {
child.write_str(expected.as_str()).expect("Bad Test");
}
let handle = FileHandle::from(path);
assert_result(expected, handle.read());
temp_dir.close().unwrap();
}
#[rstest]
#[case::should_call_write_file("hello", "world", Ok(()))]
#[case::should_propagate_error("hello///", "", Err(()))]
fn write(#[case] path: &str, #[case] content: &str, #[case] expected: Result<(), ()>) {
let temp_dir = TempDir::new().unwrap();
let child = temp_dir.child(path);
let path = child.path().to_path_buf();
let handle = FileHandle::from(path.clone());
assert_result(expected, handle.write(content.to_string()));
if expected.is_ok() {
assert_eq!(content, std::fs::read_to_string(path).expect("Bad Test"));
}
}
#[test]
fn write_uses_temp_file_and_atomic_rename() {
let temp_dir = TempDir::new().unwrap();
let target = temp_dir.path().join(".vultan.ron");
let temp = temp_dir.path().join(".vultan.ron.tmp");
std::fs::write(&temp, "stale").unwrap();
assert!(temp.is_file(), "pre-condition: stale temp file exists");
let handle = FileHandle::from(target.clone());
handle.write("fresh".to_string()).unwrap();
assert_eq!("fresh", std::fs::read_to_string(&target).unwrap());
assert!(
!temp.is_file(),
"temp file should not exist after successful write"
);
}
#[test]
fn write_does_not_corrupt_target_when_temp_create_fails() {
let temp_dir = TempDir::new().unwrap();
let target = temp_dir.path().join("subdir").join(".vultan.ron");
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
std::fs::write(&target, "known good").unwrap();
let parent = target.parent().unwrap().to_path_buf();
std::fs::remove_dir_all(&parent).unwrap();
let handle = FileHandle::from(target.clone());
let result = handle.write("would-be-new".to_string());
assert!(result.is_err(), "expected write to fail when parent missing");
std::fs::create_dir_all(&parent).unwrap();
std::fs::write(&target, "known good").unwrap();
assert_eq!("known good", std::fs::read_to_string(&target).unwrap());
}
}