Skip to main content

claudex_cli/commands/
watch.rs

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    // Accumulate raw bytes; split on '\n' at the byte level so multi-byte
32    // UTF-8 codepoints that straddle a read boundary stay intact.
33    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
74/// Extract every complete (`\n`-terminated) line from `buf` and return them
75/// formatted for display. Any trailing partial line stays in `buf` for the
76/// next poll. Operates on raw bytes so multi-byte UTF-8 codepoints that
77/// straddle chunk boundaries survive intact.
78fn 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    // Session JSONL record style (has a "type" field)
116    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    // Structured log style (level + message)
139    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    // Extract HH:MM:SS from "YYYY-MM-DDTHH:MM:SS..." or "YYYY-MM-DD HH:MM:SS..."
164    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        // "é" is 0xC3 0xA9. If the reader hands us the first byte alone, we
298        // must not insert a replacement character — the codepoint needs to
299        // reassemble after the next chunk arrives.
300        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            // SAFETY: env mutation is not thread-safe; callers ensure
383            // this is used from a single test thread.
384            unsafe { std::env::set_var("HOME", path) };
385            Self { prev }
386        }
387    }
388    impl Drop for HomeGuard {
389        fn drop(&mut self) {
390            // SAFETY: see `set` above.
391            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}