nornir 0.4.12

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
//! TOTAL user-action debug log — the headless-debuggability contract for the
//! viz. Every interaction (tab switch, button click, query, RPC call, error) is
//! pushed here with a millisecond timestamp so that "when I fire it up you
//! really follow every little bug".
//!
//! Three sinks, all always-on and cheap:
//!   1. an in-memory **ring buffer** (last [`CAP`] entries) the app paints in a
//!      bottom overlay panel,
//!   2. a **per-entry stderr line** (`nornir-viz ACTION …`) so the operator
//!      watching the launching terminal / server log sees the trail live, and
//!   3. an appended **file** at `$NORNIR_VIZ_ACTIONLOG` (default
//!      `/tmp/nornir_viz_actions.log`) — externally observable + greppable for
//!      bug-hunting, mirroring how `$NORNIR_VIZ_STATE` dumps the UI state.
//!
//! Cheap by design: a `Mutex<VecDeque>` push + a `writeln!`. The instrumented
//! call sites (see `app.rs`) only push on real edge-triggered events (a click, a
//! tab change, an RPC firing), never every frame, so it never floods.

use std::collections::VecDeque;
use std::io::Write;
use std::sync::Mutex;

/// Max entries retained in the in-memory ring (older ones drop off the front).
const CAP: usize = 256;

/// One timestamped action-log entry.
#[derive(Clone, Debug)]
pub struct Entry {
    /// Monotonic sequence number (since app start) — stable ordering even when
    /// two entries land in the same millisecond.
    pub seq: u64,
    /// `HH:MM:SS.mmm` wall-clock stamp (local), for the overlay + file.
    pub stamp: String,
    /// Coarse category — drives the overlay color + the grep prefix.
    pub kind: Kind,
    /// Free-form detail, e.g. `tab=Bench`, `Search.Query q="foo"`, the error text.
    pub detail: String,
}

/// Action category. Kept small + greppable.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
    /// App lifecycle (launch, workspace switch, reload).
    Life,
    /// A tab became active.
    Tab,
    /// A button / control was clicked.
    Click,
    /// A user-issued query (search / lookup / call path).
    Query,
    /// An outgoing gRPC call to the server.
    Rpc,
    /// An error surfaced anywhere in the flow.
    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",
        }
    }
}

/// Central action log: a ring buffer + the file/stderr sinks. Interior-mutable
/// (`Mutex`) so it can be shared `&self` across every instrumented call site
/// without threading `&mut` through the whole app.
pub struct ActionLog {
    inner: Mutex<Inner>,
    /// File sink path (resolved once at construction).
    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());
        // Truncate on launch so each session's trail starts clean (matches the
        // single-snapshot semantics of $NORNIR_VIZ_STATE).
        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
    }

    /// Path of the file sink (surfaced in the overlay header).
    pub fn file_path(&self) -> &str {
        &self.file_path
    }

    /// Record one action. Always-on; pushes to the ring + both external sinks.
    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() };
        // stderr: live trail for the launching terminal / server log.
        eprintln!("nornir-viz ACTION {} [{}] {}", stamp, kind.tag(), detail);
        // file: greppable, externally observable (mirrors $NORNIR_VIZ_STATE).
        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);
    }

    /// Snapshot the most recent `n` entries (oldest→newest) for the overlay.
    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()
    }

    /// Total entries recorded this session (the live counter, for the header).
    pub fn count(&self) -> u64 {
        self.inner.lock().map(|g| g.seq).unwrap_or(0)
    }

    /// Tail the action-log **file** — the single shared source of truth. Unlike
    /// [`recent`](Self::recent) (this viz's own in-memory ring), this reads back
    /// the file, so it also surfaces lines appended by **other** nornir processes
    /// to the same `$NORNIR_VIZ_ACTIONLOG` — most importantly a running
    /// `nornir bench`'s `[BENCH]` progress, so the operator watches the benchmark
    /// live in the viz's action-trail overlay. Bounded to the last `max_bytes`
    /// (cheap even when a long bench floods the file) and the last `max_lines`.
    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();
        }
        // from_utf8_lossy: the seek may land mid-multibyte; never panic on it.
        let text = String::from_utf8_lossy(&bytes);
        let mut lines: Vec<&str> = text.lines().collect();
        // Drop the (likely partial) first line when we seeked into the middle.
        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()
    }
}

/// `HH:MM:SS.mmm` local-time stamp. Uses `chrono` (already a dep) so it matches
/// the rest of the codebase's time formatting.
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() {
        // Don't clobber a real operator file during tests.
        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);
        // Newest last; seq strictly increasing.
        for w in recent.windows(2) {
            assert!(w[1].seq > w[0].seq);
        }
        // The ring never exceeds CAP even after CAP+50 (+1 the LIFE start) pushes.
        assert!(log.recent(usize::MAX).len() <= CAP);
        // Count tracks every push including the LIFE start line.
        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());
    }
}