vultan 1.0.1

Terminal-based, Anki-compatible spaced-repetition study tool that reads flashcards from a directory of markdown notes.
Documentation
//! Ring-buffer backups of `.vultan.ron`. Before each write we shift any
//! existing `.bak.N` files up by one and rename the current file to `.bak.0`.
//! Bounded space: at most `N_BACKUPS` historical copies are kept; the oldest
//! is discarded automatically.

use std::path::{Path, PathBuf};

pub const N_BACKUPS: usize = 3;

fn backup_path(target: &Path, idx: usize) -> PathBuf {
    let mut name = target
        .file_name()
        .map(|s| s.to_string_lossy().into_owned())
        .unwrap_or_default();
    name.push_str(&format!(".bak.{idx}"));
    target.with_file_name(name)
}

/// Rotate the historical backup files for `target` and move the current `target`
/// into the most-recent slot (`.bak.0`). If `target` does not exist this is a no-op.
/// Best-effort: missing intermediate `.bak.N` files are ignored.
pub fn rotate_backups(target: &Path) {
    if !target.is_file() {
        return;
    }
    // Walk from oldest slot down so each rename frees the slot below.
    for i in (1..N_BACKUPS).rev() {
        let src = backup_path(target, i - 1);
        let dst = backup_path(target, i);
        let _ = std::fs::rename(&src, &dst);
    }
    let _ = std::fs::rename(target, backup_path(target, 0));
}

#[cfg(test)]
mod tests {
    use super::*;
    use assert_fs::TempDir;
    use std::fs;

    fn write(path: &Path, contents: &str) {
        fs::write(path, contents).unwrap();
    }

    fn read(path: &Path) -> Option<String> {
        fs::read_to_string(path).ok()
    }

    #[test]
    fn no_op_when_target_missing() {
        let dir = TempDir::new().unwrap();
        let target = dir.path().join("state.ron");
        rotate_backups(&target);
        assert!(read(&backup_path(&target, 0)).is_none());
    }

    #[test]
    fn first_rotation_moves_target_to_bak_0() {
        let dir = TempDir::new().unwrap();
        let target = dir.path().join("state.ron");
        write(&target, "v1");
        rotate_backups(&target);
        assert_eq!(None, read(&target));
        assert_eq!(Some("v1".to_string()), read(&backup_path(&target, 0)));
    }

    #[test]
    fn second_rotation_pushes_bak_0_to_bak_1() {
        let dir = TempDir::new().unwrap();
        let target = dir.path().join("state.ron");
        write(&target, "v1");
        rotate_backups(&target);
        write(&target, "v2");
        rotate_backups(&target);
        assert_eq!(None, read(&target));
        assert_eq!(Some("v2".to_string()), read(&backup_path(&target, 0)));
        assert_eq!(Some("v1".to_string()), read(&backup_path(&target, 1)));
    }

    #[test]
    fn oldest_rotation_is_dropped_at_n_backups() {
        let dir = TempDir::new().unwrap();
        let target = dir.path().join("state.ron");
        for i in 1..=(N_BACKUPS + 2) {
            write(&target, &format!("v{i}"));
            rotate_backups(&target);
        }
        // After N_BACKUPS+2 rotations, only the most recent N_BACKUPS survive.
        let total = N_BACKUPS + 2;
        for slot in 0..N_BACKUPS {
            let expected = format!("v{}", total - slot);
            assert_eq!(Some(expected), read(&backup_path(&target, slot)));
        }
        // What used to be in slot N_BACKUPS-1 is gone.
        assert_eq!(None, read(&backup_path(&target, N_BACKUPS)));
    }

    #[test]
    fn missing_intermediate_slot_is_tolerated() {
        let dir = TempDir::new().unwrap();
        let target = dir.path().join("state.ron");
        write(&target, "v1");
        rotate_backups(&target); // .bak.0=v1
        // Manually delete .bak.0 to simulate a corrupt or missing intermediate.
        fs::remove_file(backup_path(&target, 0)).unwrap();
        write(&target, "v2");
        rotate_backups(&target); // should still place v2 in .bak.0
        assert_eq!(Some("v2".to_string()), read(&backup_path(&target, 0)));
    }
}