vultan 1.0.0

Terminal-based, Anki-flavoured spaced-repetition study tool that reads flashcards from a directory of markdown notes.
Documentation
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

use crate::state::file::FileHandle;
use crate::state::tools::IO;

#[derive(Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct Config {
    pub notes_dirpath: Option<PathBuf>,
    /// Anki-style cap on cards reviewed per session. None = unlimited.
    /// When set, the most-overdue cards take priority.
    #[serde(default)]
    pub daily_review_limit: Option<usize>,
    /// If true and the notes directory is a git repo, vultan runs
    /// `git add .vultan.ron && git commit -m "..."` after each study session.
    /// Only the state file is staged — never the user's notes.
    #[serde(default)]
    pub auto_commit_state: bool,
}

impl Config {
    pub fn load() -> Result<Self> {
        Self::load_from(&Self::default_path()?)
    }

    pub fn load_from(path: &Path) -> Result<Self> {
        let handle = FileHandle::from(path.to_path_buf());
        match handle.read() {
            Ok(content) => ron::from_str(&content)
                .with_context(|| format!("Unable to parse config from {}", path.display())),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
            Err(e) => Err(anyhow::Error::from(e))
                .with_context(|| format!("Unable to read config from {}", path.display())),
        }
    }

    pub fn save(&self) -> Result<()> {
        self.save_to(&Self::default_path()?)
    }

    pub fn save_to(&self, path: &Path) -> Result<()> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).with_context(|| {
                format!("Unable to create config directory {}", parent.display())
            })?;
        }
        let content = ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
            .context("Unable to serialise config")?;
        FileHandle::from(path.to_path_buf())
            .write(content)
            .with_context(|| format!("Unable to write config to {}", path.display()))
    }

    pub fn default_path() -> Result<PathBuf> {
        resolve_default_path(
            std::env::var("XDG_CONFIG_HOME").ok(),
            std::env::var("HOME").ok(),
        )
    }
}

fn resolve_default_path(xdg: Option<String>, home: Option<String>) -> Result<PathBuf> {
    let base = xdg
        .filter(|s| !s.is_empty())
        .map(PathBuf::from)
        .or_else(|| home.map(|h| PathBuf::from(h).join(".config")));
    match base {
        Some(b) => Ok(b.join("vultan").join("config.ron")),
        None => anyhow::bail!(
            "Cannot resolve config path: neither $XDG_CONFIG_HOME nor $HOME is set. \
             Set one of these env vars (typically `export HOME=$(pwd)` if you're in \
             a sandboxed shell) before running vultan."
        ),
    }
}

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

    #[test]
    fn config_load_from_returns_default_when_file_missing() {
        let temp_dir = TempDir::new().unwrap();
        let path = temp_dir.path().join("missing.ron");
        let config = Config::load_from(&path).unwrap();
        assert_eq!(Config::default(), config);
    }

    #[test]
    fn config_save_then_load_roundtrip() {
        let temp_dir = TempDir::new().unwrap();
        let path = temp_dir.path().join("nested/config.ron");
        let original = Config {
            notes_dirpath: Some(PathBuf::from("/some/notes")),
            daily_review_limit: None,
            auto_commit_state: false,
        };
        original.save_to(&path).unwrap();
        let loaded = Config::load_from(&path).unwrap();
        assert_eq!(original, loaded);
    }

    #[test]
    fn config_save_to_creates_missing_parent_directories() {
        let temp_dir = TempDir::new().unwrap();
        let path = temp_dir.path().join("a/b/c/config.ron");
        Config::default().save_to(&path).unwrap();
        assert!(path.exists());
    }

    #[test]
    fn config_load_from_returns_err_on_malformed_file() {
        let temp_dir = TempDir::new().unwrap();
        let path = temp_dir.path().join("bad.ron");
        std::fs::write(&path, "not a ron file").unwrap();
        let result = Config::load_from(&path);
        assert!(result.is_err());
        assert!(format!("{:#?}", result.unwrap_err()).contains("Unable to parse config"));
    }

    #[test]
    fn resolve_default_path_honors_xdg_config_home() {
        assert_eq!(
            PathBuf::from("/custom/config/vultan/config.ron"),
            resolve_default_path(
                Some("/custom/config".to_string()),
                Some("/home/u".to_string())
            )
            .unwrap()
        );
    }

    #[test]
    fn resolve_default_path_falls_back_to_home_dot_config_when_xdg_missing() {
        assert_eq!(
            PathBuf::from("/home/u/.config/vultan/config.ron"),
            resolve_default_path(None, Some("/home/u".to_string())).unwrap()
        );
    }

    #[test]
    fn resolve_default_path_treats_empty_xdg_as_unset() {
        assert_eq!(
            PathBuf::from("/home/u/.config/vultan/config.ron"),
            resolve_default_path(Some("".to_string()), Some("/home/u".to_string())).unwrap()
        );
    }

    #[test]
    fn resolve_default_path_errors_when_both_env_vars_are_unset() {
        let result = resolve_default_path(None, None);
        assert!(result.is_err());
        let msg = format!("{:#}", result.unwrap_err());
        assert!(msg.contains("XDG_CONFIG_HOME") && msg.contains("HOME"));
    }

    #[test]
    fn resolve_default_path_errors_when_xdg_empty_and_home_unset() {
        let result = resolve_default_path(Some(String::new()), None);
        assert!(result.is_err());
    }
}