Skip to main content

atomcode_tuix/input/
history.rs

1// crates/atomcode-tuix/src/input/history.rs
2
3use std::fs;
4use std::io;
5use std::path::PathBuf;
6
7/// One row in the input history file. Replaces the prior plain `String`
8/// representation so we can carry image attachments alongside the text.
9#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
10pub struct HistoryEntry {
11    pub text: String,
12    /// Image attachments associated with this submission. Skipped on
13    /// serialization when empty so plain text-only history rows stay
14    /// compact (`{"text":"hi"}` rather than `{"text":"hi","images":[]}`).
15    #[serde(default, skip_serializing_if = "Vec::is_empty")]
16    pub images: Vec<HistoryImageRef>,
17}
18
19/// Reference to a single image cached on disk under
20/// `~/.atomcode/image-cache/<hash>.<ext>`. Recorded on submit; consumed
21/// on up-arrow recall to rehydrate `pending_images`.
22#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
23pub struct HistoryImageRef {
24    /// u64 content hash, lowercase hex, 16 chars. Same value that's
25    /// pushed into `UiState::pending_image_hashes` at paste time.
26    /// Stored as a string for direct serde without a custom hex codec.
27    pub hash: String,
28    /// MIME type. Drives the cache filename extension via
29    /// `ext_for_mt()`.
30    pub mt: String,
31    /// The `[Image #N]` marker the entry was originally submitted with.
32    /// On hydrate the marker is renumbered to a fresh
33    /// `session_image_count` value to avoid collisions; this field is
34    /// the lookup key for `line.replace("[Image #<n>]", ...)`.
35    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    /// Load history from `path` and configure `cache_dir` for GC + the
48    /// future `image_cache_dir` consumers in the event loop. The
49    /// cache_dir argument is wired through from
50    /// `crate::platform::image_cache_dir()` at startup.
51    pub fn load_with_cache<P: Into<PathBuf>>(path: P, cache_dir: PathBuf) -> Self {
52        let path = path.into();
53        // Each physical line is one entry. Per-line fallback chain so we
54        // never reject a row written by an older build:
55        //   1. parse as `HistoryEntry` (current format, JSON object)
56        //   2. parse as `String` (older JSON-encoded string lines)
57        //   3. treat the line as raw plain text (pre-JSON format)
58        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    /// Back-compat constructor used by tests and any caller that doesn't
79    /// care about the cache. Sets `cache_dir` to a sibling `image-cache`
80    /// dir under the same parent so GC is a no-op when the dir doesn't
81    /// exist.
82    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    /// Default history path: `~/.atomcode/history` on Unix,
92    /// `%USERPROFILE%\.atomcode\history` on Windows (or a tempdir
93    /// fallback if home is unknown).
94    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                // Defensive fallback — HistoryEntry should always serialize
125                // cleanly via serde, but if a future field broke that,
126                // emit a JSON-string of the text so a malformed entry
127                // doesn't poison the rest of the file.
128                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(); // best-effort; never fails the save
134        Ok(())
135    }
136
137    /// Best-effort garbage collection: remove any file in `cache_dir`
138    /// whose 16-char-hex prefix is not referenced by any current
139    /// history entry. Called automatically after each `save()`.
140    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(()), // dir missing — nothing to GC
150        };
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, // unrecognized — leave it alone
157            };
158            if !referenced.contains(prefix) {
159                let _ = fs::remove_file(entry.path()); // best-effort
160            }
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        // Older builds wrote entries verbatim (one line per entry, no
211        // JSON encoding). Those files must still load — the fallback in
212        // `load()` treats unparseable lines as raw entries.
213        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        // Entries written by older builds: each line is a JSON-encoded
287        // string. After upgrade, they must load as HistoryEntry with empty
288        // images.
289        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        // Reference only `aaaa…` and `bbbb…`.
327        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");  // does not exist
366        let mut h = History::load_with_cache(dir.path().join("hist"), cache);
367        h.push(HistoryEntry { text: "x".into(), images: vec![] });
368        // Must not error.
369        h.save().unwrap();
370    }
371}