vultan 1.0.1

Terminal-based, Anki-compatible spaced-repetition study tool that reads flashcards from a directory of markdown notes.
Documentation
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 }
    }

    /// Path the temp file is written to before the atomic rename.
    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)
    }
    /// Write atomically: write to `<path>.tmp`, fsync it, then rename over
    /// the target. A crash or kill between steps leaves the original
    /// `<path>` untouched (the rename is atomic on POSIX filesystems).
    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() {
        // Pre-create a stale `<path>.tmp` and verify it's overwritten and
        // renamed away by `write`. Discriminates the temp+rename impl from
        // a plain `std::fs::write`, which would leave the stale temp intact.
        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() {
        // Pre-populate the target with known-good content.
        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();

        // Force the temp-file create step to fail by removing the parent.
        // After cleanup the parent dir doesn't exist so File::create will
        // fail. Ensure write returns Err and the on-disk target (which we
        // re-create alongside) is left intact when we restore the dir.
        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");

        // Restore parent + good content; ensure write didn't somehow write
        // a partial or wrong value before erroring.
        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());
    }
}