use std::fs::{self, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use chrono::{DateTime, Local};
use serde::{Deserialize, Serialize};
use tracing::warn;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryEntry {
pub timestamp: DateTime<Local>,
pub text: String,
pub backend: String,
pub language: String,
#[serde(default)]
pub duration_secs: f64,
}
pub fn history_path() -> PathBuf {
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
.join("whisrs")
.join("history.jsonl")
}
pub fn append_entry(entry: &HistoryEntry) -> anyhow::Result<()> {
append_entry_to(&history_path(), entry)
}
fn append_entry_to(path: &Path, entry: &HistoryEntry) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = OpenOptions::new().create(true).append(true).open(path)?;
let line = serde_json::to_string(entry)?;
writeln!(file, "{line}")?;
Ok(())
}
pub fn read_entries(limit: usize) -> anyhow::Result<Vec<HistoryEntry>> {
read_entries_from(&history_path(), limit)
}
fn read_entries_from(path: &Path, limit: usize) -> anyhow::Result<Vec<HistoryEntry>> {
if !path.exists() {
return Ok(Vec::new());
}
let file = fs::File::open(path)?;
let reader = BufReader::new(file);
let mut entries: Vec<HistoryEntry> = reader
.lines()
.filter_map(|line| {
let line = line.ok()?;
if line.trim().is_empty() {
return None;
}
match serde_json::from_str(&line) {
Ok(entry) => Some(entry),
Err(e) => {
warn!("skipping malformed history entry: {e}");
None
}
}
})
.collect();
entries.reverse();
entries.truncate(limit);
Ok(entries)
}
pub fn clear_history() -> anyhow::Result<()> {
let path = history_path();
if path.exists() {
fs::remove_file(&path)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
static TEST_COUNTER: AtomicU32 = AtomicU32::new(0);
fn temp_history_path() -> PathBuf {
let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir()
.join(format!("whisrs-history-test-{}-{id}", std::process::id()))
.join("history.jsonl")
}
fn make_entry(text: &str) -> HistoryEntry {
HistoryEntry {
timestamp: Local::now(),
text: text.to_string(),
backend: "groq".to_string(),
language: "en".to_string(),
duration_secs: 1.0,
}
}
#[test]
fn append_and_read_entries() {
let path = temp_history_path();
let entry = make_entry("hello world");
append_entry_to(&path, &entry).unwrap();
append_entry_to(&path, &entry).unwrap();
let entries = read_entries_from(&path, 10).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].text, "hello world");
let _ = fs::remove_file(&path);
}
#[test]
fn read_entries_respects_limit() {
let path = temp_history_path();
for i in 0..5 {
append_entry_to(&path, &make_entry(&format!("entry {i}"))).unwrap();
}
let entries = read_entries_from(&path, 3).unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].text, "entry 4");
let _ = fs::remove_file(&path);
}
#[test]
fn read_empty_history() {
let path = temp_history_path();
let entries = read_entries_from(&path, 10).unwrap();
assert!(entries.is_empty());
}
#[test]
fn clear_history_removes_file() {
let path = temp_history_path();
append_entry_to(&path, &make_entry("test")).unwrap();
assert!(path.exists());
fs::remove_file(&path).unwrap();
assert!(!path.exists());
let entries = read_entries_from(&path, 10).unwrap();
assert!(entries.is_empty());
}
}