cardinal-app 0.1.4

Thin CLI entrypoint for Cardinal.
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\""));
    }
}