use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ValidationError {
#[error("required field '{field}' is empty")]
RequiredFieldEmpty { field: &'static str },
#[error("invalid level '{value}'; expected one of: trace, debug, info, warn, error")]
InvalidLevel { value: String },
#[error("unsupported schema version {version}; expected 1")]
UnsupportedVersion { version: u8 },
#[error("event exceeds maximum serialized size of 65536 bytes ({size} bytes)")]
EventTooLarge { size: usize },
#[error("span[{index}] missing required field '{field}'")]
SpanRequiredFieldEmpty { index: usize, field: &'static str },
#[error("invalid span chain at index {index}: {reason}")]
InvalidSpanChain { index: usize, reason: String },
}
pub const MAX_EVENT_BYTES: usize = 64 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SpanRefV1 {
pub name: String,
pub trace_id: String,
pub span_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_span_id: Option<String>,
#[serde(default)]
pub fields: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LogEventV1 {
pub v: u8,
pub ts: String,
pub level: String,
pub source_binary: String,
pub hostname: String,
pub pid: u32,
pub target: String,
pub action: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub team: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub runtime: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trace_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub span_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subagent_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub correlation_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub outcome: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default)]
pub fields: serde_json::Map<String, serde_json::Value>,
#[serde(default)]
pub spans: Vec<SpanRefV1>,
}
impl LogEventV1 {
pub fn builder(
source_binary: impl Into<String>,
action: impl Into<String>,
target: impl Into<String>,
) -> LogEventV1Builder {
LogEventV1Builder::new(source_binary.into(), action.into(), target.into())
}
pub fn validate(&self) -> Result<(), ValidationError> {
if self.v != 1 {
return Err(ValidationError::UnsupportedVersion { version: self.v });
}
let required = [
("ts", self.ts.as_str()),
("level", self.level.as_str()),
("source_binary", self.source_binary.as_str()),
("hostname", self.hostname.as_str()),
("target", self.target.as_str()),
("action", self.action.as_str()),
];
for (field, value) in required {
if value.is_empty() {
return Err(ValidationError::RequiredFieldEmpty { field });
}
}
let level_lower = self.level.to_lowercase();
if !matches!(
level_lower.as_str(),
"trace" | "debug" | "info" | "warn" | "error"
) {
return Err(ValidationError::InvalidLevel {
value: self.level.clone(),
});
}
let serialized = serde_json::to_string(self).unwrap_or_default();
let size = serialized.len();
if size > MAX_EVENT_BYTES {
return Err(ValidationError::EventTooLarge { size });
}
self.validate_spans()?;
Ok(())
}
pub fn validate_spans(&self) -> Result<(), ValidationError> {
if self.spans.is_empty() {
return Ok(());
}
let root_trace_id = self.spans[0].trace_id.as_str();
if root_trace_id.trim().is_empty() {
return Err(ValidationError::SpanRequiredFieldEmpty {
index: 0,
field: "trace_id",
});
}
if self.spans[0].span_id.trim().is_empty() {
return Err(ValidationError::SpanRequiredFieldEmpty {
index: 0,
field: "span_id",
});
}
if self.spans[0].parent_span_id.is_some() {
return Err(ValidationError::InvalidSpanChain {
index: 0,
reason: "root span must not declare parent_span_id".to_string(),
});
}
for (idx, span) in self.spans.iter().enumerate().skip(1) {
if span.trace_id.trim().is_empty() {
return Err(ValidationError::SpanRequiredFieldEmpty {
index: idx,
field: "trace_id",
});
}
if span.span_id.trim().is_empty() {
return Err(ValidationError::SpanRequiredFieldEmpty {
index: idx,
field: "span_id",
});
}
if span.trace_id != root_trace_id {
return Err(ValidationError::InvalidSpanChain {
index: idx,
reason: "span trace_id must match root trace_id".to_string(),
});
}
let expected_parent = &self.spans[idx - 1].span_id;
match span.parent_span_id.as_deref() {
Some(parent) if parent == expected_parent => {}
Some(_) => {
return Err(ValidationError::InvalidSpanChain {
index: idx,
reason: "parent_span_id must match previous span_id".to_string(),
});
}
None => {
return Err(ValidationError::InvalidSpanChain {
index: idx,
reason: "non-root span must declare parent_span_id".to_string(),
});
}
}
}
Ok(())
}
pub fn redact(&mut self) {
redact_map(&mut self.fields);
for span in &mut self.spans {
redact_map(&mut span.fields);
}
}
}
const DENYLIST_KEYS: &[&str] = &["password", "secret", "token", "api_key", "auth"];
fn is_denylist_key(key: &str) -> bool {
let lower = key.to_lowercase();
DENYLIST_KEYS.iter().any(|&k| lower == k)
}
fn is_bearer_token(value: &str) -> bool {
if let Some(rest) = value
.strip_prefix("Bearer ")
.or_else(|| value.strip_prefix("bearer "))
{
!rest.trim().is_empty()
} else {
false
}
}
fn redact_map(map: &mut serde_json::Map<String, serde_json::Value>) {
for (key, value) in map.iter_mut() {
let should_redact =
is_denylist_key(key) || value.as_str().map(is_bearer_token).unwrap_or(false);
if should_redact {
*value = serde_json::Value::String("[REDACTED]".to_string());
}
}
}
pub struct LogEventV1Builder {
source_binary: String,
action: String,
target: String,
level: String,
team: Option<String>,
agent: Option<String>,
runtime: Option<String>,
session_id: Option<String>,
trace_id: Option<String>,
span_id: Option<String>,
subagent_id: Option<String>,
request_id: Option<String>,
correlation_id: Option<String>,
outcome: Option<String>,
error: Option<String>,
fields: serde_json::Map<String, serde_json::Value>,
spans: Vec<SpanRefV1>,
}
impl LogEventV1Builder {
fn new(source_binary: String, action: String, target: String) -> Self {
Self {
source_binary,
action,
target,
level: "info".to_string(),
team: None,
agent: None,
runtime: None,
session_id: None,
trace_id: None,
span_id: None,
subagent_id: None,
request_id: None,
correlation_id: None,
outcome: None,
error: None,
fields: serde_json::Map::new(),
spans: Vec::new(),
}
}
pub fn level(mut self, level: impl Into<String>) -> Self {
self.level = level.into();
self
}
pub fn team(mut self, team: impl Into<String>) -> Self {
self.team = Some(team.into());
self
}
pub fn agent(mut self, agent: impl Into<String>) -> Self {
self.agent = Some(agent.into());
self
}
pub fn runtime(mut self, runtime: impl Into<String>) -> Self {
self.runtime = Some(runtime.into());
self
}
pub fn session_id(mut self, session_id: impl Into<String>) -> Self {
self.session_id = Some(session_id.into());
self
}
pub fn trace_id(mut self, trace_id: impl Into<String>) -> Self {
self.trace_id = Some(trace_id.into());
self
}
pub fn span_id(mut self, span_id: impl Into<String>) -> Self {
self.span_id = Some(span_id.into());
self
}
pub fn subagent_id(mut self, subagent_id: impl Into<String>) -> Self {
self.subagent_id = Some(subagent_id.into());
self
}
pub fn request_id(mut self, request_id: impl Into<String>) -> Self {
self.request_id = Some(request_id.into());
self
}
pub fn correlation_id(mut self, correlation_id: impl Into<String>) -> Self {
self.correlation_id = Some(correlation_id.into());
self
}
pub fn outcome(mut self, outcome: impl Into<String>) -> Self {
self.outcome = Some(outcome.into());
self
}
pub fn error(mut self, error: impl Into<String>) -> Self {
self.error = Some(error.into());
self
}
pub fn field(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
self.fields.insert(key.into(), value);
self
}
pub fn span(mut self, span: SpanRefV1) -> Self {
self.spans.push(span);
self
}
pub fn build(self) -> LogEventV1 {
let ts = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let hostname = hostname::get()
.map(|h| h.to_string_lossy().to_string())
.unwrap_or_default();
let pid = std::process::id();
let mut fields = self.fields;
if let Some(ppid) = crate::pid::parent_pid() {
fields
.entry("ppid".to_string())
.or_insert_with(|| serde_json::Value::Number(ppid.into()));
}
LogEventV1 {
v: 1,
ts,
level: self.level,
source_binary: self.source_binary,
hostname,
pid,
target: self.target,
action: self.action,
team: self.team,
agent: self.agent,
runtime: self.runtime,
session_id: self.session_id,
trace_id: self.trace_id,
span_id: self.span_id,
subagent_id: self.subagent_id,
request_id: self.request_id,
correlation_id: self.correlation_id,
outcome: self.outcome,
error: self.error,
fields,
spans: self.spans,
}
}
}
pub fn new_log_event(source_binary: &str, action: &str, target: &str, level: &str) -> LogEventV1 {
LogEventV1::builder(source_binary, action, target)
.level(level)
.build()
}
pub fn configured_log_path_for_tool(home_dir: &Path, tool: &str) -> PathBuf {
std::env::var("ATM_LOG_FILE")
.or_else(|_| std::env::var("ATM_LOG_PATH"))
.ok()
.filter(|v| !v.trim().is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| {
let tool = sanitize_tool_name(tool);
home_dir
.join(".config")
.join("atm")
.join("logs")
.join(&tool)
.join(format!("{tool}.log.jsonl"))
})
}
pub fn configured_log_path(home_dir: &Path) -> PathBuf {
configured_log_path_for_tool(home_dir, "atm")
}
pub fn spool_dir_from_log_path(log_path: &Path) -> PathBuf {
let parent = log_path.parent().unwrap_or_else(|| Path::new("."));
parent.join("spool")
}
pub fn configured_spool_dir_for_tool(home_dir: &Path, tool: &str) -> PathBuf {
spool_dir_from_log_path(&configured_log_path_for_tool(home_dir, tool))
}
pub fn configured_spool_dir(home_dir: &Path) -> PathBuf {
configured_spool_dir_for_tool(home_dir, "atm")
}
pub fn spool_dir_for_tool(home_dir: &Path, tool: &str) -> PathBuf {
if std::env::var("ATM_LOG_FILE")
.or_else(|_| std::env::var("ATM_LOG_PATH"))
.ok()
.is_some_and(|v| !v.trim().is_empty())
{
return spool_dir_from_log_path(&configured_log_path_for_tool(home_dir, tool));
}
let tool = sanitize_tool_name(tool);
home_dir
.join(".config")
.join("atm")
.join("logs")
.join(tool)
.join("spool")
}
pub fn spool_dir(home_dir: &Path) -> PathBuf {
spool_dir_for_tool(home_dir, "atm")
}
fn sanitize_tool_name(tool: &str) -> String {
let trimmed = tool.trim();
if trimmed.is_empty() {
return "atm".to_string();
}
let mut out = String::with_capacity(trimmed.len());
for ch in trimmed.chars() {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
out.push(ch);
} else {
out.push('_');
}
}
if out.is_empty() {
"atm".to_string()
} else {
out
}
}
pub fn default_spool_dir() -> anyhow::Result<PathBuf> {
let home = crate::home::get_home_dir()?;
Ok(configured_spool_dir_for_tool(&home, "atm"))
}
pub fn write_to_spool_dir(event: &LogEventV1, dir: &Path) {
use std::fs::{OpenOptions, create_dir_all};
use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};
let _ = create_dir_all(dir);
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let filename = format!("{}-{}-{}.jsonl", event.source_binary, event.pid, millis);
let path = dir.join(filename);
let Ok(line) = serde_json::to_string(event) else {
return;
};
let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&path) else {
return;
};
let _ = writeln!(file, "{line}");
}
pub fn write_to_spool(event: &LogEventV1, home_dir: &Path) {
use std::fs::{OpenOptions, create_dir_all};
use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};
let dir = spool_dir_for_tool(home_dir, &event.source_binary);
let _ = create_dir_all(&dir);
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let filename = format!("{}-{}-{}.jsonl", event.source_binary, event.pid, millis);
let path = dir.join(filename);
let Ok(line) = serde_json::to_string(event) else {
return;
};
let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&path) else {
return;
};
let _ = writeln!(file, "{line}");
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use tempfile::TempDir;
fn make_valid_event() -> LogEventV1 {
new_log_event("atm", "test_action", "atm::test_module", "info")
}
#[test]
fn test_serialization_round_trip() {
let event = LogEventV1::builder("atm-daemon", "daemon_start", "atm_daemon::main")
.level("info")
.team("atm-dev")
.agent("team-lead")
.runtime("claude")
.session_id("sess-abc-123")
.trace_id("trace-123")
.span_id("span-456")
.subagent_id("subagent-789")
.outcome("ok")
.field("iteration", serde_json::Value::Number(42.into()))
.span(SpanRefV1 {
name: "daemon_dispatch".to_string(),
trace_id: "trace-1".to_string(),
span_id: "span-root".to_string(),
parent_span_id: None,
fields: {
let mut m = serde_json::Map::new();
m.insert(
"team".to_string(),
serde_json::Value::String("atm-dev".to_string()),
);
m
},
})
.build();
let json = serde_json::to_string(&event).expect("serialize");
let deserialized: LogEventV1 = serde_json::from_str(&json).expect("deserialize");
assert_eq!(deserialized.v, 1);
assert_eq!(deserialized.source_binary, "atm-daemon");
assert_eq!(deserialized.action, "daemon_start");
assert_eq!(deserialized.target, "atm_daemon::main");
assert_eq!(deserialized.level, "info");
assert_eq!(deserialized.team.as_deref(), Some("atm-dev"));
assert_eq!(deserialized.agent.as_deref(), Some("team-lead"));
assert_eq!(deserialized.runtime.as_deref(), Some("claude"));
assert_eq!(deserialized.session_id.as_deref(), Some("sess-abc-123"));
assert_eq!(deserialized.trace_id.as_deref(), Some("trace-123"));
assert_eq!(deserialized.span_id.as_deref(), Some("span-456"));
assert_eq!(deserialized.subagent_id.as_deref(), Some("subagent-789"));
assert_eq!(deserialized.outcome.as_deref(), Some("ok"));
assert_eq!(deserialized.spans.len(), 1);
assert_eq!(deserialized.spans[0].name, "daemon_dispatch");
assert_eq!(
deserialized.fields.get("iteration"),
event.fields.get("iteration")
);
}
#[test]
fn test_validation_missing_required() {
let mut event = make_valid_event();
event.action = String::new();
let result = event.validate();
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
ValidationError::RequiredFieldEmpty { field: "action" }
));
}
#[test]
fn test_validation_missing_ts() {
let mut event = make_valid_event();
event.ts = String::new();
assert!(matches!(
event.validate().unwrap_err(),
ValidationError::RequiredFieldEmpty { field: "ts" }
));
}
#[test]
fn test_validation_missing_source_binary() {
let mut event = make_valid_event();
event.source_binary = String::new();
assert!(matches!(
event.validate().unwrap_err(),
ValidationError::RequiredFieldEmpty {
field: "source_binary"
}
));
}
#[test]
fn test_validation_invalid_level() {
let mut event = make_valid_event();
event.level = "verbose".to_string();
let result = event.validate();
assert!(matches!(
result.unwrap_err(),
ValidationError::InvalidLevel { .. }
));
}
#[test]
fn test_validation_level_uppercase_ok() {
let mut event = make_valid_event();
event.level = "INFO".to_string();
assert!(event.validate().is_ok(), "uppercase level should be valid");
}
#[test]
fn test_validation_level_mixed_case_ok() {
let mut event = make_valid_event();
event.level = "Warn".to_string();
assert!(event.validate().is_ok());
}
#[test]
fn test_validation_unsupported_version() {
let mut event = make_valid_event();
event.v = 2;
assert!(matches!(
event.validate().unwrap_err(),
ValidationError::UnsupportedVersion { version: 2 }
));
}
#[test]
fn test_size_guard() {
let mut event = make_valid_event();
let big_value = "x".repeat(70 * 1024);
event.fields.insert(
"big_field".to_string(),
serde_json::Value::String(big_value),
);
let result = event.validate();
assert!(
matches!(result.unwrap_err(), ValidationError::EventTooLarge { .. }),
"event with 70 KiB field should fail the size guard"
);
}
#[test]
fn test_redaction_denylist_keys() {
let mut event = make_valid_event();
event.fields.insert(
"password".to_string(),
serde_json::Value::String("secret123".to_string()),
);
event.fields.insert(
"normal_key".to_string(),
serde_json::Value::String("keep_me".to_string()),
);
event.redact();
assert_eq!(
event.fields.get("password").and_then(|v| v.as_str()),
Some("[REDACTED]"),
"password field should be redacted"
);
assert_eq!(
event.fields.get("normal_key").and_then(|v| v.as_str()),
Some("keep_me"),
"non-sensitive field should be preserved"
);
}
#[test]
fn test_redaction_denylist_all_keys() {
for key in DENYLIST_KEYS {
let mut event = make_valid_event();
event.fields.insert(
key.to_string(),
serde_json::Value::String("sensitive_value".to_string()),
);
event.redact();
assert_eq!(
event.fields.get(*key).and_then(|v| v.as_str()),
Some("[REDACTED]"),
"key '{key}' should be redacted"
);
}
}
#[test]
fn test_redaction_bearer_token() {
let mut event = make_valid_event();
event.fields.insert(
"auth_header".to_string(),
serde_json::Value::String("Bearer eyJhbGciOiJSUzI1NiJ9.payload.sig".to_string()),
);
event.fields.insert(
"lowercase_bearer".to_string(),
serde_json::Value::String("bearer some-token-here".to_string()),
);
event.fields.insert(
"not_bearer".to_string(),
serde_json::Value::String("Basic dXNlcjpwYXNz".to_string()),
);
event.redact();
assert_eq!(
event.fields.get("auth_header").and_then(|v| v.as_str()),
Some("[REDACTED]"),
"Bearer token should be redacted"
);
assert_eq!(
event
.fields
.get("lowercase_bearer")
.and_then(|v| v.as_str()),
Some("[REDACTED]"),
"lowercase bearer token should be redacted"
);
assert_eq!(
event.fields.get("not_bearer").and_then(|v| v.as_str()),
Some("Basic dXNlcjpwYXNz"),
"Basic auth should not be redacted by bearer pattern"
);
}
#[test]
fn test_redaction_span_fields() {
let mut event = make_valid_event();
let mut span_fields = serde_json::Map::new();
span_fields.insert(
"token".to_string(),
serde_json::Value::String("tok_secret_value".to_string()),
);
span_fields.insert(
"safe_field".to_string(),
serde_json::Value::String("visible".to_string()),
);
event.spans.push(SpanRefV1 {
name: "some_span".to_string(),
trace_id: "trace-1".to_string(),
span_id: "span-1".to_string(),
parent_span_id: None,
fields: span_fields,
});
event.redact();
assert_eq!(
event.spans[0].fields.get("token").and_then(|v| v.as_str()),
Some("[REDACTED]"),
"span token field should be redacted"
);
assert_eq!(
event.spans[0]
.fields
.get("safe_field")
.and_then(|v| v.as_str()),
Some("visible"),
"safe span field should be preserved"
);
}
#[test]
fn test_new_log_event_convenience() {
let event = new_log_event("atm-tui", "tui_start", "atm_tui::main", "debug");
assert_eq!(event.v, 1);
assert_eq!(event.source_binary, "atm-tui");
assert_eq!(event.action, "tui_start");
assert_eq!(event.target, "atm_tui::main");
assert_eq!(event.level, "debug");
assert!(!event.ts.is_empty());
assert!(!event.hostname.is_empty());
assert!(event.pid > 0);
}
#[cfg(unix)]
#[test]
fn test_new_log_event_includes_parent_pid_field_on_unix() {
let event = new_log_event("atm", "send_message", "atm::send", "info");
assert!(
event.fields.get("ppid").is_some(),
"new_log_event should include ppid field on unix"
);
}
#[test]
fn test_builder_preserves_explicit_ppid_field() {
let event = LogEventV1::builder("atm", "action", "target")
.field("ppid", serde_json::Value::Number(999u64.into()))
.build();
assert_eq!(
event.fields.get("ppid"),
Some(&serde_json::Value::Number(999u64.into()))
);
}
#[test]
fn test_builder_optional_fields() {
let event = LogEventV1::builder("atm", "action", "target")
.team("my-team")
.agent("my-agent")
.session_id("sess-1")
.request_id("req-1")
.correlation_id("corr-1")
.outcome("ok")
.error("none")
.build();
assert_eq!(event.team.as_deref(), Some("my-team"));
assert_eq!(event.agent.as_deref(), Some("my-agent"));
assert_eq!(event.session_id.as_deref(), Some("sess-1"));
assert_eq!(event.request_id.as_deref(), Some("req-1"));
assert_eq!(event.correlation_id.as_deref(), Some("corr-1"));
assert_eq!(event.outcome.as_deref(), Some("ok"));
assert_eq!(event.error.as_deref(), Some("none"));
}
#[test]
fn test_option_fields_skip_when_none() {
let event = make_valid_event();
let json = serde_json::to_string(&event).expect("serialize");
assert!(
!json.contains("\"team\""),
"team should be absent when None"
);
assert!(
!json.contains("\"agent\""),
"agent should be absent when None"
);
assert!(
!json.contains("\"session_id\""),
"session_id should be absent when None"
);
}
#[test]
fn test_spool_write() {
let dir = TempDir::new().expect("temp dir");
let event = make_valid_event();
write_to_spool(&event, dir.path());
let spool = spool_dir_for_tool(dir.path(), "atm");
let entries: Vec<_> = std::fs::read_dir(&spool)
.expect("read spool dir")
.flatten()
.collect();
assert_eq!(entries.len(), 1, "expected one spool file");
let content = std::fs::read_to_string(entries[0].path()).expect("read spool file");
let deserialized: LogEventV1 =
serde_json::from_str(content.trim()).expect("parse spool JSONL");
assert_eq!(deserialized.action, event.action);
}
#[test]
fn test_spool_dir_path() {
let home = TempDir::new().expect("temp dir");
let expected = home.path().join(".config/atm/logs/atm/spool");
assert_eq!(spool_dir(home.path()), expected);
}
#[test]
fn test_configured_log_path_defaults_to_tool_scoped_formula() {
let home = TempDir::new().expect("temp dir");
let path = configured_log_path_for_tool(home.path(), "atm-daemon");
assert_eq!(
path,
home.path()
.join(".config/atm/logs/atm-daemon/atm-daemon.log.jsonl")
);
}
#[test]
#[serial]
fn test_configured_spool_dir_tracks_atm_log_file_parent() {
let home = TempDir::new().expect("temp dir");
let custom_log = home.path().join("custom/logs/custom.log");
unsafe {
std::env::set_var("ATM_LOG_FILE", &custom_log);
std::env::remove_var("ATM_LOG_PATH");
}
let spool = configured_spool_dir(home.path());
assert_eq!(spool, home.path().join("custom/logs/spool"));
unsafe {
std::env::remove_var("ATM_LOG_FILE");
}
}
#[test]
#[serial]
fn test_configured_log_path_for_tool_default_profile() {
let home = TempDir::new().expect("temp dir");
unsafe {
std::env::remove_var("ATM_LOG_FILE");
std::env::remove_var("ATM_LOG_PATH");
}
let path = configured_log_path_for_tool(home.path(), "atm-daemon");
assert_eq!(
path,
home.path()
.join(".config/atm/logs/atm-daemon/atm-daemon.log.jsonl")
);
}
#[test]
#[serial]
fn test_spool_dir_for_tool_default_profile() {
let home = TempDir::new().expect("temp dir");
unsafe {
std::env::remove_var("ATM_LOG_FILE");
std::env::remove_var("ATM_LOG_PATH");
}
let path = spool_dir_for_tool(home.path(), "atm-daemon");
assert_eq!(path, home.path().join(".config/atm/logs/atm-daemon/spool"));
}
#[test]
fn test_validate_spans_accepts_root_to_leaf_chain() {
let event = LogEventV1::builder("atm", "send", "atm::send")
.span(SpanRefV1 {
name: "root".to_string(),
trace_id: "trace-a".to_string(),
span_id: "span-root".to_string(),
parent_span_id: None,
fields: serde_json::Map::new(),
})
.span(SpanRefV1 {
name: "leaf".to_string(),
trace_id: "trace-a".to_string(),
span_id: "span-leaf".to_string(),
parent_span_id: Some("span-root".to_string()),
fields: serde_json::Map::new(),
})
.build();
assert!(event.validate_spans().is_ok());
}
#[test]
fn test_validate_spans_rejects_trace_mismatch() {
let event = LogEventV1::builder("atm", "send", "atm::send")
.span(SpanRefV1 {
name: "root".to_string(),
trace_id: "trace-a".to_string(),
span_id: "span-root".to_string(),
parent_span_id: None,
fields: serde_json::Map::new(),
})
.span(SpanRefV1 {
name: "leaf".to_string(),
trace_id: "trace-b".to_string(),
span_id: "span-leaf".to_string(),
parent_span_id: Some("span-root".to_string()),
fields: serde_json::Map::new(),
})
.build();
assert!(matches!(
event.validate_spans().unwrap_err(),
ValidationError::InvalidSpanChain { index: 1, .. }
));
}
#[test]
fn test_validate_spans_rejects_broken_parent_link() {
let event = LogEventV1::builder("atm", "send", "atm::send")
.span(SpanRefV1 {
name: "root".to_string(),
trace_id: "trace-a".to_string(),
span_id: "span-root".to_string(),
parent_span_id: None,
fields: serde_json::Map::new(),
})
.span(SpanRefV1 {
name: "leaf".to_string(),
trace_id: "trace-a".to_string(),
span_id: "span-leaf".to_string(),
parent_span_id: Some("span-other".to_string()),
fields: serde_json::Map::new(),
})
.build();
assert!(matches!(
event.validate_spans().unwrap_err(),
ValidationError::InvalidSpanChain { index: 1, .. }
));
}
#[test]
fn test_valid_event_passes_validation() {
let event = make_valid_event();
assert!(event.validate().is_ok());
}
#[test]
fn test_redaction_case_insensitive_key() {
let mut event = make_valid_event();
event.fields.insert(
"Password".to_string(),
serde_json::Value::String("hunter2".to_string()),
);
event.redact();
assert_eq!(
event.fields.get("Password").and_then(|v| v.as_str()),
Some("[REDACTED]")
);
}
}