use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
pub const DEFAULT_ROTATE_BYTES: u64 = 5 * 1024 * 1024;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Record {
SessionStart {
timestamp: String,
version: String,
pid: u32,
},
SessionExit {
timestamp: String,
reason: String,
},
Panic {
timestamp: String,
message: String,
thread: String,
location: Option<String>,
expected: bool,
},
}
impl Record {
pub fn session_start(version: &str, pid: u32) -> Self {
Record::SessionStart {
timestamp: now_iso8601(),
version: version.to_string(),
pid,
}
}
pub fn session_exit(reason: impl Into<String>) -> Self {
Record::SessionExit {
timestamp: now_iso8601(),
reason: reason.into(),
}
}
fn panic(message: String, thread: String, location: Option<String>) -> Self {
Record::Panic {
timestamp: now_iso8601(),
message,
thread,
location,
expected: false,
}
}
}
#[derive(Debug, Clone)]
pub struct Journal {
path: PathBuf,
rotate_bytes: u64,
}
impl Journal {
pub fn from_env() -> Self {
let path = std::env::var_os("FERROX_JOURNAL_PATH")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("ferrox-journal.log"));
Self {
path,
rotate_bytes: DEFAULT_ROTATE_BYTES,
}
}
#[allow(dead_code)]
pub fn at_path(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
rotate_bytes: DEFAULT_ROTATE_BYTES,
}
}
#[allow(dead_code)]
pub fn with_rotate_threshold(mut self, bytes: u64) -> Self {
self.rotate_bytes = bytes;
self
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn append(&self, record: &Record) {
if let Err(e) = self.try_append(record) {
tracing::warn!("journal write to {:?} failed: {e}", self.path);
}
}
fn try_append(&self, record: &Record) -> std::io::Result<()> {
self.rotate_if_needed()?;
let mut line = serde_json::to_string(record)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
line.push('\n');
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
file.write_all(line.as_bytes())?;
file.flush()
}
fn rotate_if_needed(&self) -> std::io::Result<()> {
let len = match std::fs::metadata(&self.path) {
Ok(meta) => meta.len(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e),
};
if len <= self.rotate_bytes {
return Ok(());
}
let mut rotated = self.path.clone();
let rotated_name = match self.path.file_name() {
Some(name) => format!("{}.1", name.to_string_lossy()),
None => "ferrox-journal.log.1".to_string(),
};
rotated.set_file_name(rotated_name);
std::fs::rename(&self.path, &rotated)
}
}
pub fn install_panic_hook(journal: Journal) {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let message = panic_message(info);
let thread = std::thread::current()
.name()
.unwrap_or("<unnamed>")
.to_string();
let location = info
.location()
.map(|l| format!("{}:{}", l.file(), l.line()));
journal.append(&Record::panic(message, thread, location));
previous(info);
}));
}
fn panic_message(info: &std::panic::PanicHookInfo<'_>) -> String {
if let Some(s) = info.payload().downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = info.payload().downcast_ref::<String>() {
s.clone()
} else {
"<non-string panic payload>".to_string()
}
}
fn now_iso8601() -> String {
let dur = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
format_iso8601(dur.as_secs(), dur.subsec_millis())
}
fn format_iso8601(total_secs: u64, millis: u32) -> String {
let days = (total_secs / 86_400) as i64;
let secs_of_day = total_secs % 86_400;
let hour = secs_of_day / 3600;
let min = (secs_of_day % 3600) / 60;
let sec = secs_of_day % 60;
let (year, month, day) = civil_from_days(days);
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}.{millis:03}Z")
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; let year = if m <= 2 { y + 1 } else { y };
(year, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::BufRead;
fn read_lines(path: &Path) -> Vec<String> {
let file = std::fs::File::open(path).expect("journal file must exist");
std::io::BufReader::new(file)
.lines()
.map(|l| l.expect("valid utf8 line"))
.collect()
}
#[test]
fn civil_from_days_matches_known_dates() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
assert_eq!(civil_from_days(10_957), (2000, 1, 1));
assert_eq!(civil_from_days(19_782), (2024, 2, 29));
}
#[test]
fn now_iso8601_has_the_expected_shape() {
let ts = now_iso8601();
assert_eq!(ts.len(), 24, "unexpected timestamp shape: {ts}");
assert!(ts.ends_with('Z'));
assert_eq!(ts.as_bytes()[4], b'-');
assert_eq!(ts.as_bytes()[10], b'T');
}
#[test]
fn session_start_and_exit_round_trip_as_valid_json_lines() {
let dir = std::env::temp_dir().join(format!(
"ferrox-journal-test-roundtrip-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("journal.log");
let _ = std::fs::remove_file(&path);
let journal = Journal::at_path(&path);
journal.append(&Record::session_start(env!("CARGO_PKG_VERSION"), 4242));
journal.append(&Record::session_exit("normal"));
let lines = read_lines(&path);
assert_eq!(lines.len(), 2);
let start: Record = serde_json::from_str(&lines[0]).expect("valid JSON line");
match &start {
Record::SessionStart { version, pid, .. } => {
assert_eq!(version, env!("CARGO_PKG_VERSION"));
assert_eq!(*pid, 4242);
}
other => panic!("expected SessionStart, got {other:?}"),
}
let exit: Record = serde_json::from_str(&lines[1]).expect("valid JSON line");
match &exit {
Record::SessionExit { reason, .. } => assert_eq!(reason, "normal"),
other => panic!("expected SessionExit, got {other:?}"),
}
let raw: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
assert_eq!(raw["type"], "session_start");
assert!(raw["timestamp"].is_string());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn error_exit_reason_round_trips() {
let dir = std::env::temp_dir().join(format!(
"ferrox-journal-test-error-exit-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("journal.log");
let _ = std::fs::remove_file(&path);
let journal = Journal::at_path(&path);
journal.append(&Record::session_exit("bind error: address in use"));
let lines = read_lines(&path);
let exit: Record = serde_json::from_str(&lines[0]).unwrap();
match exit {
Record::SessionExit { reason, .. } => {
assert_eq!(reason, "bind error: address in use")
}
other => panic!("expected SessionExit, got {other:?}"),
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn rotation_moves_oversized_file_to_dot_1_and_keeps_only_one_predecessor() {
let dir = std::env::temp_dir().join(format!(
"ferrox-journal-test-rotation-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("journal.log");
let rotated = dir.join("journal.log.1");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&rotated);
let journal = Journal::at_path(&path).with_rotate_threshold(50);
journal.append(&Record::session_start("0.0.0-test", 1));
assert!(
!rotated.exists(),
"must not rotate before exceeding threshold"
);
journal.append(&Record::session_exit("marker-before-rotation"));
for i in 0..20 {
journal.append(&Record::session_exit(format!("filler-{i}")));
}
assert!(rotated.exists(), "rotated predecessor file must exist");
let before_second_rotation_len = std::fs::metadata(&rotated).unwrap().len();
for i in 0..20 {
journal.append(&Record::session_exit(format!("more-filler-{i}")));
}
assert!(
!dir.join("journal.log.2").exists(),
"must never keep more than one retained predecessor"
);
let after_second_rotation_len = std::fs::metadata(&rotated).unwrap().len();
assert!(after_second_rotation_len > 0);
let _ = before_second_rotation_len;
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn panic_hook_writes_a_panic_record_with_message_thread_and_location() {
let dir =
std::env::temp_dir().join(format!("ferrox-journal-test-panic-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("journal.log");
let _ = std::fs::remove_file(&path);
let prior_hook = std::panic::take_hook();
let journal = Journal::at_path(&path);
install_panic_hook(journal.clone());
let result = std::panic::catch_unwind(|| {
std::thread::Builder::new()
.name("journal-test-thread".to_string())
.spawn(|| {
panic!("deliberate test panic for the journal");
})
.unwrap()
.join()
});
std::panic::set_hook(prior_hook);
assert!(result.is_ok(), "catch_unwind itself must not propagate");
assert!(
result.unwrap().is_err(),
"the spawned thread must have actually panicked"
);
let lines = read_lines(&path);
assert_eq!(lines.len(), 1, "exactly one panic record expected");
let record: Record = serde_json::from_str(&lines[0]).unwrap();
match record {
Record::Panic {
message,
thread,
location,
expected,
..
} => {
assert_eq!(message, "deliberate test panic for the journal");
assert_eq!(thread, "journal-test-thread");
assert!(location.is_some(), "location must be captured");
assert!(
location.unwrap().contains("journal.rs"),
"location should point at this file"
);
assert!(!expected, "expected must default to false");
}
other => panic!("expected Panic, got {other:?}"),
}
std::fs::remove_dir_all(&dir).ok();
}
}