nornir 0.5.1

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! Structured event trace — the **machine-readable** sibling of `action_log`.
//!
//! `action_log` is a human free-form trail (`tab=Bench`, `reload … → 3
//! release(s)`). This is its typed counterpart: every instrumented operation
//! emits its INPUT parameters and its OUTPUT *data* as real JSON payloads, so an
//! external agent (or the headless test matrix) can read back **exactly the data
//! the UI rendered** — the bubble table, the bench rows, the server response —
//! not a summary and not a screenshot.
//!
//! One JSON object per line (JSONL) at `$NORNIR_VIZ_TRACE` (default
//! `/tmp/nornir_viz_trace.jsonl`), truncated on launch:
//!
//! ```json
//! {"seq":7,"ts_ms":1733961825678,"stamp":"01:23:45.678",
//!  "span":"knowledge.scan","phase":"in","data":{"workspace_root":"…","repos":["njord"]}}
//! {"seq":8,...,"span":"knowledge.scan.repo","phase":"end","data":{"repo":"njord","symbols":642,…}}
//! {"seq":9,...,"span":"knowledge.scan","phase":"out","data":{"repos_ok":1,"repos_total":1,"ms":83}}
//! {"seq":10,...,"span":"knowledge.render","phase":"end","data":{"bubbles":[{"repo":"njord",…}]}}
//! ```
//!
//! ## The contract
//! - **span** — the operation name (`knowledge.scan`, `ui.tab`, `server.reload`).
//! - **phase** — `in` (params accepted), `out` (result produced), `end` (a unit
//!   of work finished), or `event` (a point-in-time fact, e.g. a tab switch).
//!   Pair an `in` with its later `out`/`end` of the same `span` by adjacency —
//!   the UI is single-threaded, so within one operation they don't interleave.
//! - **data** — the real, structured payload. Emit typed structs (serialized via
//!   serde), never a pre-formatted string, so the consumer parses fields.
//!
//! Cheap by design: a `Mutex<File>` `writeln!` + a global atomic seq. Call sites
//! only emit on edge-triggered events (a scan, a click, an RPC), never per-frame.

use std::io::Write;
use std::sync::{Mutex, OnceLock};

use serde::Serialize;

/// Phase of an instrumented operation. Lets a consumer correlate the inputs a
/// call accepted with the data it produced.
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Phase {
    /// Inputs accepted — the parameters that drive the operation.
    In,
    /// Output produced — the result data the operation returns.
    Out,
    /// A unit of work finished (use when there's no separate in/out split).
    End,
    /// A point-in-time fact with no duration (a tab switch, a selection).
    Event,
}

struct Sink {
    inner: Mutex<Inner>,
    path: String,
}

struct Inner {
    seq: u64,
    file: Option<std::fs::File>,
}

static SINK: OnceLock<Sink> = OnceLock::new();

fn sink() -> &'static Sink {
    SINK.get_or_init(|| {
        let path = std::env::var("NORNIR_VIZ_TRACE")
            .unwrap_or_else(|_| "/tmp/nornir_viz_trace.jsonl".to_string());
        // Truncate on launch so each session's trace starts clean (matches the
        // single-snapshot semantics of $NORNIR_VIZ_STATE / the action log file).
        let file = std::fs::File::create(&path).ok();
        let s = Sink { inner: Mutex::new(Inner { seq: 0, file }), path };
        // A session header so the consumer knows the trace (re)started and where.
        emit_into(&s, "trace.session", Phase::Event, &serde_json::json!({ "file": s.path }));
        s
    })
}

/// Emit one structured event: `span` is the operation, `phase` its stage, and
/// `data` the real payload (any `Serialize` — a typed struct, ideally). Writes a
/// single JSONL line to `$NORNIR_VIZ_TRACE`; never panics (best-effort I/O).
pub fn emit<T: Serialize>(span: &str, phase: Phase, data: &T) {
    emit_into(sink(), span, phase, data);
}

/// Convenience: the inputs a call accepted.
pub fn emit_in<T: Serialize>(span: &str, data: &T) {
    emit(span, Phase::In, data);
}

/// Convenience: the data a call produced.
pub fn emit_out<T: Serialize>(span: &str, data: &T) {
    emit(span, Phase::Out, data);
}

/// Convenience: a unit of work finished (no separate in/out).
pub fn emit_end<T: Serialize>(span: &str, data: &T) {
    emit(span, Phase::End, data);
}

/// Convenience: a point-in-time fact (tab switch, selection).
pub fn emit_event<T: Serialize>(span: &str, data: &T) {
    emit(span, Phase::Event, data);
}

fn emit_into<T: Serialize>(s: &Sink, span: &str, phase: Phase, data: &T) {
    let now = chrono::Local::now();
    let stamp = now.format("%H:%M:%S%.3f").to_string();
    let ts_ms = now.timestamp_millis();
    let Ok(mut g) = s.inner.lock() else { return };
    g.seq += 1;
    let line = serde_json::json!({
        "seq": g.seq,
        "ts_ms": ts_ms,
        "stamp": stamp,
        "span": span,
        "phase": phase,
        // Serialize the payload through serde_json::to_value so a failure to
        // serialize degrades to null instead of dropping the whole event.
        "data": serde_json::to_value(data).unwrap_or(serde_json::Value::Null),
    });
    if let Some(f) = g.file.as_mut() {
        let _ = writeln!(f, "{line}");
        let _ = f.flush();
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[derive(Serialize)]
    struct Payload {
        repo: String,
        symbols: usize,
    }

    #[test]
    fn writes_jsonl_lines_with_real_payload() {
        // Serialize against any other test that mutates the global NORNIR_VIZ_TRACE
        // env var. Hint a process-unique path so that when this test is the first
        // to touch the global sink it stays isolated from a concurrent `cargo test`
        // run. The sink is a process-global `OnceLock`, though, so an earlier
        // production-path emit may have already bound it to another file — read the
        // sink's ACTUAL path and tolerate foreign/interleaved lines rather than
        // assuming our env var won.
        let _g = crate::serial_guard();
        let hint = std::env::temp_dir()
            .join(format!("nornir_viz_trace_test_{}.jsonl", std::process::id()));
        // TODO: Audit that the environment access only happens in single-threaded code.
        unsafe { std::env::set_var("NORNIR_VIZ_TRACE", &hint) };

        emit_in("knowledge.scan", &serde_json::json!({ "repos": ["njord"] }));
        emit_end("knowledge.scan.repo", &Payload { repo: "njord".into(), symbols: 642 });

        // The real file the global sink writes to (our hint if we initialized it,
        // else whatever an earlier emit bound).
        let body = std::fs::read_to_string(&sink().path).unwrap();
        // Tolerant parse: skip blank/partial lines an interleaved foreign emit may
        // have left; keep only well-formed JSON objects carrying the contract.
        let rows: Vec<serde_json::Value> = body
            .lines()
            .filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
            .filter(|v| {
                v.get("seq").is_some()
                    && v.get("span").is_some()
                    && v.get("phase").is_some()
                    && v.get("data").is_some()
            })
            .collect();
        // Our two emits must be present as real structured data, not strings.
        assert!(
            rows.iter()
                .any(|v| v["span"] == "knowledge.scan" && v["phase"] == "in"),
            "missing knowledge.scan/in row in {rows:?}"
        );
        let end = rows
            .iter()
            .find(|v| v["span"] == "knowledge.scan.repo" && v["phase"] == "end")
            .unwrap_or_else(|| panic!("missing knowledge.scan.repo/end row in {rows:?}"));
        assert_eq!(end["data"]["symbols"], 642);
    }
}