use std::collections::VecDeque;
use std::io::Write;
use std::sync::Mutex;
const CAP: usize = 256;
#[derive(Clone, Debug)]
pub struct Entry {
pub seq: u64,
pub stamp: String,
pub kind: Kind,
pub detail: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Life,
Tab,
Click,
Query,
Rpc,
Error,
}
impl Kind {
pub fn tag(self) -> &'static str {
match self {
Kind::Life => "LIFE",
Kind::Tab => "TAB",
Kind::Click => "CLICK",
Kind::Query => "QUERY",
Kind::Rpc => "RPC",
Kind::Error => "ERROR",
}
}
}
pub struct ActionLog {
inner: Mutex<Inner>,
file_path: String,
}
struct Inner {
ring: VecDeque<Entry>,
seq: u64,
file: Option<std::fs::File>,
}
impl Default for ActionLog {
fn default() -> Self {
Self::new()
}
}
impl ActionLog {
pub fn new() -> Self {
let file_path = std::env::var("NORNIR_VIZ_ACTIONLOG")
.unwrap_or_else(|_| "/tmp/nornir_viz_actions.log".to_string());
let file = std::fs::File::create(&file_path).ok();
let log = Self {
inner: Mutex::new(Inner { ring: VecDeque::with_capacity(CAP), seq: 0, file }),
file_path,
};
log.push(Kind::Life, format!("action-log started → {}", log.file_path));
log
}
pub fn file_path(&self) -> &str {
&self.file_path
}
pub fn push(&self, kind: Kind, detail: impl Into<String>) {
let detail = detail.into();
let stamp = now_stamp();
let Ok(mut g) = self.inner.lock() else { return };
g.seq += 1;
let entry = Entry { seq: g.seq, stamp: stamp.clone(), kind, detail: detail.clone() };
eprintln!("nornir-viz ACTION {} [{}] {}", stamp, kind.tag(), detail);
if let Some(f) = g.file.as_mut() {
let _ = writeln!(f, "{} {:>5} [{}] {}", stamp, entry.seq, kind.tag(), detail);
let _ = f.flush();
}
if g.ring.len() == CAP {
g.ring.pop_front();
}
g.ring.push_back(entry);
}
pub fn recent(&self, n: usize) -> Vec<Entry> {
let Ok(g) = self.inner.lock() else { return Vec::new() };
let len = g.ring.len();
let start = len.saturating_sub(n);
g.ring.iter().skip(start).cloned().collect()
}
pub fn count(&self) -> u64 {
self.inner.lock().map(|g| g.seq).unwrap_or(0)
}
pub fn tail_lines(&self, max_bytes: u64, max_lines: usize) -> Vec<String> {
use std::io::{Read, Seek, SeekFrom};
let Ok(mut f) = std::fs::File::open(&self.file_path) else { return Vec::new() };
let len = f.metadata().map(|m| m.len()).unwrap_or(0);
let start = len.saturating_sub(max_bytes);
if start > 0 && f.seek(SeekFrom::Start(start)).is_err() {
return Vec::new();
}
let mut bytes = Vec::new();
if f.read_to_end(&mut bytes).is_err() {
return Vec::new();
}
let text = String::from_utf8_lossy(&bytes);
let mut lines: Vec<&str> = text.lines().collect();
if start > 0 && !lines.is_empty() {
lines.remove(0);
}
let n = lines.len();
lines[n.saturating_sub(max_lines)..]
.iter()
.map(|s| s.to_string())
.collect()
}
}
fn now_stamp() -> String {
chrono::Local::now().format("%H:%M:%S%.3f").to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ring_caps_and_orders() {
std::env::set_var("NORNIR_VIZ_ACTIONLOG", "/tmp/nornir_viz_actions_test.log");
let log = ActionLog::new();
for i in 0..(CAP + 50) {
log.push(Kind::Click, format!("click {i}"));
}
let recent = log.recent(10);
assert_eq!(recent.len(), 10);
for w in recent.windows(2) {
assert!(w[1].seq > w[0].seq);
}
assert!(log.recent(usize::MAX).len() <= CAP);
assert_eq!(log.count(), CAP as u64 + 50 + 1);
}
#[test]
fn kinds_have_distinct_tags() {
let tags = [
Kind::Life.tag(),
Kind::Tab.tag(),
Kind::Click.tag(),
Kind::Query.tag(),
Kind::Rpc.tag(),
Kind::Error.tag(),
];
let mut uniq = tags.to_vec();
uniq.sort_unstable();
uniq.dedup();
assert_eq!(uniq.len(), tags.len());
}
}