ktype 0.1.1

A terminal-native typing test inspired by Monkeytype — fast, minimal, and offline-first.
use std::path::{Path, PathBuf};

use crate::stats::SessionResult;

#[derive(Debug, thiserror::Error)]
pub enum PersistError {
    #[error("io: {0}")]
    Io(#[from] std::io::Error),
    #[error("json: {0}")]
    Json(#[from] serde_json::Error),
}

fn stats_path() -> PathBuf {
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
    PathBuf::from(home)
        .join(".config")
        .join("ktype")
        .join("stats.json")
}

pub fn load() -> Result<Vec<SessionResult>, PersistError> {
    load_from(&stats_path())
}

pub fn append(result: &SessionResult) -> Result<(), PersistError> {
    append_to(&stats_path(), result)
}

pub(crate) fn load_from(path: &Path) -> Result<Vec<SessionResult>, PersistError> {
    if !path.exists() {
        return Ok(Vec::new());
    }
    let data = std::fs::read_to_string(path)?;
    Ok(serde_json::from_str(&data)?)
}

pub(crate) fn append_to(path: &Path, result: &SessionResult) -> Result<(), PersistError> {
    let mut entries = if path.exists() {
        let data = std::fs::read_to_string(path)?;
        serde_json::from_str::<Vec<SessionResult>>(&data)?
    } else {
        Vec::new()
    };
    entries.push(result.clone());
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::write(path, serde_json::to_string_pretty(&entries)?)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use proptest::prelude::*;
    use tempfile::TempDir;

    fn sample_result() -> SessionResult {
        SessionResult {
            timestamp: 1_000_000,
            duration_secs: 15,
            wpm: 60.0,
            raw_wpm: 65.0,
            accuracy: 92.0,
        }
    }

    fn arb_result() -> impl Strategy<Value = SessionResult> {
        (
            any::<i64>(),
            any::<u64>(),
            0.0f64..200.0f64,
            0.0f64..200.0f64,
            0.0f64..100.0f64,
        )
            .prop_map(
                |(timestamp, duration_secs, wpm, raw_wpm, accuracy)| SessionResult {
                    timestamp,
                    duration_secs,
                    wpm,
                    raw_wpm,
                    accuracy,
                },
            )
    }

    #[test]
    fn load_missing_file_returns_empty_vec() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("stats.json");
        let result = load_from(&path).unwrap();
        assert!(result.is_empty());
    }

    #[test]
    fn append_creates_file_and_loads_back() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("stats.json");
        let r = sample_result();
        append_to(&path, &r).unwrap();
        let loaded = load_from(&path).unwrap();
        assert_eq!(loaded.len(), 1);
        assert_eq!(loaded[0].timestamp, r.timestamp);
        assert!((loaded[0].wpm - r.wpm).abs() < 0.01);
    }

    #[test]
    fn append_accumulates_multiple_results() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("stats.json");
        append_to(&path, &sample_result()).unwrap();
        append_to(&path, &sample_result()).unwrap();
        let loaded = load_from(&path).unwrap();
        assert_eq!(loaded.len(), 2);
    }

    proptest! {
        #[test]
        fn append_n_load_n_round_trip(results in prop::collection::vec(arb_result(), 1..=20)) {
            let dir = TempDir::new().unwrap();
            let path = dir.path().join("stats.json");

            for result in &results {
                append_to(&path, result).unwrap();
            }

            let loaded = load_from(&path).unwrap();
            prop_assert_eq!(loaded.len(), results.len());

            for (orig, loaded_entry) in results.iter().zip(loaded.iter()) {
                prop_assert_eq!(orig.timestamp, loaded_entry.timestamp);
                prop_assert_eq!(orig.duration_secs, loaded_entry.duration_secs);
                prop_assert!((orig.wpm - loaded_entry.wpm).abs() < 1e-9);
                prop_assert!((orig.raw_wpm - loaded_entry.raw_wpm).abs() < 1e-9);
                prop_assert!((orig.accuracy - loaded_entry.accuracy).abs() < 1e-9);
            }
        }
    }
}