use std::fs;
use std::io;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct HistoryEntry {
pub text: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub images: Vec<HistoryImageRef>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct HistoryImageRef {
pub hash: String,
pub mt: String,
pub n: usize,
}
pub const HISTORY_MAX: usize = 1000;
pub struct History {
path: PathBuf,
entries: Vec<HistoryEntry>,
cache_dir: PathBuf,
}
impl History {
pub fn load_with_cache<P: Into<PathBuf>>(path: P, cache_dir: PathBuf) -> Self {
let path = path.into();
let entries: Vec<HistoryEntry> = fs::read_to_string(&path)
.ok()
.map(|s| {
s.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| {
if let Ok(e) = serde_json::from_str::<HistoryEntry>(l) {
return e;
}
if let Ok(t) = serde_json::from_str::<String>(l) {
return HistoryEntry { text: t, images: Vec::new() };
}
HistoryEntry { text: l.to_string(), images: Vec::new() }
})
.collect()
})
.unwrap_or_default();
Self { path, entries, cache_dir }
}
pub fn load<P: Into<PathBuf>>(path: P) -> Self {
let path = path.into();
let cache_dir = path
.parent()
.map(|p| p.join("image-cache"))
.unwrap_or_else(|| PathBuf::from("."));
Self::load_with_cache(path, cache_dir)
}
pub fn default_path() -> Option<PathBuf> {
Some(crate::platform::history_path())
}
pub fn entries(&self) -> &Vec<HistoryEntry> {
&self.entries
}
pub fn push(&mut self, entry: HistoryEntry) {
if entry.text.trim().is_empty() {
return;
}
if self.entries.last().map(|e| &e.text) == Some(&entry.text) {
return;
}
self.entries.push(entry);
if self.entries.len() > HISTORY_MAX {
let drop = self.entries.len() - HISTORY_MAX;
self.entries.drain(..drop);
}
}
pub fn save(&self) -> io::Result<()> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent)?;
}
let contents: String = self
.entries
.iter()
.map(|e| serde_json::to_string(e).unwrap_or_else(|_| {
serde_json::to_string(&e.text).unwrap_or_else(|_| e.text.clone())
}))
.collect::<Vec<_>>()
.join("\n");
fs::write(&self.path, contents)?;
let _ = self.gc();
Ok(())
}
fn gc(&self) -> io::Result<()> {
use std::collections::HashSet;
let referenced: HashSet<&str> = self
.entries
.iter()
.flat_map(|e| e.images.iter().map(|i| i.hash.as_str()))
.collect();
let dir = match fs::read_dir(&self.cache_dir) {
Ok(d) => d,
Err(_) => return Ok(()),
};
for entry in dir.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
let prefix = match name_str.split('.').next() {
Some(p) if p.len() == 16 && p.chars().all(|c| c.is_ascii_hexdigit()) => p,
_ => continue,
};
if !referenced.contains(prefix) {
let _ = fs::remove_file(entry.path());
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn load_nonexistent_returns_empty() {
let dir = tempdir().unwrap();
let h = History::load(dir.path().join("hist"));
assert_eq!(h.entries(), &Vec::<HistoryEntry>::new());
}
#[test]
fn save_and_load_roundtrip() {
let dir = tempdir().unwrap();
let path = dir.path().join("hist");
let mut h = History::load(&path);
h.push(HistoryEntry { text: "one".into(), images: Vec::new() });
h.push(HistoryEntry { text: "two".into(), images: Vec::new() });
h.save().unwrap();
let h2 = History::load(&path);
assert_eq!(h2.entries().len(), 2);
assert_eq!(h2.entries()[0].text, "one");
assert_eq!(h2.entries()[1].text, "two");
}
#[test]
fn multi_line_entry_survives_roundtrip() {
let dir = tempdir().unwrap();
let path = dir.path().join("hist");
let mut h = History::load(&path);
h.push(HistoryEntry { text: "1\n2\n3".into(), images: Vec::new() });
h.push(HistoryEntry { text: "next".into(), images: Vec::new() });
h.save().unwrap();
let h2 = History::load(&path);
assert_eq!(h2.entries().len(), 2);
assert_eq!(h2.entries()[0].text, "1\n2\n3");
assert_eq!(h2.entries()[1].text, "next");
}
#[test]
fn legacy_plaintext_history_still_loads() {
let dir = tempdir().unwrap();
let path = dir.path().join("hist");
fs::write(&path, "hello world\nanother line").unwrap();
let h = History::load(&path);
assert_eq!(h.entries().len(), 2);
assert_eq!(h.entries()[0].text, "hello world");
assert!(h.entries()[0].images.is_empty());
assert_eq!(h.entries()[1].text, "another line");
}
#[test]
fn duplicate_consecutive_collapsed() {
let dir = tempdir().unwrap();
let mut h = History::load(dir.path().join("hist"));
h.push(HistoryEntry { text: "x".into(), images: Vec::new() });
h.push(HistoryEntry { text: "x".into(), images: Vec::new() });
h.push(HistoryEntry { text: "y".into(), images: Vec::new() });
assert_eq!(h.entries().len(), 2);
assert_eq!(h.entries()[0].text, "x");
assert_eq!(h.entries()[1].text, "y");
}
#[test]
fn capped_at_max_entries() {
let dir = tempdir().unwrap();
let mut h = History::load(dir.path().join("hist"));
for i in 0..2000 {
h.push(HistoryEntry { text: format!("cmd{}", i), images: Vec::new() });
}
assert!(h.entries().len() <= HISTORY_MAX);
assert!(!h.entries().iter().any(|e| e.text == "cmd0"));
}
#[test]
fn empty_entries_ignored() {
let dir = tempdir().unwrap();
let mut h = History::load(dir.path().join("hist"));
h.push(HistoryEntry { text: "".into(), images: Vec::new() });
h.push(HistoryEntry { text: " ".into(), images: Vec::new() });
h.push(HistoryEntry { text: "real".into(), images: Vec::new() });
assert_eq!(h.entries().len(), 1);
assert_eq!(h.entries()[0].text, "real");
}
#[test]
fn history_entry_serde_roundtrip_with_images() {
let e = HistoryEntry {
text: "look [Image #2]".to_string(),
images: vec![HistoryImageRef {
hash: "deadbeef12345678".to_string(),
mt: "image/png".to_string(),
n: 2,
}],
};
let j = serde_json::to_string(&e).unwrap();
let back: HistoryEntry = serde_json::from_str(&j).unwrap();
assert_eq!(back.text, e.text);
assert_eq!(back.images.len(), 1);
assert_eq!(back.images[0].hash, "deadbeef12345678");
assert_eq!(back.images[0].mt, "image/png");
assert_eq!(back.images[0].n, 2);
}
#[test]
fn history_entry_text_only_serializes_without_images_field() {
let e = HistoryEntry { text: "hi".to_string(), images: vec![] };
let j = serde_json::to_string(&e).unwrap();
assert!(!j.contains("images"), "empty images vec must be skipped: {}", j);
assert_eq!(j, r#"{"text":"hi"}"#);
}
#[test]
fn load_legacy_string_lines_become_text_only_entries() {
let dir = tempdir().unwrap();
let path = dir.path().join("hist");
fs::write(&path, "\"hello\"\n\"world\"").unwrap();
let h = History::load(&path);
assert_eq!(h.entries().len(), 2);
assert_eq!(h.entries()[0].text, "hello");
assert!(h.entries()[0].images.is_empty());
assert_eq!(h.entries()[1].text, "world");
}
#[test]
fn load_new_object_lines_carry_images() {
let dir = tempdir().unwrap();
let path = dir.path().join("hist");
fs::write(
&path,
"{\"text\":\"a\",\"images\":[{\"hash\":\"deadbeef12345678\",\"mt\":\"image/png\",\"n\":1}]}\n{\"text\":\"b\"}",
)
.unwrap();
let h = History::load(&path);
assert_eq!(h.entries().len(), 2);
assert_eq!(h.entries()[0].text, "a");
assert_eq!(h.entries()[0].images.len(), 1);
assert_eq!(h.entries()[0].images[0].hash, "deadbeef12345678");
assert_eq!(h.entries()[1].text, "b");
assert!(h.entries()[1].images.is_empty());
}
#[test]
fn gc_removes_orphan_cache_files() {
let dir = tempdir().unwrap();
let cache = dir.path().join("image-cache");
fs::create_dir(&cache).unwrap();
fs::write(cache.join("aaaaaaaaaaaaaaaa.png"), b"a").unwrap();
fs::write(cache.join("bbbbbbbbbbbbbbbb.png"), b"b").unwrap();
fs::write(cache.join("cccccccccccccccc.png"), b"c").unwrap();
let mut h = History::load_with_cache(dir.path().join("hist"), cache.clone());
h.push(HistoryEntry {
text: "x".into(),
images: vec![HistoryImageRef {
hash: "aaaaaaaaaaaaaaaa".into(),
mt: "image/png".into(),
n: 1,
}],
});
h.push(HistoryEntry {
text: "y".into(),
images: vec![HistoryImageRef {
hash: "bbbbbbbbbbbbbbbb".into(),
mt: "image/png".into(),
n: 1,
}],
});
h.save().unwrap();
assert!(cache.join("aaaaaaaaaaaaaaaa.png").exists());
assert!(cache.join("bbbbbbbbbbbbbbbb.png").exists());
assert!(!cache.join("cccccccccccccccc.png").exists(), "orphan should be GC'd");
}
#[test]
fn gc_keeps_unparseable_files() {
let dir = tempdir().unwrap();
let cache = dir.path().join("image-cache");
fs::create_dir(&cache).unwrap();
fs::write(cache.join("garbage.txt"), b"not a hash").unwrap();
fs::write(cache.join("short.png"), b"too short hex prefix").unwrap();
let h = History::load_with_cache(dir.path().join("hist"), cache.clone());
h.save().unwrap();
assert!(cache.join("garbage.txt").exists());
assert!(cache.join("short.png").exists());
}
#[test]
fn gc_skips_when_cache_dir_missing() {
let dir = tempdir().unwrap();
let cache = dir.path().join("image-cache");
let mut h = History::load_with_cache(dir.path().join("hist"), cache);
h.push(HistoryEntry { text: "x".into(), images: vec![] });
h.save().unwrap();
}
}