use std::fs::{self, OpenOptions};
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process;
use std::time::{SystemTime, UNIX_EPOCH};
use rand::random;
use serde::Serialize;
use super::output::{redact_secret_patterns, truncate_result};
use super::HeadlessError;
const LOG_FILENAME_PREFIX: &str = "run-";
const LOG_FILENAME_SUFFIX: &str = ".jsonl";
const LOG_FILENAME_SEPARATOR: char = '-';
const TIMESTAMP_WIDTH: usize = 20;
const RAND_SUFFIX_HEX_WIDTH: usize = 8;
const EVENT_KIND_MESSAGE: &str = "message";
const EVENT_KIND_TOOL_CALL: &str = "tool_call";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum LogLevel {
Error,
Warn,
Info,
Debug,
}
impl std::str::FromStr for LogLevel {
type Err = HeadlessError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"error" => Ok(LogLevel::Error),
"warn" => Ok(LogLevel::Warn),
"info" => Ok(LogLevel::Info),
"debug" => Ok(LogLevel::Debug),
other => Err(HeadlessError::InputInvalid(format!(
"invalid [headless] log_level {other:?} (expected one of \
error|warn|info|debug)"
))),
}
}
}
struct LogFileEntry {
path: PathBuf,
ts: Option<u64>,
size_bytes: u64,
}
#[derive(Debug, Clone)]
pub enum LogEvent<'a> {
Message {
level: LogLevel,
text: &'a str,
},
ToolCall {
level: LogLevel,
name: &'a str,
ok: bool,
ms: u64,
input: &'a str,
},
}
impl LogEvent<'_> {
fn level(&self) -> LogLevel {
match self {
LogEvent::Message { level, .. } | LogEvent::ToolCall { level, .. } => *level,
}
}
fn render(
&self,
configured_level: LogLevel,
tool_result_cap: usize,
) -> Result<String, HeadlessError> {
let ts = current_epoch_millis();
let serialized = match self {
LogEvent::Message { level, text } => serde_json::to_string(&MessageLine {
ts,
level: *level,
kind: EVENT_KIND_MESSAGE,
message: text,
}),
LogEvent::ToolCall {
level,
name,
ok,
ms,
input,
} => {
let (input_field, input_len_field) = if configured_level == LogLevel::Debug {
(
Some(truncate_result(
&redact_secret_patterns(input),
tool_result_cap,
)),
None,
)
} else {
(None, Some(input.len()))
};
serde_json::to_string(&ToolCallLine {
ts,
level: *level,
kind: EVENT_KIND_TOOL_CALL,
name,
ok: *ok,
ms: *ms,
input: input_field,
input_len: input_len_field,
})
}
};
serialized.map_err(|e| HeadlessError::Io(e.to_string()))
}
}
#[derive(Debug, Serialize)]
struct MessageLine<'a> {
ts: u64,
level: LogLevel,
kind: &'static str,
message: &'a str,
}
#[derive(Debug, Serialize)]
struct ToolCallLine<'a> {
ts: u64,
level: LogLevel,
kind: &'static str,
name: &'a str,
ok: bool,
ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
input: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
input_len: Option<usize>,
}
#[derive(Debug)]
pub struct RunLog {
path: PathBuf,
level: LogLevel,
tool_result_cap: usize,
}
impl RunLog {
pub fn start(
logs_dir: &Path,
level: LogLevel,
retention_runs: usize,
max_log_bytes: u64,
tool_result_cap: usize,
) -> Result<Self, HeadlessError> {
fs::create_dir_all(logs_dir).map_err(|e| HeadlessError::Io(e.to_string()))?;
prune_retention(logs_dir, retention_runs, max_log_bytes);
let path = logs_dir.join(generate_log_file_name());
OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| HeadlessError::Io(e.to_string()))?;
Ok(Self {
path,
level,
tool_result_cap,
})
}
pub fn event(&mut self, ev: &LogEvent<'_>) -> Result<(), HeadlessError> {
if ev.level() > self.level {
return Ok(());
}
let line = ev.render(self.level, self.tool_result_cap)?;
self.append_line(&line)
}
fn append_line(&self, line: &str) -> Result<(), HeadlessError> {
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
.map_err(|e| HeadlessError::Io(e.to_string()))?;
writeln!(file, "{line}").map_err(|e| HeadlessError::Io(e.to_string()))
}
}
fn current_epoch_millis() -> u64 {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
u64::try_from(millis).unwrap_or(u64::MAX)
}
fn generate_log_file_name() -> String {
let ts_ms = current_epoch_millis();
let pid = process::id();
let rand_suffix: u32 = random();
format!(
"{LOG_FILENAME_PREFIX}{ts_ms:0ts_width$}-{pid}-{rand_suffix:0rand_width$x}{LOG_FILENAME_SUFFIX}",
ts_width = TIMESTAMP_WIDTH,
rand_width = RAND_SUFFIX_HEX_WIDTH,
)
}
fn parse_log_timestamp(file_name: &str) -> Option<u64> {
let stem = file_name
.strip_prefix(LOG_FILENAME_PREFIX)?
.strip_suffix(LOG_FILENAME_SUFFIX)?;
let ts_part = stem.split(LOG_FILENAME_SEPARATOR).next()?;
ts_part.parse::<u64>().ok()
}
fn list_run_logs(dir: &Path) -> Vec<LogFileEntry> {
let Ok(read_dir) = fs::read_dir(dir) else {
return Vec::new();
};
read_dir
.filter_map(Result::ok)
.filter(|entry| entry.file_type().map(|t| t.is_file()).unwrap_or(false))
.filter_map(|entry| {
let file_name_os = entry.file_name();
let file_name = file_name_os.to_str()?;
if !file_name.starts_with(LOG_FILENAME_PREFIX)
|| !file_name.ends_with(LOG_FILENAME_SUFFIX)
{
return None;
}
let size_bytes = entry.metadata().map(|m| m.len()).unwrap_or(0);
Some(LogFileEntry {
path: entry.path(),
ts: parse_log_timestamp(file_name),
size_bytes,
})
})
.collect()
}
fn try_prune_one(path: &Path) -> bool {
match fs::remove_file(path) {
Ok(()) => true,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => true,
Err(_) => false,
}
}
fn prune_retention(dir: &Path, retention_runs: usize, max_bytes: u64) {
let mut entries = list_run_logs(dir);
entries.sort_by_key(|e| e.ts);
let mut count = entries.len();
let mut total_bytes: u64 = entries.iter().map(|e| e.size_bytes).sum();
for entry in &entries {
if count <= retention_runs && total_bytes <= max_bytes {
break;
}
if try_prune_one(&entry.path) {
count = count.saturating_sub(1);
total_bytes = total_bytes.saturating_sub(entry.size_bytes);
}
}
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::{Path, PathBuf};
use super::super::limits::{LOG_MAX_BYTES, LOG_RETENTION_RUNS, TOOL_RESULT_CAP};
use super::{
current_epoch_millis, parse_log_timestamp, prune_retention, HeadlessError, LogEvent,
LogLevel, RunLog, LOG_FILENAME_PREFIX, LOG_FILENAME_SUFFIX, TIMESTAMP_WIDTH,
};
fn touch_log(dir: &Path, i: usize) -> PathBuf {
let ts: u64 = 1_000_000_000_000 + i as u64;
let name = format!(
"{LOG_FILENAME_PREFIX}{ts:0width$}-9999-{i:08x}{LOG_FILENAME_SUFFIX}",
width = TIMESTAMP_WIDTH
);
let path = dir.join(name);
fs::write(&path, b"{}\n").expect("test fixture write must succeed");
path
}
#[test]
fn test_log_retention_prunes_oldest_beyond_count_cap() {
let dir = tempfile::tempdir().expect("tempdir");
for i in 0..(LOG_RETENTION_RUNS + 5) {
touch_log(dir.path(), i);
}
let _log = RunLog::start(
dir.path(),
LogLevel::Info,
LOG_RETENTION_RUNS,
LOG_MAX_BYTES,
TOOL_RESULT_CAP,
)
.expect("start must succeed");
let n = fs::read_dir(dir.path()).expect("read_dir").count();
assert!(
n <= LOG_RETENTION_RUNS + 1,
"expected pruned count, got {n}"
);
}
#[test]
fn test_run_log_start_respects_custom_effective_retention_count() {
let dir = tempfile::tempdir().expect("tempdir");
let small_retention = 3usize;
for i in 0..(small_retention + 5) {
touch_log(dir.path(), i);
}
let _log = RunLog::start(
dir.path(),
LogLevel::Info,
small_retention,
LOG_MAX_BYTES,
TOOL_RESULT_CAP,
)
.expect("start must succeed");
let n = fs::read_dir(dir.path()).expect("read_dir").count();
assert!(
n <= small_retention + 1,
"expected pruning down to the custom (smaller) retention cap, got {n} files"
);
}
#[test]
fn test_log_retention_keeps_the_newest_entries() {
let dir = tempfile::tempdir().expect("tempdir");
let mut paths = Vec::new();
for i in 0..(LOG_RETENTION_RUNS + 3) {
paths.push(touch_log(dir.path(), i));
}
prune_retention(dir.path(), LOG_RETENTION_RUNS, LOG_MAX_BYTES);
let newest = paths.last().expect("at least one path");
assert!(
newest.exists(),
"newest log file must survive retention pruning"
);
let oldest = paths.first().expect("at least one path");
assert!(!oldest.exists(), "oldest log file must be pruned first");
}
#[test]
fn test_start_succeeds_when_a_prune_candidate_already_vanished() {
let dir = tempfile::tempdir().expect("tempdir");
let mut paths = Vec::new();
for i in 0..(LOG_RETENTION_RUNS + 5) {
paths.push(touch_log(dir.path(), i));
}
fs::remove_file(&paths[0]).expect("simulated race removal");
let result = RunLog::start(
dir.path(),
LogLevel::Info,
LOG_RETENTION_RUNS,
LOG_MAX_BYTES,
TOOL_RESULT_CAP,
);
assert!(
result.is_ok(),
"start must tolerate a vanished prune candidate"
);
}
#[test]
fn test_unparseable_timestamp_filename_is_pruned_first_without_crashing() {
let dir = tempfile::tempdir().expect("tempdir");
let garbage_name = format!("{LOG_FILENAME_PREFIX}not-a-timestamp-1{LOG_FILENAME_SUFFIX}");
let garbage_path = dir.path().join(&garbage_name);
fs::write(&garbage_path, b"{}\n").expect("write garbage fixture");
for i in 0..(LOG_RETENTION_RUNS + 2) {
touch_log(dir.path(), i);
}
prune_retention(dir.path(), LOG_RETENTION_RUNS, LOG_MAX_BYTES);
assert!(
!garbage_path.exists(),
"unparseable-ts filename must be pruned first"
);
}
#[test]
fn test_parse_log_timestamp_none_for_malformed_some_for_wellformed() {
assert_eq!(parse_log_timestamp("not-even-close.txt"), None);
assert_eq!(parse_log_timestamp("run-abc-123-deadbeef.jsonl"), None);
assert_eq!(
parse_log_timestamp("run-00000000000001234567-123-deadbeef.jsonl"),
Some(1_234_567)
);
}
#[test]
fn test_debug_tool_call_redacts_secret_in_written_log_line() {
let dir = tempfile::tempdir().expect("tempdir");
let secret = format!("sk-ant-{}", "SECRET".repeat(3));
let input = format!("{{\"token\":\"{secret}\"}}");
let mut log = RunLog::start(
dir.path(),
LogLevel::Debug,
LOG_RETENTION_RUNS,
LOG_MAX_BYTES,
TOOL_RESULT_CAP,
)
.expect("start");
log.event(&LogEvent::ToolCall {
level: LogLevel::Info,
name: "bash",
ok: true,
ms: 5,
input: &input,
})
.expect("event must write");
let contents = fs::read_to_string(&log.path).expect("read log file");
assert!(
!contents.contains(&secret),
"secret leaked in clear: {contents}"
);
assert!(
contents.contains("[REDACTED]"),
"expected redaction marker: {contents}"
);
}
#[test]
fn test_info_level_tool_call_omits_raw_input_and_logs_length_only() {
let dir = tempfile::tempdir().expect("tempdir");
let input = "plain non-secret argument";
let mut log = RunLog::start(
dir.path(),
LogLevel::Info,
LOG_RETENTION_RUNS,
LOG_MAX_BYTES,
TOOL_RESULT_CAP,
)
.expect("start");
log.event(&LogEvent::ToolCall {
level: LogLevel::Info,
name: "ls",
ok: true,
ms: 3,
input,
})
.expect("event must write");
let contents = fs::read_to_string(&log.path).expect("read log file");
assert!(
!contents.contains(input),
"raw input must not appear at info level"
);
assert!(contents.contains("\"input_len\":25"));
assert!(contents.contains("\"name\":\"ls\""));
}
#[test]
fn test_debug_input_redacts_secret_straddling_truncation_boundary() {
use crate::headless::limits::TOOL_RESULT_CAP;
let body = "SECRET".repeat(4); let key = format!("sk-{body}");
const KEPT_BODY_LEN: usize = 12;
let prefix_len = TOOL_RESULT_CAP - "sk-".len() - KEPT_BODY_LEN;
let input = format!("{}{key}", "x".repeat(prefix_len));
assert!(
input.len() > TOOL_RESULT_CAP,
"fixture must straddle the truncation cap"
);
let dir = tempfile::tempdir().expect("tempdir");
let mut log = RunLog::start(
dir.path(),
LogLevel::Debug,
LOG_RETENTION_RUNS,
LOG_MAX_BYTES,
TOOL_RESULT_CAP,
)
.expect("start");
log.event(&LogEvent::ToolCall {
level: LogLevel::Info,
name: "bash",
ok: true,
ms: 5,
input: &input,
})
.expect("event must write");
let contents = fs::read_to_string(&log.path).expect("read log file");
assert!(
!contents.contains(&key),
"full secret leaked in clear: {contents}"
);
let partial_prefix = key
.get(.."sk-".len() + KEPT_BODY_LEN)
.expect("prefix within key bounds");
assert!(
!contents.contains(partial_prefix),
"partial secret prefix leaked in clear (split-secret regression): {contents}"
);
}
#[test]
fn test_message_event_never_carries_a_raw_prompt_field() {
let dir = tempfile::tempdir().expect("tempdir");
let mut log = RunLog::start(
dir.path(),
LogLevel::Debug,
LOG_RETENTION_RUNS,
LOG_MAX_BYTES,
TOOL_RESULT_CAP,
)
.expect("start");
log.event(&LogEvent::Message {
level: LogLevel::Info,
text: "startup notice",
})
.expect("event must write");
let contents = fs::read_to_string(&log.path).expect("read log file");
assert!(contents.contains("startup notice"));
assert!(!contents.contains("\"prompt\""));
}
#[test]
fn test_event_more_verbose_than_configured_level_is_filtered_out() {
let dir = tempfile::tempdir().expect("tempdir");
let mut log = RunLog::start(
dir.path(),
LogLevel::Warn,
LOG_RETENTION_RUNS,
LOG_MAX_BYTES,
TOOL_RESULT_CAP,
)
.expect("start");
log.event(&LogEvent::Message {
level: LogLevel::Debug,
text: "should not appear",
})
.expect("filtered event must still return Ok");
let contents = fs::read_to_string(&log.path).expect("read log file");
assert!(
contents.is_empty(),
"filtered event must not be written: {contents}"
);
}
#[test]
fn test_event_at_or_below_configured_level_is_written() {
let dir = tempfile::tempdir().expect("tempdir");
let mut log = RunLog::start(
dir.path(),
LogLevel::Warn,
LOG_RETENTION_RUNS,
LOG_MAX_BYTES,
TOOL_RESULT_CAP,
)
.expect("start");
log.event(&LogEvent::Message {
level: LogLevel::Warn,
text: "a warning",
})
.expect("event must write");
let contents = fs::read_to_string(&log.path).expect("read log file");
assert!(contents.contains("a warning"));
}
#[test]
fn test_log_level_ordering_from_least_to_most_verbose() {
assert!(LogLevel::Error < LogLevel::Warn);
assert!(LogLevel::Warn < LogLevel::Info);
assert!(LogLevel::Info < LogLevel::Debug);
}
#[test]
fn test_log_level_from_str_parses_all_four_literals() {
assert_eq!("error".parse::<LogLevel>().unwrap(), LogLevel::Error);
assert_eq!("warn".parse::<LogLevel>().unwrap(), LogLevel::Warn);
assert_eq!("info".parse::<LogLevel>().unwrap(), LogLevel::Info);
assert_eq!("debug".parse::<LogLevel>().unwrap(), LogLevel::Debug);
}
#[test]
fn test_log_level_from_str_rejects_unknown_value() {
assert!(matches!(
"verbose".parse::<LogLevel>(),
Err(HeadlessError::InputInvalid(_))
));
assert!(matches!(
"".parse::<LogLevel>(),
Err(HeadlessError::InputInvalid(_))
));
assert!(matches!(
"Debug".parse::<LogLevel>(),
Err(HeadlessError::InputInvalid(_))
));
}
#[test]
fn test_current_epoch_millis_is_nonzero_under_a_normal_clock() {
assert!(current_epoch_millis() > 0);
}
}