use std::fs::{self, OpenOptions};
use std::io::{self, Write};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug)]
pub struct StructuredLogger {
sink: Option<std::fs::File>,
stderr_enabled: bool,
}
impl StructuredLogger {
pub fn from_env() -> io::Result<Self> {
let sink = std::env::var_os("CARDINAL_LOG_PATH")
.map(PathBuf::from)
.map(open_log_sink)
.transpose()?;
let stderr_enabled = std::env::var("CARDINAL_LOG_STDERR")
.ok()
.map(|value| !matches!(value.trim(), "0" | "false" | "FALSE"))
.unwrap_or(true);
Ok(Self {
sink,
stderr_enabled,
})
}
pub fn event(&mut self, level: &str, event: &str, fields: &[(&str, String)]) {
let line = format_event_line(level, event, fields);
if self.stderr_enabled {
let _ = writeln!(std::io::stderr(), "{line}");
}
if let Some(sink) = self.sink.as_mut() {
let _ = writeln!(sink, "{line}");
let _ = sink.flush();
}
}
}
fn open_log_sink(path: PathBuf) -> io::Result<std::fs::File> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
OpenOptions::new().create(true).append(true).open(path)
}
fn format_event_line(level: &str, event: &str, fields: &[(&str, String)]) -> String {
let timestamp_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0);
let mut parts = Vec::with_capacity(3 + fields.len());
parts.push(format!("\"ts_ms\":{timestamp_ms}"));
parts.push(format!("\"level\":\"{}\"", escape_json(level)));
parts.push(format!("\"event\":\"{}\"", escape_json(event)));
for (key, value) in fields {
parts.push(format!(
"\"{}\":\"{}\"",
escape_json(key),
escape_json(value)
));
}
format!("{{{}}}", parts.join(","))
}
fn escape_json(value: &str) -> String {
let mut output = String::with_capacity(value.len());
for character in value.chars() {
match character {
'\\' => output.push_str("\\\\"),
'"' => output.push_str("\\\""),
'\n' => output.push_str("\\n"),
'\r' => output.push_str("\\r"),
'\t' => output.push_str("\\t"),
control if control.is_control() => output.push('?'),
other => output.push(other),
}
}
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_json_line_with_fields() {
let line = format_event_line(
"info",
"startup",
&[("mode", "tui".to_owned()), ("result", "ok".to_owned())],
);
assert!(line.contains("\"level\":\"info\""));
assert!(line.contains("\"event\":\"startup\""));
assert!(line.contains("\"mode\":\"tui\""));
assert!(line.contains("\"result\":\"ok\""));
}
}