use std::fs::OpenOptions;
use std::io::{self, Write};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::Serialize;
use crate::op::Phase;
use crate::verb::Verb;
const LINE_MAX: usize = 4096;
const TRUNC_MARKER: &str = "…[truncated]";
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Level {
Debug,
Info,
Error,
}
impl Level {
#[must_use]
pub fn token(self) -> &'static str {
match self {
Level::Debug => "debug",
Level::Info => "info",
Level::Error => "error",
}
}
pub fn parse(s: &str) -> io::Result<Level> {
match s {
"debug" => Ok(Level::Debug),
"info" => Ok(Level::Info),
"error" => Ok(Level::Error),
_ => Err(io::Error::other(format!("unrecognised log level '{s}' — valid levels are debug|info|error"))),
}
}
}
#[derive(Serialize)]
struct Record<'a> {
ts: i64,
lvl: &'a str,
src: &'a str,
op: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
phase: Option<&'a str>,
msg: &'a str,
}
pub struct Log {
path: PathBuf,
threshold: Level,
op: Verb,
now: fn() -> i64,
}
impl Log {
#[must_use]
pub fn new(path: PathBuf, threshold: Level, op: Verb, now: fn() -> i64) -> Self {
Self { path, threshold, op, now }
}
pub(crate) fn record(&self, lvl: Level, src: &str, phase: Option<Phase>, msg: &str) {
if lvl < self.threshold {
return;
}
let line = self.line(lvl, src, phase, msg);
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&self.path) {
let _ = file.write_all(line.as_bytes());
}
eprint!("{line}");
}
fn line(&self, lvl: Level, src: &str, phase: Option<Phase>, msg: &str) -> String {
let ts = (self.now)();
let mk = |m: &str| {
let record = Record {
ts,
lvl: lvl.token(),
src,
op: self.op.token(),
phase: phase.map(Phase::token),
msg: m,
};
let mut line = serde_json::to_string(&record).expect("a flat record serializes");
line.push('\n');
line
};
let full = mk(msg);
if full.len() <= LINE_MAX {
return full;
}
let mut keep = msg.len();
loop {
while keep > 0 && !msg.is_char_boundary(keep) {
keep -= 1;
}
let line = mk(&format!("{}{TRUNC_MARKER}", &msg[..keep]));
if line.len() <= LINE_MAX || keep == 0 {
return line;
}
keep = keep.saturating_sub(line.len() - LINE_MAX);
}
}
}
#[must_use]
pub fn wall() -> i64 {
#[allow(clippy::cast_possible_wrap)]
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64)
}
#[cfg(test)]
#[path = "log_tests.rs"]
mod tests;