1use std::fs;
4use std::io;
5use std::path::PathBuf;
6
7#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
10pub struct HistoryEntry {
11 pub text: String,
12 #[serde(default, skip_serializing_if = "Vec::is_empty")]
16 pub images: Vec<HistoryImageRef>,
17}
18
19#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
23pub struct HistoryImageRef {
24 pub hash: String,
28 pub mt: String,
31 pub n: usize,
36}
37
38pub const HISTORY_MAX: usize = 1000;
39
40pub struct History {
41 path: PathBuf,
42 entries: Vec<HistoryEntry>,
43 cache_dir: PathBuf,
44}
45
46impl History {
47 pub fn load_with_cache<P: Into<PathBuf>>(path: P, cache_dir: PathBuf) -> Self {
52 let path = path.into();
53 let entries: Vec<HistoryEntry> = fs::read_to_string(&path)
59 .ok()
60 .map(|s| {
61 s.lines()
62 .filter(|l| !l.trim().is_empty())
63 .map(|l| {
64 if let Ok(e) = serde_json::from_str::<HistoryEntry>(l) {
65 return e;
66 }
67 if let Ok(t) = serde_json::from_str::<String>(l) {
68 return HistoryEntry { text: t, images: Vec::new() };
69 }
70 HistoryEntry { text: l.to_string(), images: Vec::new() }
71 })
72 .collect()
73 })
74 .unwrap_or_default();
75 Self { path, entries, cache_dir }
76 }
77
78 pub fn load<P: Into<PathBuf>>(path: P) -> Self {
83 let path = path.into();
84 let cache_dir = path
85 .parent()
86 .map(|p| p.join("image-cache"))
87 .unwrap_or_else(|| PathBuf::from("."));
88 Self::load_with_cache(path, cache_dir)
89 }
90
91 pub fn default_path() -> Option<PathBuf> {
95 Some(crate::platform::history_path())
96 }
97
98 pub fn entries(&self) -> &Vec<HistoryEntry> {
99 &self.entries
100 }
101
102 pub fn push(&mut self, entry: HistoryEntry) {
103 if entry.text.trim().is_empty() {
104 return;
105 }
106 if self.entries.last().map(|e| &e.text) == Some(&entry.text) {
107 return;
108 }
109 self.entries.push(entry);
110 if self.entries.len() > HISTORY_MAX {
111 let drop = self.entries.len() - HISTORY_MAX;
112 self.entries.drain(..drop);
113 }
114 }
115
116 pub fn save(&self) -> io::Result<()> {
117 if let Some(parent) = self.path.parent() {
118 fs::create_dir_all(parent)?;
119 }
120 let contents: String = self
121 .entries
122 .iter()
123 .map(|e| serde_json::to_string(e).unwrap_or_else(|_| {
124 serde_json::to_string(&e.text).unwrap_or_else(|_| e.text.clone())
129 }))
130 .collect::<Vec<_>>()
131 .join("\n");
132 fs::write(&self.path, contents)?;
133 let _ = self.gc(); Ok(())
135 }
136
137 fn gc(&self) -> io::Result<()> {
141 use std::collections::HashSet;
142 let referenced: HashSet<&str> = self
143 .entries
144 .iter()
145 .flat_map(|e| e.images.iter().map(|i| i.hash.as_str()))
146 .collect();
147 let dir = match fs::read_dir(&self.cache_dir) {
148 Ok(d) => d,
149 Err(_) => return Ok(()), };
151 for entry in dir.flatten() {
152 let name = entry.file_name();
153 let name_str = name.to_string_lossy();
154 let prefix = match name_str.split('.').next() {
155 Some(p) if p.len() == 16 && p.chars().all(|c| c.is_ascii_hexdigit()) => p,
156 _ => continue, };
158 if !referenced.contains(prefix) {
159 let _ = fs::remove_file(entry.path()); }
161 }
162 Ok(())
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use tempfile::tempdir;
170
171 #[test]
172 fn load_nonexistent_returns_empty() {
173 let dir = tempdir().unwrap();
174 let h = History::load(dir.path().join("hist"));
175 assert_eq!(h.entries(), &Vec::<HistoryEntry>::new());
176 }
177
178 #[test]
179 fn save_and_load_roundtrip() {
180 let dir = tempdir().unwrap();
181 let path = dir.path().join("hist");
182 let mut h = History::load(&path);
183 h.push(HistoryEntry { text: "one".into(), images: Vec::new() });
184 h.push(HistoryEntry { text: "two".into(), images: Vec::new() });
185 h.save().unwrap();
186
187 let h2 = History::load(&path);
188 assert_eq!(h2.entries().len(), 2);
189 assert_eq!(h2.entries()[0].text, "one");
190 assert_eq!(h2.entries()[1].text, "two");
191 }
192
193 #[test]
194 fn multi_line_entry_survives_roundtrip() {
195 let dir = tempdir().unwrap();
196 let path = dir.path().join("hist");
197 let mut h = History::load(&path);
198 h.push(HistoryEntry { text: "1\n2\n3".into(), images: Vec::new() });
199 h.push(HistoryEntry { text: "next".into(), images: Vec::new() });
200 h.save().unwrap();
201
202 let h2 = History::load(&path);
203 assert_eq!(h2.entries().len(), 2);
204 assert_eq!(h2.entries()[0].text, "1\n2\n3");
205 assert_eq!(h2.entries()[1].text, "next");
206 }
207
208 #[test]
209 fn legacy_plaintext_history_still_loads() {
210 let dir = tempdir().unwrap();
214 let path = dir.path().join("hist");
215 fs::write(&path, "hello world\nanother line").unwrap();
216 let h = History::load(&path);
217 assert_eq!(h.entries().len(), 2);
218 assert_eq!(h.entries()[0].text, "hello world");
219 assert!(h.entries()[0].images.is_empty());
220 assert_eq!(h.entries()[1].text, "another line");
221 }
222
223 #[test]
224 fn duplicate_consecutive_collapsed() {
225 let dir = tempdir().unwrap();
226 let mut h = History::load(dir.path().join("hist"));
227 h.push(HistoryEntry { text: "x".into(), images: Vec::new() });
228 h.push(HistoryEntry { text: "x".into(), images: Vec::new() });
229 h.push(HistoryEntry { text: "y".into(), images: Vec::new() });
230 assert_eq!(h.entries().len(), 2);
231 assert_eq!(h.entries()[0].text, "x");
232 assert_eq!(h.entries()[1].text, "y");
233 }
234
235 #[test]
236 fn capped_at_max_entries() {
237 let dir = tempdir().unwrap();
238 let mut h = History::load(dir.path().join("hist"));
239 for i in 0..2000 {
240 h.push(HistoryEntry { text: format!("cmd{}", i), images: Vec::new() });
241 }
242 assert!(h.entries().len() <= HISTORY_MAX);
243 assert!(!h.entries().iter().any(|e| e.text == "cmd0"));
244 }
245
246 #[test]
247 fn empty_entries_ignored() {
248 let dir = tempdir().unwrap();
249 let mut h = History::load(dir.path().join("hist"));
250 h.push(HistoryEntry { text: "".into(), images: Vec::new() });
251 h.push(HistoryEntry { text: " ".into(), images: Vec::new() });
252 h.push(HistoryEntry { text: "real".into(), images: Vec::new() });
253 assert_eq!(h.entries().len(), 1);
254 assert_eq!(h.entries()[0].text, "real");
255 }
256
257 #[test]
258 fn history_entry_serde_roundtrip_with_images() {
259 let e = HistoryEntry {
260 text: "look [Image #2]".to_string(),
261 images: vec![HistoryImageRef {
262 hash: "deadbeef12345678".to_string(),
263 mt: "image/png".to_string(),
264 n: 2,
265 }],
266 };
267 let j = serde_json::to_string(&e).unwrap();
268 let back: HistoryEntry = serde_json::from_str(&j).unwrap();
269 assert_eq!(back.text, e.text);
270 assert_eq!(back.images.len(), 1);
271 assert_eq!(back.images[0].hash, "deadbeef12345678");
272 assert_eq!(back.images[0].mt, "image/png");
273 assert_eq!(back.images[0].n, 2);
274 }
275
276 #[test]
277 fn history_entry_text_only_serializes_without_images_field() {
278 let e = HistoryEntry { text: "hi".to_string(), images: vec![] };
279 let j = serde_json::to_string(&e).unwrap();
280 assert!(!j.contains("images"), "empty images vec must be skipped: {}", j);
281 assert_eq!(j, r#"{"text":"hi"}"#);
282 }
283
284 #[test]
285 fn load_legacy_string_lines_become_text_only_entries() {
286 let dir = tempdir().unwrap();
290 let path = dir.path().join("hist");
291 fs::write(&path, "\"hello\"\n\"world\"").unwrap();
292 let h = History::load(&path);
293 assert_eq!(h.entries().len(), 2);
294 assert_eq!(h.entries()[0].text, "hello");
295 assert!(h.entries()[0].images.is_empty());
296 assert_eq!(h.entries()[1].text, "world");
297 }
298
299 #[test]
300 fn load_new_object_lines_carry_images() {
301 let dir = tempdir().unwrap();
302 let path = dir.path().join("hist");
303 fs::write(
304 &path,
305 "{\"text\":\"a\",\"images\":[{\"hash\":\"deadbeef12345678\",\"mt\":\"image/png\",\"n\":1}]}\n{\"text\":\"b\"}",
306 )
307 .unwrap();
308 let h = History::load(&path);
309 assert_eq!(h.entries().len(), 2);
310 assert_eq!(h.entries()[0].text, "a");
311 assert_eq!(h.entries()[0].images.len(), 1);
312 assert_eq!(h.entries()[0].images[0].hash, "deadbeef12345678");
313 assert_eq!(h.entries()[1].text, "b");
314 assert!(h.entries()[1].images.is_empty());
315 }
316
317 #[test]
318 fn gc_removes_orphan_cache_files() {
319 let dir = tempdir().unwrap();
320 let cache = dir.path().join("image-cache");
321 fs::create_dir(&cache).unwrap();
322 fs::write(cache.join("aaaaaaaaaaaaaaaa.png"), b"a").unwrap();
323 fs::write(cache.join("bbbbbbbbbbbbbbbb.png"), b"b").unwrap();
324 fs::write(cache.join("cccccccccccccccc.png"), b"c").unwrap();
325 let mut h = History::load_with_cache(dir.path().join("hist"), cache.clone());
326 h.push(HistoryEntry {
328 text: "x".into(),
329 images: vec![HistoryImageRef {
330 hash: "aaaaaaaaaaaaaaaa".into(),
331 mt: "image/png".into(),
332 n: 1,
333 }],
334 });
335 h.push(HistoryEntry {
336 text: "y".into(),
337 images: vec![HistoryImageRef {
338 hash: "bbbbbbbbbbbbbbbb".into(),
339 mt: "image/png".into(),
340 n: 1,
341 }],
342 });
343 h.save().unwrap();
344 assert!(cache.join("aaaaaaaaaaaaaaaa.png").exists());
345 assert!(cache.join("bbbbbbbbbbbbbbbb.png").exists());
346 assert!(!cache.join("cccccccccccccccc.png").exists(), "orphan should be GC'd");
347 }
348
349 #[test]
350 fn gc_keeps_unparseable_files() {
351 let dir = tempdir().unwrap();
352 let cache = dir.path().join("image-cache");
353 fs::create_dir(&cache).unwrap();
354 fs::write(cache.join("garbage.txt"), b"not a hash").unwrap();
355 fs::write(cache.join("short.png"), b"too short hex prefix").unwrap();
356 let h = History::load_with_cache(dir.path().join("hist"), cache.clone());
357 h.save().unwrap();
358 assert!(cache.join("garbage.txt").exists());
359 assert!(cache.join("short.png").exists());
360 }
361
362 #[test]
363 fn gc_skips_when_cache_dir_missing() {
364 let dir = tempdir().unwrap();
365 let cache = dir.path().join("image-cache"); let mut h = History::load_with_cache(dir.path().join("hist"), cache);
367 h.push(HistoryEntry { text: "x".into(), images: vec![] });
368 h.save().unwrap();
370 }
371}