use std::fs;
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::thread;
use std::time::Duration;
use anyhow::Result;
use serde_json::Value;
use crate::ui;
pub fn run(raw: bool, follow: Option<&str>) -> Result<()> {
let path = match follow {
Some(p) => PathBuf::from(p),
None => default_debug_log()?,
};
eprintln!(
"Watching {} (Ctrl-C to exit)",
ui::timestamp(&path.display().to_string())
);
if !path.exists() {
eprintln!(
"{} {}",
ui::timestamp("waiting for"),
ui::timestamp("claude --debug-file <path>"),
);
}
let mut pos: u64 = file_len(&path);
let mut leftover: Vec<u8> = Vec::new();
loop {
let len = file_len(&path);
if len < pos {
eprintln!(
"\n{} {}",
ui::banner("āāā new session"),
ui::timestamp(&path.display().to_string())
);
pos = 0;
leftover.clear();
}
if len > pos
&& let Ok(mut f) = fs::File::open(&path)
&& f.seek(SeekFrom::Start(pos)).is_ok()
{
let mut buf = Vec::new();
if f.read_to_end(&mut buf).is_ok() {
pos += buf.len() as u64;
leftover.extend_from_slice(&buf);
for line in lines_from_leftover(&mut leftover, raw) {
println!("{line}");
}
}
}
thread::sleep(Duration::from_millis(500));
}
}
fn default_debug_log() -> Result<PathBuf> {
let dir = claudex::claudex_dir()?.join("debug");
fs::create_dir_all(&dir)?;
Ok(dir.join("latest.log"))
}
fn file_len(path: &Path) -> u64 {
fs::metadata(path).map(|m| m.len()).unwrap_or(0)
}
fn lines_from_leftover(buf: &mut Vec<u8>, raw: bool) -> Vec<String> {
let mut out = Vec::new();
let mut start = 0;
for i in 0..buf.len() {
if buf[i] == b'\n' {
let line = String::from_utf8_lossy(&buf[start..i]);
if !line.trim().is_empty() {
out.push(if raw {
line.into_owned()
} else {
format_line(&line)
});
}
start = i + 1;
}
}
if start > 0 {
buf.drain(..start);
}
out
}
fn format_line(line: &str) -> String {
if let Ok(v) = serde_json::from_str::<Value>(line) {
format_json_line(&v, line)
} else {
ui::classify_text_line(line)
}
}
fn format_json_line(v: &Value, raw_line: &str) -> String {
let ts = v["timestamp"]
.as_str()
.or_else(|| v["ts"].as_str())
.unwrap_or("");
let ts_short = shorten_ts(ts);
if let Some(record_type) = v["type"].as_str() {
if record_type == "system" {
let dur = v["durationMs"].as_u64().unwrap_or(0);
let suffix = if dur > 0 {
format!(" {dur}ms")
} else {
String::new()
};
return format!(
"{} [{}]{}",
ui::timestamp(ts_short),
ui::level_debug("system"),
ui::level_debug(&suffix)
);
}
return format!(
"{} [{}]",
ui::timestamp(ts_short),
ui::record_type(record_type)
);
}
let level = v["level"]
.as_str()
.or_else(|| v["severity"].as_str())
.unwrap_or("info");
let msg = v["message"]
.as_str()
.or_else(|| v["msg"].as_str())
.unwrap_or(raw_line);
let (level_s, msg_s) = match level.to_lowercase().as_str() {
"error" | "fatal" | "critical" => (ui::level_error(level), ui::level_error(msg)),
"warn" | "warning" => (ui::level_warn(level), ui::level_warn(msg)),
"debug" | "trace" => (ui::level_debug(level), ui::level_debug(msg)),
_ => (level.to_string(), msg.to_string()),
};
if ts_short.is_empty() {
format!("[{level_s}] {msg_s}")
} else {
format!("{} [{}] {}", ui::timestamp(ts_short), level_s, msg_s)
}
}
fn shorten_ts(ts: &str) -> &str {
if ts.len() >= 19 && ts.is_char_boundary(11) && ts.is_char_boundary(19) {
&ts[11..19]
} else if ts.len() >= 8 && ts.is_char_boundary(8) {
&ts[..8]
} else {
ts
}
}
#[cfg(test)]
mod tests {
use super::*;
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\x1b' {
for c in chars.by_ref() {
if c.is_ascii_alphabetic() {
break;
}
}
} else {
out.push(c);
}
}
out
}
#[test]
fn shorten_ts_iso_with_t_separator() {
assert_eq!(shorten_ts("2026-04-18T12:34:56.789Z"), "12:34:56");
}
#[test]
fn shorten_ts_iso_with_space_separator() {
assert_eq!(shorten_ts("2026-04-18 12:34:56.789"), "12:34:56");
}
#[test]
fn shorten_ts_short_input_returned_as_is() {
assert_eq!(shorten_ts("12:34:56"), "12:34:56");
assert_eq!(shorten_ts(""), "");
assert_eq!(shorten_ts("abc"), "abc");
}
#[test]
fn format_line_handles_structured_json_log() {
let line = r#"{"timestamp":"2026-04-18T12:34:56.000Z","level":"error","message":"boom"}"#;
let out = strip_ansi(&format_line(line));
assert!(out.contains("12:34:56"), "got: {out}");
assert!(out.contains("error"), "got: {out}");
assert!(out.contains("boom"), "got: {out}");
}
#[test]
fn format_line_handles_alt_field_names() {
let line = r#"{"ts":"2026-04-18T09:00:00.000Z","severity":"warn","msg":"slow"}"#;
let out = strip_ansi(&format_line(line));
assert!(out.contains("09:00:00"));
assert!(out.contains("warn"));
assert!(out.contains("slow"));
}
#[test]
fn format_line_missing_timestamp_omits_ts_prefix() {
let line = r#"{"level":"info","message":"hello"}"#;
let out = strip_ansi(&format_line(line));
assert!(out.starts_with("[info] hello"), "got: {out}");
}
#[test]
fn format_line_session_record_types() {
for (record_type, ts) in [
("user", "2026-04-18T12:00:00.000Z"),
("assistant", "2026-04-18T12:00:01.000Z"),
("other", "2026-04-18T12:00:02.000Z"),
] {
let line = format!(r#"{{"type":"{record_type}","timestamp":"{ts}"}}"#);
let out = strip_ansi(&format_line(&line));
assert!(out.contains(record_type), "type={record_type} got: {out}");
assert!(out.contains(&ts[11..19]), "type={record_type} got: {out}");
}
}
#[test]
fn format_line_system_record_includes_duration() {
let line = r#"{"type":"system","timestamp":"2026-04-18T12:00:00.000Z","durationMs":250}"#;
let out = strip_ansi(&format_line(line));
assert!(out.contains("system"));
assert!(out.contains("250ms"), "got: {out}");
}
#[test]
fn format_line_system_record_omits_zero_duration() {
let line = r#"{"type":"system","timestamp":"2026-04-18T12:00:00.000Z"}"#;
let out = strip_ansi(&format_line(line));
assert!(out.contains("system"));
assert!(!out.contains("ms"), "got: {out}");
}
#[test]
fn format_line_falls_back_to_classify_for_non_json() {
assert_eq!(
strip_ansi(&format_line("plain log line")),
strip_ansi(&ui::classify_text_line("plain log line")),
);
}
#[test]
fn lines_from_leftover_splits_complete_lines() {
let mut buf = b"a\nb\nc\n".to_vec();
let lines = lines_from_leftover(&mut buf, true);
assert_eq!(lines, vec!["a", "b", "c"]);
assert!(buf.is_empty());
}
#[test]
fn lines_from_leftover_buffers_partial_line() {
let mut buf = b"hello wor".to_vec();
let first = lines_from_leftover(&mut buf, true);
assert!(first.is_empty());
assert_eq!(buf, b"hello wor");
buf.extend_from_slice(b"ld\nnext\n");
let second = lines_from_leftover(&mut buf, true);
assert_eq!(second, vec!["hello world", "next"]);
assert!(buf.is_empty());
}
#[test]
fn lines_from_leftover_preserves_utf8_across_chunks() {
let mut buf = vec![b'a', 0xC3];
let first = lines_from_leftover(&mut buf, true);
assert!(first.is_empty());
buf.push(0xA9);
buf.push(b'\n');
let second = lines_from_leftover(&mut buf, true);
assert_eq!(second, vec!["aƩ"]);
}
#[test]
fn lines_from_leftover_skips_blank_lines() {
let mut buf = b"a\n\n \nb\n".to_vec();
let lines = lines_from_leftover(&mut buf, true);
assert_eq!(lines, vec!["a", "b"]);
}
#[test]
fn lines_from_leftover_raw_vs_formatted() {
let json = br#"{"level":"error","message":"boom"}
"#;
let mut buf1 = json.to_vec();
let raw = lines_from_leftover(&mut buf1, true);
let mut buf2 = json.to_vec();
let formatted = lines_from_leftover(&mut buf2, false);
assert_eq!(raw.len(), 1);
assert_eq!(formatted.len(), 1);
assert!(raw[0].contains("{\"level\""));
assert!(!raw[0].contains('\x1b'));
assert!(
strip_ansi(&formatted[0]).contains("boom"),
"got: {}",
formatted[0]
);
}
#[test]
fn lines_from_leftover_trailing_newline() {
let mut buf = b"a\n".to_vec();
lines_from_leftover(&mut buf, true);
assert!(buf.is_empty());
buf.extend_from_slice(b"b");
lines_from_leftover(&mut buf, true);
assert_eq!(buf, b"b");
buf.extend_from_slice(b"\n");
let lines = lines_from_leftover(&mut buf, true);
assert_eq!(lines, vec!["b"]);
assert!(buf.is_empty());
}
#[test]
fn file_len_missing_file_returns_zero() {
let p = Path::new("/definitely/not/a/real/path/claudex-watch-test-xyz");
assert_eq!(file_len(p), 0);
}
#[test]
fn file_len_reports_size() {
use std::io::Write;
let tmp = tempfile::NamedTempFile::new().unwrap();
let mut f = std::fs::File::create(tmp.path()).unwrap();
f.write_all(b"hello").unwrap();
assert_eq!(file_len(tmp.path()), 5);
}
#[test]
fn default_debug_log_creates_dir_and_returns_path() {
let tmp = tempfile::TempDir::new().unwrap();
let _guard = HomeGuard::set(tmp.path());
let path = default_debug_log().unwrap();
assert_eq!(path, tmp.path().join(".claudex/debug/latest.log"));
assert!(path.parent().unwrap().is_dir());
}
struct HomeGuard {
prev: Option<std::ffi::OsString>,
}
impl HomeGuard {
fn set(path: &Path) -> Self {
let prev = std::env::var_os("HOME");
unsafe { std::env::set_var("HOME", path) };
Self { prev }
}
}
impl Drop for HomeGuard {
fn drop(&mut self) {
unsafe {
match self.prev.take() {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
}
}
}