use std::io::Write;
use std::path::{Path, PathBuf};
use crate::config::HistoryCfg;
pub struct History {
path: Option<PathBuf>,
last: Option<String>,
}
impl History {
pub fn load(cfg: &HistoryCfg, data_dir: &Path) -> (Self, Vec<String>) {
if !cfg.is_enabled() {
return (
History {
path: None,
last: None,
},
Vec::new(),
);
}
let path = cfg.resolved_path(data_dir);
let entries = read_and_bound(&path, cfg.max_entries(), cfg.max_bytes());
let last = entries.last().cloned();
(
History {
path: Some(path),
last,
},
entries,
)
}
pub fn append(&mut self, text: &str) {
let Some(path) = self.path.clone() else {
return;
};
if text.trim().is_empty() || self.last.as_deref() == Some(text) {
return;
}
let Ok(line) = serde_json::to_string(text) else {
return;
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let ok = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.and_then(|mut f| writeln!(f, "{line}"))
.is_ok();
if ok {
self.last = Some(text.to_string());
}
}
}
fn read_and_bound(path: &Path, max_entries: usize, max_bytes: u64) -> Vec<String> {
let Ok(text) = std::fs::read_to_string(path) else {
return Vec::new();
};
let raw_lines = text.lines().filter(|l| !l.trim().is_empty()).count();
let parsed: Vec<String> = text
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str::<String>(l).ok())
.collect();
let bounded = bound(&parsed, max_entries, max_bytes);
if bounded.len() != raw_lines {
let _ = atomic_rewrite(path, &bounded);
}
bounded
}
fn bound(entries: &[String], max_entries: usize, max_bytes: u64) -> Vec<String> {
let mut kept: std::collections::VecDeque<String> = std::collections::VecDeque::new();
let mut bytes: u64 = 0;
for e in entries.iter().rev() {
if kept.len() >= max_entries {
break;
}
let cost = serde_json::to_string(e)
.map(|s| s.len() as u64 + 1)
.unwrap_or(0);
if !kept.is_empty() && bytes + cost > max_bytes {
break;
}
bytes += cost;
kept.push_front(e.clone());
}
kept.into()
}
fn atomic_rewrite(path: &Path, entries: &[String]) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut body = String::new();
for e in entries {
body.push_str(&serde_json::to_string(e).unwrap_or_else(|_| "\"\"".into()));
body.push('\n');
}
let tmp = path.with_file_name(format!(
"{}.tmp.{}",
path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("history"),
std::process::id()
));
std::fs::write(&tmp, body)?;
std::fs::rename(&tmp, path)
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(dir: &Path, max_entries: usize, max_bytes: u64) -> HistoryCfg {
HistoryCfg {
enabled: Some(true),
path: Some(dir.join("history.jsonl").to_string_lossy().into_owned()),
max_entries: Some(max_entries),
max_bytes: Some(max_bytes),
}
}
fn seed(path: &Path, entries: &[&str]) {
let mut body = String::new();
for e in entries {
body.push_str(&serde_json::to_string(e).unwrap());
body.push('\n');
}
std::fs::write(path, body).unwrap();
}
#[test]
fn jsonl_round_trips_including_multiline_prompts() {
let dir = tempfile::tempdir().unwrap();
let data = dir.path();
let (mut h, loaded) = History::load(&cfg(data, 100, 1 << 20), data);
assert!(loaded.is_empty());
h.append("write a test for foo");
h.append("line one\nline two");
let (_h, reloaded) = History::load(&cfg(data, 100, 1 << 20), data);
assert_eq!(reloaded, vec!["write a test for foo", "line one\nline two"]);
}
#[test]
fn disabled_reads_and_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let data = dir.path();
seed(&data.join("history.jsonl"), &["old one", "old two"]);
let disabled = HistoryCfg {
enabled: Some(false),
path: Some(data.join("history.jsonl").to_string_lossy().into_owned()),
..Default::default()
};
let (mut h, loaded) = History::load(&disabled, data);
assert!(loaded.is_empty(), "disabled load reads nothing");
h.append("should not be written");
let raw = std::fs::read_to_string(data.join("history.jsonl")).unwrap();
assert!(!raw.contains("should not be written"));
assert!(raw.contains("old two"));
}
#[test]
fn startup_trims_to_max_entries_and_rewrites() {
let dir = tempfile::tempdir().unwrap();
let data = dir.path();
let path = data.join("history.jsonl");
seed(&path, &["a", "b", "c", "d", "e"]);
let (_h, loaded) = History::load(&cfg(data, 3, 1 << 20), data);
assert_eq!(loaded, vec!["c", "d", "e"], "newest 3 kept");
let raw = std::fs::read_to_string(&path).unwrap();
assert_eq!(raw.lines().count(), 3);
assert!(!raw.contains("\"a\"") && raw.contains("\"e\""));
}
#[test]
fn startup_trims_to_max_bytes_independently() {
let dir = tempfile::tempdir().unwrap();
let data = dir.path();
let path = data.join("history.jsonl");
seed(&path, &["xxxx", "yyyy", "zzzz"]);
let (_h, loaded) = History::load(&cfg(data, 1000, 16), data);
assert_eq!(loaded, vec!["yyyy", "zzzz"], "entry count high, bytes bind");
assert_eq!(std::fs::read_to_string(&path).unwrap().lines().count(), 2);
}
#[test]
fn newest_entry_survives_even_if_it_alone_exceeds_the_byte_cap() {
let dir = tempfile::tempdir().unwrap();
let data = dir.path();
let path = data.join("history.jsonl");
seed(
&path,
&["small", "a very long final entry that exceeds the cap"],
);
let (_h, loaded) = History::load(&cfg(data, 1000, 4), data);
assert_eq!(loaded, vec!["a very long final entry that exceeds the cap"]);
}
#[test]
fn append_suppresses_consecutive_duplicates() {
let dir = tempfile::tempdir().unwrap();
let data = dir.path();
let (mut h, _) = History::load(&cfg(data, 100, 1 << 20), data);
h.append("same");
h.append("same");
h.append("other");
h.append("same");
let (_h, reloaded) = History::load(&cfg(data, 100, 1 << 20), data);
assert_eq!(reloaded, vec!["same", "other", "same"]);
}
#[test]
fn no_rewrite_when_nothing_is_dropped() {
let dir = tempfile::tempdir().unwrap();
let data = dir.path();
let path = data.join("history.jsonl");
seed(&path, &["a", "b"]);
let before = std::fs::read_to_string(&path).unwrap();
let (_h, loaded) = History::load(&cfg(data, 100, 1 << 20), data);
assert_eq!(loaded, vec!["a", "b"]);
assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
}
#[test]
fn malformed_lines_are_skipped_and_cleaned_up() {
let dir = tempfile::tempdir().unwrap();
let data = dir.path();
let path = data.join("history.jsonl");
std::fs::write(&path, "\"good\"\nnot json\n\"also good\"\n").unwrap();
let (_h, loaded) = History::load(&cfg(data, 100, 1 << 20), data);
assert_eq!(loaded, vec!["good", "also good"]);
assert_eq!(std::fs::read_to_string(&path).unwrap().lines().count(), 2);
}
}