use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
const HISTORY_CAP: usize = 50;
pub const MAX_STAGE: u8 = 5;
const CURRENT_VERSION: u32 = 1;
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Review {
pub ts_ms: u64,
pub passed: bool,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
pub struct Sm2State {
pub ease: f64,
pub reps: u32,
pub interval_ms: u64,
pub due_ms: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct CardState {
pub stage: u8,
pub stage_entered_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sm2: Option<Sm2State>,
#[serde(default)]
pub total_reviews: u32,
#[serde(default)]
pub total_passes: u32,
#[serde(default)]
pub streak: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub history: Vec<Review>,
}
impl CardState {
pub fn new(now_ms: u64) -> Self {
Self {
stage: 1,
stage_entered_ms: now_ms,
sm2: None,
total_reviews: 0,
total_passes: 0,
streak: 0,
history: Vec::new(),
}
}
pub fn record_review(&mut self, ts_ms: u64, passed: bool) {
self.total_reviews += 1;
if passed {
self.total_passes += 1;
self.streak += 1;
} else {
self.streak = 0;
}
self.history.push(Review { ts_ms, passed });
if self.history.len() > HISTORY_CAP {
let excess = self.history.len() - HISTORY_CAP;
self.history.drain(..excess);
}
}
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct DeckProgress {
pub mastered_at_ms: u64,
}
#[derive(Serialize, Deserialize)]
struct StoreFile {
#[serde(default = "default_version")]
version: u32,
cards: HashMap<String, CardState>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
decks: HashMap<String, DeckProgress>,
}
pub struct Store {
path: PathBuf,
cards: HashMap<u64, CardState>,
decks: HashMap<String, DeckProgress>,
}
#[derive(Debug, Error)]
pub enum StoreError {
#[error("{path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("{path}: {source}")]
Format {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error(
"{path}: progress file is version {found}, but this build of alix \
understands only up to {supported} — upgrade alix to open it"
)]
TooNew {
path: PathBuf,
found: u32,
supported: u32,
},
}
impl Store {
pub fn open(path: impl AsRef<Path>) -> Result<Self, StoreError> {
let path = path.as_ref().to_path_buf();
if !path.exists() {
return Ok(Self {
path,
cards: HashMap::new(),
decks: HashMap::new(),
});
}
let text = std::fs::read_to_string(&path).map_err(|source| StoreError::Io {
path: path.clone(),
source,
})?;
let file: StoreFile = serde_json::from_str(&text).map_err(|source| StoreError::Format {
path: path.clone(),
source,
})?;
let file = migrate(file, &path)?;
let mut cards = HashMap::with_capacity(file.cards.len());
for (key, state) in file.cards {
let hash = key.parse::<u64>().map_err(|e| StoreError::Format {
path: path.clone(),
source: serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("bad card key {key:?}: {e}"),
)),
})?;
cards.insert(hash, state);
}
Ok(Self {
path,
cards,
decks: file.decks,
})
}
pub fn save(&self) -> Result<(), StoreError> {
let io_err = |source| StoreError::Io {
path: self.path.clone(),
source,
};
if let Some(dir) = self.path.parent() {
std::fs::create_dir_all(dir).map_err(io_err)?;
}
let file = StoreFile {
version: CURRENT_VERSION,
cards: self
.cards
.iter()
.map(|(hash, state)| (hash.to_string(), state.clone()))
.collect(),
decks: self.decks.clone(),
};
let json = serde_json::to_string_pretty(&file).map_err(|source| StoreError::Format {
path: self.path.clone(),
source,
})?;
let tmp = self.path.with_extension("json.tmp");
std::fs::write(&tmp, json).map_err(io_err)?;
std::fs::rename(&tmp, &self.path).map_err(io_err)?;
Ok(())
}
pub fn get(&self, card_id: u64) -> Option<&CardState> {
self.cards.get(&card_id)
}
pub fn last_review_ms(&self) -> Option<u64> {
self.cards
.values()
.filter_map(|state| state.history.last().map(|review| review.ts_ms))
.max()
}
pub fn get_or_insert(&mut self, card_id: u64, now_ms: u64) -> &mut CardState {
self.cards
.entry(card_id)
.or_insert_with(|| CardState::new(now_ms))
}
pub fn remove(&mut self, card_id: u64) -> bool {
self.cards.remove(&card_id).is_some()
}
pub fn deck_mastered(&self, subject: &str) -> bool {
self.decks.contains_key(subject)
}
pub fn set_deck_mastered(&mut self, subject: &str, now_ms: u64) {
self.decks.insert(
subject.to_string(),
DeckProgress {
mastered_at_ms: now_ms,
},
);
}
pub fn clear_deck_mastered(&mut self, subject: &str) -> bool {
self.decks.remove(subject).is_some()
}
pub fn clear(&mut self) -> usize {
let n = self.cards.len();
self.cards.clear();
self.decks.clear();
n
}
pub fn len(&self) -> usize {
self.cards.len()
}
pub fn is_empty(&self) -> bool {
self.cards.is_empty()
}
pub fn path(&self) -> &Path {
&self.path
}
}
fn default_version() -> u32 {
1
}
fn migrate(file: StoreFile, path: &Path) -> Result<StoreFile, StoreError> {
if file.version > CURRENT_VERSION {
return Err(StoreError::TooNew {
path: path.to_path_buf(),
found: file.version,
supported: CURRENT_VERSION,
});
}
Ok(file)
}
pub fn default_store_path() -> Option<PathBuf> {
directories::ProjectDirs::from("", "", "alix").map(|dirs| dirs.data_dir().join("progress.json"))
}
pub fn migrate_legacy_data_dir() {
let old = directories::ProjectDirs::from("", "", "flash").map(|d| d.data_dir().to_path_buf());
let new = directories::ProjectDirs::from("", "", "alix").map(|d| d.data_dir().to_path_buf());
if let (Some(old), Some(new)) = (old, new) {
adopt_legacy_dir(&old, &new);
}
}
fn adopt_legacy_dir(old: &Path, new: &Path) {
if new.exists() || !old.exists() {
return;
}
if let Some(parent) = new.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::rename(old, new);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_creates_empty_store() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
let store = Store::open(&path).unwrap();
assert!(store.is_empty());
}
#[test]
fn adopt_legacy_dir_moves_when_new_is_absent() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("flash");
let new = tmp.path().join("alix");
std::fs::create_dir_all(&old).unwrap();
std::fs::write(old.join("progress.json"), "{}").unwrap();
adopt_legacy_dir(&old, &new);
assert!(
new.join("progress.json").exists(),
"progress should have moved"
);
assert!(
!old.exists(),
"the legacy dir should be gone after the move"
);
}
#[test]
fn adopt_legacy_dir_leaves_an_existing_new_dir_untouched() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("flash");
let new = tmp.path().join("alix");
std::fs::create_dir_all(&old).unwrap();
std::fs::write(old.join("progress.json"), "OLD").unwrap();
std::fs::create_dir_all(&new).unwrap();
std::fs::write(new.join("progress.json"), "NEW").unwrap();
adopt_legacy_dir(&old, &new);
assert_eq!(
"NEW",
std::fs::read_to_string(new.join("progress.json")).unwrap()
);
assert!(old.exists(), "the legacy dir should be left in place");
}
#[test]
fn open_rejects_malformed_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
std::fs::write(&path, "this is not json").unwrap();
assert!(Store::open(&path).is_err());
}
#[test]
fn open_rejects_a_non_numeric_card_key() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
std::fs::write(
&path,
r#"{"version":1,"cards":{"not-a-number":{"stage":1,"stage_entered_ms":0}}}"#,
)
.unwrap();
let err = Store::open(&path).err().unwrap();
assert!(format!("{err}").contains("bad card key"));
}
#[test]
fn last_review_ms_is_the_latest_across_cards() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
let mut store = Store::open(&path).unwrap();
assert_eq!(None, store.last_review_ms());
store.get_or_insert(1, 0).record_review(100, true);
store.get_or_insert(2, 0).record_review(300, true);
assert_eq!(Some(300), store.last_review_ms());
}
#[test]
fn path_returns_the_store_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
let store = Store::open(&path).unwrap();
assert_eq!(path.as_path(), store.path());
}
#[test]
fn save_and_reload_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
let mut store = Store::open(&path).unwrap();
let state = store.get_or_insert(42, 1000);
state.stage = 3;
state.record_review(1000, true);
store.save().unwrap();
let reloaded = Store::open(&path).unwrap();
assert_eq!(1, reloaded.len());
let state = reloaded.get(42).unwrap();
assert_eq!(3, state.stage);
assert_eq!(1, state.total_reviews);
assert_eq!(
vec![Review {
ts_ms: 1000,
passed: true
}],
state.history
);
}
#[test]
fn deck_mastered_roundtrips_and_clears() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
let mut store = Store::open(&path).unwrap();
assert!(!store.deck_mastered("rust.txt"));
store.set_deck_mastered("rust.txt", 1234);
assert!(store.deck_mastered("rust.txt"));
store.save().unwrap();
let mut reloaded = Store::open(&path).unwrap();
assert!(reloaded.deck_mastered("rust.txt"));
assert!(reloaded.clear_deck_mastered("rust.txt"));
assert!(!reloaded.deck_mastered("rust.txt"));
assert!(!reloaded.clear_deck_mastered("rust.txt")); }
#[test]
fn clear_also_drops_deck_mastered() {
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path().join("p.json")).unwrap();
store.set_deck_mastered("a.txt", 1);
store.clear();
assert!(!store.deck_mastered("a.txt"));
}
#[test]
fn loads_store_file_without_decks_field() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
std::fs::write(&path, "{\"version\":1,\"cards\":{}}").unwrap();
let store = Store::open(&path).unwrap();
assert!(store.is_empty());
assert!(!store.deck_mastered("anything.txt"));
}
#[test]
fn loads_a_store_file_without_a_version_field() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
std::fs::write(&path, "{\"cards\":{}}").unwrap();
let store = Store::open(&path).unwrap();
assert!(store.is_empty());
}
#[test]
fn rejects_a_store_from_a_newer_alix_without_touching_it() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
let newer = "{\"version\":999,\"cards\":{}}";
std::fs::write(&path, newer).unwrap();
let err = Store::open(&path).err().unwrap();
assert!(format!("{err}").contains("version 999"));
assert_eq!(newer, std::fs::read_to_string(&path).unwrap());
}
#[test]
fn save_writes_the_current_version() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
let mut store = Store::open(&path).unwrap();
store.get_or_insert(1, 0);
store.save().unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains(&format!("\"version\": {CURRENT_VERSION}")));
}
#[test]
fn remove_drops_the_entry() {
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path().join("p.json")).unwrap();
store.get_or_insert(42, 1000);
assert!(store.remove(42));
assert!(store.get(42).is_none());
assert!(!store.remove(42));
}
#[test]
fn clear_empties_and_counts() {
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path().join("p.json")).unwrap();
store.get_or_insert(1, 0);
store.get_or_insert(2, 0);
assert_eq!(2, store.clear());
assert!(store.is_empty());
assert_eq!(0, store.clear()); }
#[test]
fn history_is_capped() {
let mut state = CardState::new(0);
for i in 0..(HISTORY_CAP as u64 + 10) {
state.record_review(i, true);
}
assert_eq!(HISTORY_CAP, state.history.len());
assert_eq!(10, state.history[0].ts_ms);
assert_eq!(HISTORY_CAP as u32 + 10, state.total_reviews);
}
#[test]
fn streak_resets_on_fail() {
let mut state = CardState::new(0);
state.record_review(1, true);
state.record_review(2, true);
assert_eq!(2, state.streak);
state.record_review(3, false);
assert_eq!(0, state.streak);
assert_eq!(2, state.total_passes);
assert_eq!(3, state.total_reviews);
}
}