vultan 1.0.1

Terminal-based, Anki-compatible spaced-repetition study tool that reads flashcards from a directory of markdown notes.
Documentation
use chrono::{Duration, Utc};

use super::card::Card;
use super::deck::Deck;
use super::State;

pub const MASTERED_FACTOR_THRESHOLD: f64 = 2500.0;
pub const MASTERED_INTERVAL_THRESHOLD_DAYS: f64 = 21.0;
pub const FORGOTTEN_FACTOR_THRESHOLD: f64 = 1500.0;

pub const ALL_DECKS_LABEL: &str = "all";

#[derive(Debug, Clone, PartialEq)]
pub struct DeckStats {
    pub name: String,
    pub due: usize,
    pub due_within_a_week: usize,
    pub mastered: usize,
    pub forgotten: usize,
    pub total: usize,
}

impl DeckStats {
    pub(crate) fn empty(name: &str) -> Self {
        Self {
            name: name.to_string(),
            due: 0,
            due_within_a_week: 0,
            mastered: 0,
            forgotten: 0,
            total: 0,
        }
    }

    fn count(&mut self, card: &Card, thresholds: &Thresholds) {
        if card.is_due() {
            self.due += 1;
        } else if is_due_within_a_week(card) {
            self.due_within_a_week += 1;
        }
        if thresholds.is_mastered(card) {
            self.mastered += 1;
        }
        if thresholds.is_forgotten(card) {
            self.forgotten += 1;
        }
    }
}

#[derive(Debug, Clone, PartialEq)]
pub struct Thresholds {
    pub factor_mastered: f64,
    pub interval_mastered: f64,
    pub factor_forgotten: f64,
}

impl Thresholds {
    pub fn defaults() -> Self {
        Self {
            factor_mastered: MASTERED_FACTOR_THRESHOLD,
            interval_mastered: MASTERED_INTERVAL_THRESHOLD_DAYS,
            factor_forgotten: FORGOTTEN_FACTOR_THRESHOLD,
        }
    }

    pub fn for_deck(deck: &Deck) -> Self {
        let d = Self::defaults();
        Self {
            factor_mastered: deck.factor_mastered_threshold.unwrap_or(d.factor_mastered),
            interval_mastered: deck
                .interval_mastered_threshold
                .unwrap_or(d.interval_mastered),
            factor_forgotten: deck.factor_forgotten_threshold.unwrap_or(d.factor_forgotten),
        }
    }

    fn is_mastered(&self, card: &Card) -> bool {
        card.revision_settings.memorisation_factor >= self.factor_mastered
            && card.revision_settings.interval >= self.interval_mastered
    }

    fn is_forgotten(&self, card: &Card) -> bool {
        // A card with interval 0 has never been reviewed — its factor is at the
        // default floor only by virtue of never having been touched. Require
        // some review history before we call it forgotten.
        card.revision_settings.interval > 0.0
            && card.revision_settings.memorisation_factor <= self.factor_forgotten
    }
}

fn is_due_within_a_week(card: &Card) -> bool {
    let due = card.revision_settings.due;
    let now = Utc::now();
    due > now && due <= now + Duration::days(7)
}

impl State {
    /// One stats row per deck, sorted alphabetically by name.
    pub fn deck_stats(&self) -> Vec<DeckStats> {
        let mut rows: Vec<DeckStats> = self
            .decks
            .values()
            .map(|deck| {
                let thresholds = Thresholds::for_deck(deck);
                let mut stats = DeckStats::empty(&deck.name);
                stats.total = deck.card_paths.len();
                for path in &deck.card_paths {
                    if let Some(card) = self.cards.get(path) {
                        stats.count(card, &thresholds);
                    }
                }
                stats
            })
            .collect();
        rows.sort_by(|a, b| a.name.cmp(&b.name));
        rows
    }

    /// Aggregate stats across all loaded cards (each card counted at most once).
    /// Uses default thresholds — per-deck overrides only apply to per-deck rows.
    pub fn all_due_stats(&self) -> DeckStats {
        let thresholds = Thresholds::defaults();
        let mut stats = DeckStats::empty(ALL_DECKS_LABEL);
        stats.total = self.cards.len();
        for card in self.cards.values() {
            stats.count(card, &thresholds);
        }
        stats
    }
}

#[cfg(test)]
mod unit_tests {
    use super::*;
    use crate::state::card::revision_settings::RevisionSettings;
    use crate::state::deck;
    use chrono::DateTime;

    fn make_card(
        path: &str,
        decks: &[&str],
        due: DateTime<Utc>,
        factor: f64,
        interval: f64,
    ) -> Card {
        Card::new(
            path.to_string(),
            decks.iter().map(|s| s.to_string()).collect(),
            "q".to_string(),
            "a".to_string(),
            RevisionSettings::new(due, interval, factor),
        )
    }

    fn make_state(cards: Vec<Card>) -> State {
        let decks = deck::many_from_cards(&cards);
        State::new(
            crate::state::card::parser::ParsingConfig::default(),
            cards,
            decks,
        )
    }

    fn default_factor() -> f64 {
        1300.0
    }

    #[test]
    fn deck_stats_returns_one_row_per_deck_sorted_by_name() {
        let now = Utc::now();
        let state = make_state(vec![
            make_card("c.md", &["c"], now, 0.0, default_factor()),
            make_card("a.md", &["a"], now, 0.0, default_factor()),
            make_card("b.md", &["b"], now, 0.0, default_factor()),
        ]);
        let rows = state.deck_stats();
        assert_eq!(3, rows.len());
        assert_eq!(
            vec!["a", "b", "c"],
            rows.iter().map(|r| r.name.clone()).collect::<Vec<_>>()
        );
    }

    #[test]
    fn deck_stats_counts_currently_due_cards() {
        let past = Utc::now() - Duration::days(1);
        let future = Utc::now() + Duration::days(30);
        let state = make_state(vec![
            make_card("a.md", &["x"], past, 0.0, 1300.0),
            make_card("b.md", &["x"], past, 0.0, 1300.0),
            make_card("c.md", &["x"], future, 0.0, 1300.0),
        ]);
        let rows = state.deck_stats();
        let x = rows.iter().find(|r| r.name == "x").unwrap();
        assert_eq!(2, x.due);
        assert_eq!(3, x.total);
    }

    #[test]
    fn deck_stats_counts_due_within_a_week_excluding_currently_due() {
        let now = Utc::now();
        let state = make_state(vec![
            make_card("past.md", &["x"], now - Duration::days(1), 0.0, 1300.0),
            make_card("plus_one.md", &["x"], now + Duration::days(1), 0.0, 1300.0),
            make_card("plus_five.md", &["x"], now + Duration::days(5), 0.0, 1300.0),
            make_card(
                "plus_eight.md",
                &["x"],
                now + Duration::days(8),
                0.0,
                1300.0,
            ),
        ]);
        let rows = state.deck_stats();
        let x = rows.iter().find(|r| r.name == "x").unwrap();
        assert_eq!(1, x.due, "only the past-due card counts as currently due");
        assert_eq!(
            2, x.due_within_a_week,
            "plus_one and plus_five are within a week"
        );
    }

    #[test]
    fn deck_stats_classifies_mastered_cards() {
        let future = Utc::now() + Duration::days(30);
        let state = make_state(vec![
            make_card("mastered.md", &["x"], future, 3000.0, 30.0),
            make_card("not_yet.md", &["x"], future, 2400.0, 30.0),
            make_card("short_interval.md", &["x"], future, 3000.0, 14.0),
        ]);
        let rows = state.deck_stats();
        let x = rows.iter().find(|r| r.name == "x").unwrap();
        assert_eq!(
            1, x.mastered,
            "only the high-factor + long-interval card is mastered"
        );
    }

    #[test]
    fn deck_stats_classifies_forgotten_cards() {
        let future = Utc::now() + Duration::days(30);
        let state = make_state(vec![
            // never-reviewed card: factor at floor but interval 0 — not forgotten.
            make_card("untouched.md", &["x"], future, 1300.0, 0.0),
            // reviewed card stuck at the floor — counts as forgotten.
            make_card("struggling.md", &["x"], future, 1300.0, 0.5),
            // doing well — not forgotten.
            make_card("doing_well.md", &["x"], future, 2000.0, 5.0),
        ]);
        let rows = state.deck_stats();
        let x = rows.iter().find(|r| r.name == "x").unwrap();
        assert_eq!(
            1, x.forgotten,
            "only the reviewed-but-still-at-floor card classifies as forgotten"
        );
    }

    #[test]
    fn deck_stats_honors_per_deck_factor_mastered_override() {
        let future = Utc::now() + Duration::days(30);
        // Card has factor 2400 — below the default 2500 threshold but above
        // a custom 2000 threshold for this deck.
        let card = make_card("c.md", &["x"], future, 2400.0, 30.0);
        let mut state = make_state(vec![card]);
        // Mutate the deck to set a relaxed factor threshold.
        if let Some(deck) = state.decks.get_mut("x") {
            deck.factor_mastered_threshold = Some(2000.0);
        }

        let rows = state.deck_stats();
        let x = rows.iter().find(|r| r.name == "x").unwrap();
        assert_eq!(1, x.mastered, "card crosses the per-deck override");
    }

    #[test]
    fn all_due_stats_dedupes_cards_in_multiple_decks() {
        let past = Utc::now() - Duration::days(1);
        let state = make_state(vec![
            make_card("shared.md", &["a", "b"], past, 0.0, 1300.0),
            make_card("only_a.md", &["a"], past, 0.0, 1300.0),
        ]);
        let all = state.all_due_stats();
        assert_eq!(2, all.total, "shared card counted once, not twice");
        assert_eq!(2, all.due);
    }
}