use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::{LazyLock, Mutex};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Level {
Off = 0,
Events = 1,
Io = 2,
}
const ENV: u8 = 255;
static LEVEL: AtomicU8 = AtomicU8::new(ENV);
static CFG: LazyLock<(Level, PathBuf)> = LazyLock::new(|| {
let mut level = Level::Off;
let mut path = default_path();
if let Some(spec) = std::env::var_os("CCTOP_LOG") {
let spec = spec.to_string_lossy();
let mut named = false;
for part in spec.split(',') {
match part.trim() {
"0" | "off" | "no" => level = Level::Off,
"io" => level = Level::Io,
other if other.contains('/') || other.starts_with('~') => {
path = PathBuf::from(crate::util::untildify(other));
named = true;
}
"" => {}
_ => level = Level::Events,
}
}
if level == Level::Off && (!named || spec.trim().is_empty()) {
level = Level::Events;
}
}
(level, path)
});
static FILE: LazyLock<Mutex<Option<std::fs::File>>> = LazyLock::new(|| Mutex::new(None));
const MAX_LOG_BYTES: u64 = 16 * 1024 * 1024;
pub fn level() -> Level {
match LEVEL.load(Ordering::Relaxed) {
ENV => CFG.0,
0 => Level::Off,
1 => Level::Events,
_ => Level::Io,
}
}
#[cfg(feature = "debug")]
pub fn set(level: Level) {
LEVEL.store(level as u8, Ordering::Relaxed);
}
#[cfg(feature = "debug")]
pub fn level_name(level: Level) -> &'static str {
match level {
Level::Off => "off",
Level::Events => "events",
Level::Io => "io",
}
}
#[cfg(feature = "debug")]
pub fn parse_level(word: &str) -> Option<Level> {
match word {
"off" | "0" => Some(Level::Off),
"events" | "1" => Some(Level::Events),
"io" => Some(Level::Io),
_ => None,
}
}
pub fn path() -> &'static Path {
&CFG.1
}
fn default_path() -> PathBuf {
crate::config::CACHE_DIR.join("events.jsonl")
}
fn file() -> Option<std::sync::MutexGuard<'static, Option<std::fs::File>>> {
let mut slot = FILE.lock().unwrap_or_else(|e| e.into_inner());
if slot.is_none() {
*slot = open(&CFG.1);
}
slot.is_some().then_some(slot)
}
fn open(path: &Path) -> Option<std::fs::File> {
use std::os::unix::fs::OpenOptionsExt;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok()?;
}
if std::fs::metadata(path).map(|m| m.len()).unwrap_or(0) > MAX_LOG_BYTES {
let _ = std::fs::rename(path, path.with_extension("old.jsonl"));
}
std::fs::OpenOptions::new()
.create(true)
.append(true)
.mode(0o600)
.open(path)
.ok()
}
pub fn event(src: &'static str, kind: &'static str, fields: serde_json::Value) {
if level() < Level::Events {
return;
}
write(src, kind, fields);
}
pub fn bytes(
src: &'static str,
kind: &'static str,
dir: &'static str,
data: &[u8],
extra: serde_json::Value,
) {
if level() < Level::Events {
return;
}
let mut fields = serde_json::json!({ "dir": dir, "bytes": data.len() });
if let serde_json::Value::Object(map) = extra {
for (k, v) in map {
fields[k] = v;
}
}
if level() >= Level::Io {
fields["data"] = preview(data).into();
}
write(src, kind, fields);
}
pub fn tui(event: &crossterm::event::Event) {
use crossterm::event::{Event, MouseEventKind};
if level() < Level::Events {
return;
}
match event {
Event::Key(k) => write(
"tui",
"key",
serde_json::json!({
"code": format!("{:?}", k.code),
"mods": format!("{:?}", k.modifiers),
}),
),
Event::Mouse(m) => {
if matches!(m.kind, MouseEventKind::Moved) && level() < Level::Io {
return;
}
write(
"tui",
"mouse",
serde_json::json!({
"ev": format!("{:?}", m.kind),
"col": m.column,
"row": m.row,
}),
);
}
Event::Paste(text) => bytes("tui", "paste", "in", text.as_bytes(), serde_json::json!({})),
Event::Resize(cols, rows) => write(
"tui",
"resize",
serde_json::json!({ "cols": cols, "rows": rows }),
),
Event::FocusGained => write("tui", "focus", serde_json::json!({ "gained": true })),
Event::FocusLost => write("tui", "focus", serde_json::json!({ "gained": false })),
}
}
fn write(src: &'static str, kind: &'static str, fields: serde_json::Value) {
let mut line = serde_json::json!({
"ts": crate::util::ms_to_rfc3339(crate::util::now_ms()),
"pid": std::process::id(),
"src": src,
"kind": kind,
});
if let (Some(obj), serde_json::Value::Object(extra)) = (line.as_object_mut(), fields) {
for (k, v) in extra {
obj.insert(k, v);
}
}
let mut out = serde_json::to_vec(&line).unwrap_or_default();
out.push(b'\n');
if let Some(mut slot) = file()
&& let Some(f) = slot.as_mut()
{
let _ = f.write_all(&out);
}
}
fn preview(data: &[u8]) -> String {
const MAX: usize = 200;
let mut out = String::new();
for &b in data.iter().take(MAX) {
match b {
0x1b => out.push_str("\\e"),
b'\n' => out.push_str("\\n"),
b'\r' => out.push_str("\\r"),
b'\t' => out.push_str("\\t"),
0x20..=0x7e => out.push(b as char),
_ => out.push_str(&format!("\\x{b:02x}")),
}
}
if data.len() > MAX {
out.push_str(&format!("…+{}", data.len() - MAX));
}
out
}
pub const HELP: &str = "\
cctop log — the event stream CCTOP_LOG writes, readable without a TUI
USAGE:
cctop log [FILE] [-f] [-n N] [--src SRC] [--kind KIND] [--raw]
With no FILE it reads the default log (the one CCTOP_LOG writes). -f follows
it like tail -f. --src and --kind keep only matching events; --raw prints the
JSONL untouched for a pipe.
To get events in the file at all, run the cctop being watched with
CCTOP_LOG=events (metadata) or CCTOP_LOG=io (metadata plus byte previews), or
raise a running server's level with POST /api/debug/log.
";
fn flag_value<'a>(argv: &'a [String], i: &mut usize, name: &str) -> Option<&'a str> {
let arg = argv.get(*i)?;
if let Some(v) = arg.strip_prefix(&format!("{name}=")) {
return Some(v);
}
if arg == name {
*i += 1;
return argv.get(*i).map(String::as_str);
}
None
}
fn render(line: &str) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(line).ok()?;
let ts = v["ts"].as_str().unwrap_or_default();
let time = ts.get(11..).unwrap_or(ts);
let src = v["src"].as_str().unwrap_or("?");
let kind = v["kind"].as_str().unwrap_or("?");
let mut out = format!("{time} {src:7} {kind}");
if let Some(obj) = v.as_object() {
for (k, val) in obj {
if matches!(k.as_str(), "ts" | "pid" | "src" | "kind") {
continue;
}
let shown = match val {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
out.push_str(&format!(" {k}={shown}"));
}
}
Some(out)
}
pub fn run(argv: &[String]) -> i32 {
if argv.iter().any(|a| a == "-h" || a == "--help") {
print!("{HELP}");
return 0;
}
let mut follow = false;
let mut raw = false;
let mut tail: Option<usize> = None;
let mut src: Option<String> = None;
let mut kind: Option<String> = None;
let mut path = path().to_path_buf();
let mut i = 0;
while i < argv.len() {
if let Some(v) = flag_value(argv, &mut i, "--src") {
src = Some(v.to_string());
} else if let Some(v) = flag_value(argv, &mut i, "--kind") {
kind = Some(v.to_string());
} else if let Some(v) = flag_value(argv, &mut i, "-n") {
tail = v.parse().ok();
} else {
match argv[i].as_str() {
"-f" | "--follow" => follow = true,
"--raw" => raw = true,
"-h" | "--help" => {
print!("{HELP}");
return 0;
}
other if !other.starts_with('-') => path = PathBuf::from(other),
other => {
eprintln!("cctop log: unknown flag {other} (--help lists them)");
return 2;
}
}
}
i += 1;
}
let wanted = |line: &str| -> bool {
if src.is_none() && kind.is_none() {
return true;
}
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
return false;
};
src.as_deref().is_none_or(|s| v["src"].as_str() == Some(s))
&& kind
.as_deref()
.is_none_or(|k| v["kind"].as_str() == Some(k))
};
let show = |line: &str| {
if !wanted(line) {
return;
}
match raw {
true => println!("{line}"),
false => println!("{}", render(line).unwrap_or_else(|| line.to_string())),
}
};
let Ok(content) = std::fs::read_to_string(&path) else {
eprintln!(
"cctop log: {} — nothing there. Run a cctop with CCTOP_LOG=events (or =io) first.",
path.display()
);
return 1;
};
let mut lines: Vec<&str> = content.lines().collect();
if let Some(n) = tail {
lines = lines.split_off(lines.len().saturating_sub(n));
}
for line in lines {
show(line);
}
if !follow {
return 0;
}
let mut at = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
loop {
std::thread::sleep(std::time::Duration::from_millis(400));
let Ok(mut f) = std::fs::File::open(&path) else {
continue;
};
let len = f.metadata().map(|m| m.len()).unwrap_or(0);
if len < at {
at = 0;
}
use std::io::{Read, Seek, SeekFrom};
if f.seek(SeekFrom::Start(at)).is_err() {
continue;
}
let mut buf = String::new();
if f.read_to_string(&mut buf).is_err() {
continue;
}
at = len;
let complete = buf.ends_with('\n') || buf.is_empty();
let mut iter = buf.lines().peekable();
while let Some(line) = iter.next() {
if !complete && iter.peek().is_none() {
at -= line.len() as u64;
break;
}
show(line);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_preview_escapes_what_would_move_the_cursor() {
let shown = preview(b"\x1b[<35;79;14M");
assert_eq!(shown, "\\e[<35;79;14M");
assert!(!shown.contains('\x1b'));
}
#[test]
fn a_preview_bounds_how_much_it_carries() {
let big = vec![b'x'; 4096];
let shown = preview(&big);
assert!(shown.ends_with("…+3896"), "{shown}");
assert_eq!(shown.len(), 208);
}
#[test]
fn a_line_renders_for_a_person() {
let line = r#"{"ts":"2026-09-17T12:34:56.789Z","pid":1,"src":"http","kind":"response","path":"/api/sessions","status":200,"bytes":1234}"#;
let shown = render(line).unwrap();
assert_eq!(
shown,
"12:34:56.789Z http response path=/api/sessions status=200 bytes=1234"
);
}
#[test]
fn level_words_and_paths_parse() {
for (spec, want) in [
("off", Level::Off),
("io", Level::Io),
("events", Level::Events),
("1", Level::Events),
] {
let mut level = Level::Off;
for part in spec.split(',') {
match part.trim() {
"0" | "off" | "no" => level = Level::Off,
"io" => level = Level::Io,
other if other.contains('/') || other.starts_with('~') => {}
"" => {}
_ => level = Level::Events,
}
}
assert_eq!(level, want, "{spec}");
}
}
}