1use anyhow::{bail, Context, Result};
2use chrono::{DateTime, Utc};
3use std::path::Path;
4
5pub const MAX_SESSION_FILE_BYTES: u64 = 256 * 1024 * 1024;
9
10const WINDOW_HEAD_BYTES: u64 = 256 * 1024;
12const WINDOW_TAIL_BYTES: u64 = 512 * 1024;
13
14pub 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 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 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
71pub 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 Ok(match text.find('\n') {
92 Some(i) => text[i + 1..].to_string(),
93 None => String::new(),
94 })
95}
96
97pub fn read_to_string_capped(path: &Path) -> Result<String> {
100 read_capped(path, MAX_SESSION_FILE_BYTES)
101}
102
103fn 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
120pub 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
131pub 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 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
204pub 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 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 let content: String = (0..10).map(|i| format!("line{i:02}\n")).collect();
271 std::fs::write(&p, &content).unwrap();
272 let (all, w) = session_lines_windowed(&p, 10_000, 22, 22).unwrap();
274 assert_eq!(all.len(), 10);
275 assert!(!w);
276 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 #[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 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 assert_eq!(read_tail(&p, 1000).unwrap(), "first\nsecond\nthird\n");
321 assert_eq!(read_tail(&p, 12).unwrap(), "third\n");
324 assert_eq!(read_tail(&p, 3).unwrap(), "");
327 let _ = std::fs::remove_file(&p);
328 }
329}