Skip to main content

sessionwiki/
util.rs

1use anyhow::{bail, Context, Result};
2use chrono::{DateTime, Utc};
3use std::path::Path;
4
5/// The largest session file we will read into memory. Session transcripts are
6/// text; a file past this is malformed or hostile, so it is skipped rather than
7/// allowed to exhaust memory.
8pub const MAX_SESSION_FILE_BYTES: u64 = 256 * 1024 * 1024;
9
10/// Bytes read from the head/tail of an over-cap session for its window.
11const WINDOW_HEAD_BYTES: u64 = 256 * 1024;
12const WINDOW_TAIL_BYTES: u64 = 512 * 1024;
13
14/// The JSONL lines to parse for a session, and whether it was WINDOWED. Under
15/// the cap: every line. Over the cap: only the first 256 KB and last 512 KB of
16/// lines (the partial line at each cut boundary is dropped), so a huge session
17/// is still indexed - searchable, and its start + recent turns readable via
18/// `session_window`/`show` - instead of being dropped entirely. Byte-bounded, so
19/// even a 256 MB+ file is read cheaply (no full scan).
20pub fn session_lines(path: &Path) -> Result<(Vec<String>, bool)> {
21    session_lines_windowed(
22        path,
23        MAX_SESSION_FILE_BYTES,
24        WINDOW_HEAD_BYTES,
25        WINDOW_TAIL_BYTES,
26    )
27}
28
29fn session_lines_windowed(
30    path: &Path,
31    cap: u64,
32    head_bytes: u64,
33    tail_bytes: u64,
34) -> Result<(Vec<String>, bool)> {
35    use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
36    let mut f = std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
37    let len = f.metadata().map(|m| m.len()).unwrap_or(0);
38    if len <= cap {
39        let lines = BufReader::new(f)
40            .lines()
41            .map_while(std::result::Result::ok)
42            .collect();
43        return Ok((lines, false));
44    }
45    let head_n = head_bytes.min(len) as usize;
46    let mut head_buf = vec![0u8; head_n];
47    f.read_exact(&mut head_buf)?;
48    let tail_n = tail_bytes.min(len) as usize;
49    let mut tail_buf = vec![0u8; tail_n];
50    f.seek(SeekFrom::End(-(tail_n as i64)))?;
51    f.read_exact(&mut tail_buf)?;
52
53    let head_s = String::from_utf8_lossy(&head_buf);
54    let tail_s = String::from_utf8_lossy(&tail_buf);
55    let mut lines: Vec<String> = Vec::new();
56    // Head: drop the last line (cut mid-line at the byte boundary).
57    let mut hl: Vec<&str> = head_s.lines().collect();
58    if hl.len() > 1 {
59        hl.pop();
60    }
61    lines.extend(hl.iter().map(|s| s.to_string()));
62    // Tail: drop the first line (cut mid-line at the seek boundary).
63    let mut tl: Vec<&str> = tail_s.lines().collect();
64    if tl.len() > 1 {
65        tl.remove(0);
66    }
67    lines.extend(tl.iter().map(|s| s.to_string()));
68    Ok((lines, true))
69}
70
71/// The last `max_bytes` of a file as whole lines, newest last.
72///
73/// For a file whose tail is the part that matters and whose size is another
74/// program's business. A partial line at the seek boundary is dropped, so every
75/// line handed back is one the writer finished.
76pub fn read_tail(path: &Path, max_bytes: u64) -> Result<String> {
77    use std::io::{Read, Seek, SeekFrom};
78    let mut f = std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
79    let len = f.metadata().map(|m| m.len()).unwrap_or(0);
80    if len <= max_bytes {
81        let mut s = String::new();
82        f.read_to_string(&mut s)?;
83        return Ok(s);
84    }
85    let n = max_bytes as usize;
86    let mut buf = vec![0u8; n];
87    f.seek(SeekFrom::End(-(n as i64)))?;
88    f.read_exact(&mut buf)?;
89    let text = String::from_utf8_lossy(&buf).into_owned();
90    // Drop the line the seek cut in half.
91    Ok(match text.find('\n') {
92        Some(i) => text[i + 1..].to_string(),
93        None => String::new(),
94    })
95}
96
97/// Read a file to a string, refusing anything over [`MAX_SESSION_FILE_BYTES`]
98/// so a malicious or corrupt session file can't OOM the process.
99pub fn read_to_string_capped(path: &Path) -> Result<String> {
100    read_capped(path, MAX_SESSION_FILE_BYTES)
101}
102
103/// The same with the cap given, so the refusal itself can be tested without
104/// writing a quarter of a gigabyte to disk.
105fn read_capped(path: &Path, cap: u64) -> Result<String> {
106    let len = std::fs::metadata(path)
107        .with_context(|| format!("stat {}", path.display()))?
108        .len();
109    if len > cap {
110        bail!(
111            "{} is {} - over the {} cap; skipping",
112            path.display(),
113            human_size(len),
114            human_size(cap)
115        );
116    }
117    std::fs::read_to_string(path).with_context(|| format!("open {}", path.display()))
118}
119
120/// Stable short id from a path string. FNV-1a is implemented by hand because
121/// std's DefaultHasher is not guaranteed stable across Rust releases.
122pub fn short_id(s: &str) -> String {
123    let mut hash: u64 = 0xcbf29ce484222325;
124    for b in s.as_bytes() {
125        hash ^= *b as u64;
126        hash = hash.wrapping_mul(0x100000001b3);
127    }
128    format!("{hash:016x}")[..12].to_string()
129}
130
131/// Normalize text to Unicode NFC before it enters or queries the index.
132///
133/// The FTS5 trigram tokenizer windows over raw bytes, so the same grapheme in
134/// two normalization forms never matches: macOS stores Hangul as NFD
135/// (decomposed jamo - "회사" as combining scalars) while a typed query is NFC,
136/// so without this an NFD-stored Korean session is invisible to an NFC search,
137/// and the `trace` suffix match misses the same way. Normalizing both the
138/// indexed text and the query to NFC makes them line up. Pure ASCII is already
139/// NFC, so this is a cheap near-no-op for English; the cost is per-message and
140/// negligible next to parsing and the SQLite write.
141pub fn nfc(s: &str) -> String {
142    use unicode_normalization::UnicodeNormalization;
143    s.nfc().collect()
144}
145
146pub fn human_size(bytes: u64) -> String {
147    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
148    let mut size = bytes as f64;
149    let mut unit = 0;
150    while size >= 1024.0 && unit < UNITS.len() - 1 {
151        size /= 1024.0;
152        unit += 1;
153    }
154    if unit == 0 {
155        format!("{bytes} B")
156    } else {
157        format!("{size:.1} {}", UNITS[unit])
158    }
159}
160
161pub fn fmt_date(ts: Option<DateTime<Utc>>) -> String {
162    match ts {
163        Some(t) => t.format("%Y-%m-%d %H:%M").to_string(),
164        None => "-".into(),
165    }
166}
167
168pub fn rel_time(ts: Option<DateTime<Utc>>) -> String {
169    let Some(t) = ts else { return "-".into() };
170    let secs = (Utc::now() - t).num_seconds().max(0);
171    match secs {
172        0..=59 => "just now".into(),
173        60..=3599 => format!("{}m ago", secs / 60),
174        3600..=86399 => format!("{}h ago", secs / 3600),
175        86400..=2591999 => format!("{}d ago", secs / 86400),
176        _ => t.format("%Y-%m-%d").to_string(),
177    }
178}
179
180pub fn truncate(s: &str, max: usize) -> String {
181    // Session titles (and other indexed strings) are untrusted input: a planted
182    // or prompt-poisoned session can set any title, and this is the choke point
183    // that renders titles to the terminal across list/search/trace/resume/blame.
184    // Strip control characters so a title can't smuggle ANSI/terminal-control
185    // escapes into our output. \n and \t become spaces; every other C0 control,
186    // DEL, and the C1 range is dropped (same posture as clean_snippet).
187    let clean: String = s
188        .chars()
189        .filter_map(|c| match c {
190            '\n' | '\t' => Some(' '),
191            c if (c as u32) < 0x20 || c == '\u{7f}' || ('\u{80}'..='\u{9f}').contains(&c) => None,
192            c => Some(c),
193        })
194        .collect();
195    let clean = clean.trim();
196    if clean.chars().count() <= max {
197        clean.to_string()
198    } else {
199        let cut: String = clean.chars().take(max.saturating_sub(1)).collect();
200        format!("{cut}\u{2026}")
201    }
202}
203
204// Minimal ANSI helpers. Respect NO_COLOR and non-tty stdout.
205pub fn color_enabled() -> bool {
206    use std::io::IsTerminal;
207    std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal()
208}
209
210pub fn paint(code: &str, s: &str) -> String {
211    if color_enabled() {
212        format!("\x1b[{code}m{s}\x1b[0m")
213    } else {
214        s.to_string()
215    }
216}
217
218pub fn bold(s: &str) -> String {
219    paint("1", s)
220}
221pub fn dim(s: &str) -> String {
222    paint("2", s)
223}
224pub fn cyan(s: &str) -> String {
225    paint("36", s)
226}
227pub fn yellow(s: &str) -> String {
228    paint("33", s)
229}
230pub fn green(s: &str) -> String {
231    paint("32", s)
232}
233
234#[cfg(test)]
235mod tests {
236    use super::{session_lines_windowed, truncate};
237
238    #[test]
239    fn truncate_strips_control_characters() {
240        // ESC, DEL, and C1 controls are dropped so an untrusted title cannot
241        // inject terminal escape sequences; the remaining literal text stays.
242        let title = "ok\u{1b}[31mred\u{7f}\u{9b}end";
243        let out = truncate(title, 100);
244        assert!(!out.contains('\u{1b}'), "ESC must be stripped");
245        assert!(!out.contains('\u{7f}'), "DEL must be stripped");
246        assert!(!out.contains('\u{9b}'), "C1 must be stripped");
247        assert_eq!(out, "ok[31mredend");
248    }
249
250    #[test]
251    fn truncate_collapses_whitespace_controls() {
252        assert_eq!(truncate("a\nb\tc", 100), "a b c");
253    }
254
255    #[test]
256    fn truncate_adds_ellipsis_when_too_long() {
257        assert_eq!(truncate("abcdef", 4), "abc\u{2026}");
258    }
259
260    #[test]
261    fn truncate_keeps_unicode_titles() {
262        assert_eq!(truncate("한국어 검색", 100), "한국어 검색");
263    }
264
265    #[test]
266    fn windows_an_over_cap_session_to_head_and_tail() {
267        let dir = tempfile::tempdir().unwrap();
268        let p = dir.path().join("s.jsonl");
269        // 10 lines of "lineNN\n" (7 bytes each = 70 bytes total).
270        let content: String = (0..10).map(|i| format!("line{i:02}\n")).collect();
271        std::fs::write(&p, &content).unwrap();
272        // Under the cap: every line, not windowed.
273        let (all, w) = session_lines_windowed(&p, 10_000, 22, 22).unwrap();
274        assert_eq!(all.len(), 10);
275        assert!(!w);
276        // Over the cap with small budgets: head + tail only, middle dropped.
277        let (win, w2) = session_lines_windowed(&p, 30, 22, 22).unwrap();
278        assert!(w2, "flagged windowed");
279        assert!(win.iter().any(|l| l == "line00"), "head kept: {win:?}");
280        assert!(win.iter().any(|l| l == "line09"), "tail kept: {win:?}");
281        assert!(
282            !win.iter().any(|l| l == "line05"),
283            "middle dropped: {win:?}"
284        );
285    }
286}
287
288#[cfg(test)]
289mod cap_tests {
290    use super::*;
291
292    /// The cap exists so a corrupt or hostile session file cannot exhaust
293    /// memory. Eleven adapters went through it; the prodex one read its task
294    /// JSON and its artifact with a plain `fs::read`, under a comment claiming
295    /// the read was bounded.
296    #[test]
297    fn a_file_over_the_cap_is_refused_by_name_and_size() {
298        let d = tempfile::tempdir().unwrap();
299        let p = d.path().join("huge.json");
300        std::fs::write(&p, b"{}").unwrap();
301        // Cheap: assert the guard reads the SIZE, by capping at zero.
302        let err = read_capped(&p, 0).unwrap_err().to_string();
303        assert!(err.contains("over the"), "should name the cap: {err}");
304        assert!(err.contains("huge.json"), "should name the file: {err}");
305    }
306
307    #[test]
308    fn a_file_under_the_cap_reads_whole() {
309        let d = tempfile::tempdir().unwrap();
310        let p = d.path().join("small.json");
311        std::fs::write(&p, b"{\"id\":\"x\"}").unwrap();
312        assert_eq!(read_capped(&p, 1024).unwrap(), "{\"id\":\"x\"}");
313    }
314
315    #[test]
316    fn read_tail_returns_whole_lines_from_the_end() {
317        let p = std::env::temp_dir().join(format!("sw-tail-{}", std::process::id()));
318        std::fs::write(&p, "first\nsecond\nthird\n").unwrap();
319        // Under the cap: the whole file, byte for byte.
320        assert_eq!(read_tail(&p, 1000).unwrap(), "first\nsecond\nthird\n");
321        // Over it: only whole lines, and never the one the seek cut in half.
322        // 12 bytes from the end is "ond\nthird\n" plus part of "sec".
323        assert_eq!(read_tail(&p, 12).unwrap(), "third\n");
324        // A cap too small to hold even one full line yields nothing, not a
325        // fragment that would parse as a different record.
326        assert_eq!(read_tail(&p, 3).unwrap(), "");
327        let _ = std::fs::remove_file(&p);
328    }
329}