use std::fs::OpenOptions;
use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::paths;
const JOURNAL_FILE: &str = "journal.jsonl";
const MAX_BYTES: u64 = 1024 * 1024;
#[derive(Serialize, Deserialize)]
pub struct Entry {
pub at: String,
pub action: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub app: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
pub fn record(action: &str, app: Option<&str>, server: Option<&str>, detail: Option<&str>) {
let entry = Entry {
at: now_iso8601(),
action: action.to_string(),
app: app.map(str::to_string),
server: server.map(str::to_string),
detail: detail.map(str::to_string),
};
let _ = append(&entry);
}
fn append(entry: &Entry) -> anyhow::Result<()> {
let dir = paths::data_dir()?;
if !dir.is_dir() {
return Ok(());
}
let path = dir.join(JOURNAL_FILE);
if path.metadata().map(|m| m.len() >= MAX_BYTES).unwrap_or(false) {
let _ = std::fs::rename(&path, dir.join(format!("{JOURNAL_FILE}.1")));
}
let mut line = serde_json::to_string(entry)?;
line.push('\n');
let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
file.write_all(line.as_bytes())?;
Ok(())
}
pub fn tail(limit: usize) -> Vec<Entry> {
let Ok(dir) = paths::data_dir() else {
return Vec::new();
};
let Ok(text) = std::fs::read_to_string(dir.join(JOURNAL_FILE)) else {
return Vec::new();
};
let mut entries: Vec<Entry> = text.lines().filter_map(|line| serde_json::from_str(line).ok()).collect();
if entries.len() > limit {
entries.drain(..entries.len() - limit);
}
entries
}
fn now_iso8601() -> String {
let secs = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
let (days, rem) = (secs / 86_400, secs % 86_400);
let (year, month, day) = crate::utils::civil_from_days(days as i64);
let (hour, minute, second) = (rem / 3_600, (rem % 3_600) / 60, rem % 60);
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn timestamps_have_the_expected_shape() {
let stamp = now_iso8601();
assert_eq!(stamp.len(), 20, "{stamp}");
assert!(stamp.ends_with('Z'));
assert_eq!(stamp.as_bytes()[4], b'-');
assert_eq!(stamp.as_bytes()[10], b'T');
}
}