1use std::fs;
2use std::io::{Read, Seek, SeekFrom};
3use std::path::{Path, PathBuf};
4use std::thread;
5use std::time::Duration;
6
7use anyhow::Result;
8use serde_json::Value;
9
10use crate::ui;
11
12pub fn run(raw: bool, follow: Option<&str>) -> Result<()> {
13 let path = match follow {
14 Some(p) => PathBuf::from(p),
15 None => default_debug_log()?,
16 };
17
18 eprintln!(
19 "Watching {} (Ctrl-C to exit)",
20 ui::timestamp(&path.display().to_string())
21 );
22 if !path.exists() {
23 eprintln!(
24 "{} {}",
25 ui::timestamp("waiting for"),
26 ui::timestamp("claude --debug-file <path>"),
27 );
28 }
29
30 let mut pos: u64 = file_len(&path);
31 let mut leftover: Vec<u8> = Vec::new();
34
35 loop {
36 let len = file_len(&path);
37 if len < pos {
38 eprintln!(
39 "\n{} {}",
40 ui::banner("─── new session"),
41 ui::timestamp(&path.display().to_string())
42 );
43 pos = 0;
44 leftover.clear();
45 }
46 if len > pos
47 && let Ok(mut f) = fs::File::open(&path)
48 && f.seek(SeekFrom::Start(pos)).is_ok()
49 {
50 let mut buf = Vec::new();
51 if f.read_to_end(&mut buf).is_ok() {
52 pos += buf.len() as u64;
53 leftover.extend_from_slice(&buf);
54 for line in lines_from_leftover(&mut leftover, raw) {
55 println!("{line}");
56 }
57 }
58 }
59
60 thread::sleep(Duration::from_millis(500));
61 }
62}
63
64fn default_debug_log() -> Result<PathBuf> {
65 let dir = claudex::claudex_dir()?.join("debug");
66 fs::create_dir_all(&dir)?;
67 Ok(dir.join("latest.log"))
68}
69
70fn file_len(path: &Path) -> u64 {
71 fs::metadata(path).map(|m| m.len()).unwrap_or(0)
72}
73
74fn lines_from_leftover(buf: &mut Vec<u8>, raw: bool) -> Vec<String> {
79 let mut out = Vec::new();
80 let mut start = 0;
81 for i in 0..buf.len() {
82 if buf[i] == b'\n' {
83 let line = String::from_utf8_lossy(&buf[start..i]);
84 if !line.trim().is_empty() {
85 out.push(if raw {
86 line.into_owned()
87 } else {
88 format_line(&line)
89 });
90 }
91 start = i + 1;
92 }
93 }
94 if start > 0 {
95 buf.drain(..start);
96 }
97 out
98}
99
100fn format_line(line: &str) -> String {
101 if let Ok(v) = serde_json::from_str::<Value>(line) {
102 format_json_line(&v, line)
103 } else {
104 ui::classify_text_line(line)
105 }
106}
107
108fn format_json_line(v: &Value, raw_line: &str) -> String {
109 let ts = v["timestamp"]
110 .as_str()
111 .or_else(|| v["ts"].as_str())
112 .unwrap_or("");
113 let ts_short = shorten_ts(ts);
114
115 if let Some(record_type) = v["type"].as_str() {
117 if record_type == "system" {
118 let dur = v["durationMs"].as_u64().unwrap_or(0);
119 let suffix = if dur > 0 {
120 format!(" {dur}ms")
121 } else {
122 String::new()
123 };
124 return format!(
125 "{} [{}]{}",
126 ui::timestamp(ts_short),
127 ui::level_debug("system"),
128 ui::level_debug(&suffix)
129 );
130 }
131 return format!(
132 "{} [{}]",
133 ui::timestamp(ts_short),
134 ui::record_type(record_type)
135 );
136 }
137
138 let level = v["level"]
140 .as_str()
141 .or_else(|| v["severity"].as_str())
142 .unwrap_or("info");
143 let msg = v["message"]
144 .as_str()
145 .or_else(|| v["msg"].as_str())
146 .unwrap_or(raw_line);
147
148 let (level_s, msg_s) = match level.to_lowercase().as_str() {
149 "error" | "fatal" | "critical" => (ui::level_error(level), ui::level_error(msg)),
150 "warn" | "warning" => (ui::level_warn(level), ui::level_warn(msg)),
151 "debug" | "trace" => (ui::level_debug(level), ui::level_debug(msg)),
152 _ => (level.to_string(), msg.to_string()),
153 };
154
155 if ts_short.is_empty() {
156 format!("[{level_s}] {msg_s}")
157 } else {
158 format!("{} [{}] {}", ui::timestamp(ts_short), level_s, msg_s)
159 }
160}
161
162fn shorten_ts(ts: &str) -> &str {
163 if ts.len() >= 19 && ts.is_char_boundary(11) && ts.is_char_boundary(19) {
165 &ts[11..19]
166 } else if ts.len() >= 8 && ts.is_char_boundary(8) {
167 &ts[..8]
168 } else {
169 ts
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 fn strip_ansi(s: &str) -> String {
178 let mut out = String::with_capacity(s.len());
179 let mut chars = s.chars();
180 while let Some(c) = chars.next() {
181 if c == '\x1b' {
182 for c in chars.by_ref() {
183 if c.is_ascii_alphabetic() {
184 break;
185 }
186 }
187 } else {
188 out.push(c);
189 }
190 }
191 out
192 }
193
194 #[test]
195 fn shorten_ts_iso_with_t_separator() {
196 assert_eq!(shorten_ts("2026-04-18T12:34:56.789Z"), "12:34:56");
197 }
198
199 #[test]
200 fn shorten_ts_iso_with_space_separator() {
201 assert_eq!(shorten_ts("2026-04-18 12:34:56.789"), "12:34:56");
202 }
203
204 #[test]
205 fn shorten_ts_short_input_returned_as_is() {
206 assert_eq!(shorten_ts("12:34:56"), "12:34:56");
207 assert_eq!(shorten_ts(""), "");
208 assert_eq!(shorten_ts("abc"), "abc");
209 }
210
211 #[test]
212 fn format_line_handles_structured_json_log() {
213 let line = r#"{"timestamp":"2026-04-18T12:34:56.000Z","level":"error","message":"boom"}"#;
214 let out = strip_ansi(&format_line(line));
215 assert!(out.contains("12:34:56"), "got: {out}");
216 assert!(out.contains("error"), "got: {out}");
217 assert!(out.contains("boom"), "got: {out}");
218 }
219
220 #[test]
221 fn format_line_handles_alt_field_names() {
222 let line = r#"{"ts":"2026-04-18T09:00:00.000Z","severity":"warn","msg":"slow"}"#;
223 let out = strip_ansi(&format_line(line));
224 assert!(out.contains("09:00:00"));
225 assert!(out.contains("warn"));
226 assert!(out.contains("slow"));
227 }
228
229 #[test]
230 fn format_line_missing_timestamp_omits_ts_prefix() {
231 let line = r#"{"level":"info","message":"hello"}"#;
232 let out = strip_ansi(&format_line(line));
233 assert!(out.starts_with("[info] hello"), "got: {out}");
234 }
235
236 #[test]
237 fn format_line_session_record_types() {
238 for (record_type, ts) in [
239 ("user", "2026-04-18T12:00:00.000Z"),
240 ("assistant", "2026-04-18T12:00:01.000Z"),
241 ("other", "2026-04-18T12:00:02.000Z"),
242 ] {
243 let line = format!(r#"{{"type":"{record_type}","timestamp":"{ts}"}}"#);
244 let out = strip_ansi(&format_line(&line));
245 assert!(out.contains(record_type), "type={record_type} got: {out}");
246 assert!(out.contains(&ts[11..19]), "type={record_type} got: {out}");
247 }
248 }
249
250 #[test]
251 fn format_line_system_record_includes_duration() {
252 let line = r#"{"type":"system","timestamp":"2026-04-18T12:00:00.000Z","durationMs":250}"#;
253 let out = strip_ansi(&format_line(line));
254 assert!(out.contains("system"));
255 assert!(out.contains("250ms"), "got: {out}");
256 }
257
258 #[test]
259 fn format_line_system_record_omits_zero_duration() {
260 let line = r#"{"type":"system","timestamp":"2026-04-18T12:00:00.000Z"}"#;
261 let out = strip_ansi(&format_line(line));
262 assert!(out.contains("system"));
263 assert!(!out.contains("ms"), "got: {out}");
264 }
265
266 #[test]
267 fn format_line_falls_back_to_classify_for_non_json() {
268 assert_eq!(
269 strip_ansi(&format_line("plain log line")),
270 strip_ansi(&ui::classify_text_line("plain log line")),
271 );
272 }
273
274 #[test]
275 fn lines_from_leftover_splits_complete_lines() {
276 let mut buf = b"a\nb\nc\n".to_vec();
277 let lines = lines_from_leftover(&mut buf, true);
278 assert_eq!(lines, vec!["a", "b", "c"]);
279 assert!(buf.is_empty());
280 }
281
282 #[test]
283 fn lines_from_leftover_buffers_partial_line() {
284 let mut buf = b"hello wor".to_vec();
285 let first = lines_from_leftover(&mut buf, true);
286 assert!(first.is_empty());
287 assert_eq!(buf, b"hello wor");
288
289 buf.extend_from_slice(b"ld\nnext\n");
290 let second = lines_from_leftover(&mut buf, true);
291 assert_eq!(second, vec!["hello world", "next"]);
292 assert!(buf.is_empty());
293 }
294
295 #[test]
296 fn lines_from_leftover_preserves_utf8_across_chunks() {
297 let mut buf = vec![b'a', 0xC3];
301 let first = lines_from_leftover(&mut buf, true);
302 assert!(first.is_empty());
303 buf.push(0xA9);
304 buf.push(b'\n');
305 let second = lines_from_leftover(&mut buf, true);
306 assert_eq!(second, vec!["aé"]);
307 }
308
309 #[test]
310 fn lines_from_leftover_skips_blank_lines() {
311 let mut buf = b"a\n\n \nb\n".to_vec();
312 let lines = lines_from_leftover(&mut buf, true);
313 assert_eq!(lines, vec!["a", "b"]);
314 }
315
316 #[test]
317 fn lines_from_leftover_raw_vs_formatted() {
318 let json = br#"{"level":"error","message":"boom"}
319"#;
320 let mut buf1 = json.to_vec();
321 let raw = lines_from_leftover(&mut buf1, true);
322 let mut buf2 = json.to_vec();
323 let formatted = lines_from_leftover(&mut buf2, false);
324
325 assert_eq!(raw.len(), 1);
326 assert_eq!(formatted.len(), 1);
327 assert!(raw[0].contains("{\"level\""));
328 assert!(!raw[0].contains('\x1b'));
329 assert!(
330 strip_ansi(&formatted[0]).contains("boom"),
331 "got: {}",
332 formatted[0]
333 );
334 }
335
336 #[test]
337 fn lines_from_leftover_trailing_newline() {
338 let mut buf = b"a\n".to_vec();
339 lines_from_leftover(&mut buf, true);
340 assert!(buf.is_empty());
341
342 buf.extend_from_slice(b"b");
343 lines_from_leftover(&mut buf, true);
344 assert_eq!(buf, b"b");
345
346 buf.extend_from_slice(b"\n");
347 let lines = lines_from_leftover(&mut buf, true);
348 assert_eq!(lines, vec!["b"]);
349 assert!(buf.is_empty());
350 }
351
352 #[test]
353 fn file_len_missing_file_returns_zero() {
354 let p = Path::new("/definitely/not/a/real/path/claudex-watch-test-xyz");
355 assert_eq!(file_len(p), 0);
356 }
357
358 #[test]
359 fn file_len_reports_size() {
360 use std::io::Write;
361 let tmp = tempfile::NamedTempFile::new().unwrap();
362 let mut f = std::fs::File::create(tmp.path()).unwrap();
363 f.write_all(b"hello").unwrap();
364 assert_eq!(file_len(tmp.path()), 5);
365 }
366
367 #[test]
368 fn default_debug_log_creates_dir_and_returns_path() {
369 let tmp = tempfile::TempDir::new().unwrap();
370 let _guard = HomeGuard::set(tmp.path());
371 let path = default_debug_log().unwrap();
372 assert_eq!(path, tmp.path().join(".claudex/debug/latest.log"));
373 assert!(path.parent().unwrap().is_dir());
374 }
375
376 struct HomeGuard {
377 prev: Option<std::ffi::OsString>,
378 }
379 impl HomeGuard {
380 fn set(path: &Path) -> Self {
381 let prev = std::env::var_os("HOME");
382 unsafe { std::env::set_var("HOME", path) };
385 Self { prev }
386 }
387 }
388 impl Drop for HomeGuard {
389 fn drop(&mut self) {
390 unsafe {
392 match self.prev.take() {
393 Some(v) => std::env::set_var("HOME", v),
394 None => std::env::remove_var("HOME"),
395 }
396 }
397 }
398 }
399}