Skip to main content

nmbrs_runtime/readouts/
snapshot.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Snapshot capture for readout renders. See SRD-63 §6.
5//!
6//! Each event fire captures the rendered output (both
7//! styled and ANSI-stripped) and persists to the session
8//! db's `readout_snapshots` table. The latest render per
9//! `(slot, subject_kind, subject_id, readout_name, lod)`
10//! tuple wins — insert-or-replace upsert keeps the row
11//! count bounded by the scenario tree size.
12//!
13//! Push 6 ships:
14//!
15//! - [`strip_ansi`] — utility that turns the styled byte
16//!   stream into the plain-text fallback column.
17//! - [`render_to_snapshot_strings`] — convenience that
18//!   takes a [`StringSink`](super::StringSink)'s output
19//!   and returns the `(ansi_bytes, plain_text)` pair the
20//!   sqlite writer expects.
21//! - The [`SqliteReporter::upsert_readout_snapshot`]
22//!   surface in `nmbrs-metrics::reporters::sqlite` is the
23//!   actual store.
24//!
25//! Wiring the capture into `nmbrs-runtime::activity`'s
26//! per-event fire sites is intentionally out of scope here
27//! — the activity-side glue lives next to the existing
28//! binder.fire() call sites and threads through whatever
29//! `SqliteReporter` the session already holds.
30
31/// Handle the activity holds for snapshot writes.
32///
33/// The shape — `Arc<Mutex<Option<SqliteReporter>>>` — is
34/// load-bearing because the runner shares the same handle
35/// for non-readout sqlite work (cadence flushes, shutdown
36/// finalisers); the inner `Option` reflects "sqlite init
37/// failed; degrade gracefully," the Mutex serialises
38/// writes (sqlite isn't Send), and the Arc shares across
39/// the activity / observer / readout-fire threads.
40///
41/// Snapshot capture goes through [`capture`] rather than
42/// locking and matching by hand — the helper hides the
43/// three-layer unwrap and treats lock-poisoning /
44/// writer-absent as silent no-ops (best-effort).
45pub type SnapshotWriter =
46    std::sync::Arc<std::sync::Mutex<Option<nmbrs_metrics::reporters::sqlite::SqliteReporter>>>;
47
48/// LOD → string serialised for the storage column.
49pub fn lod_str(lod: super::Lod) -> &'static str {
50    match lod {
51        super::Lod::Compact => "compact",
52        super::Lod::Labeled => "labeled",
53        super::Lod::Expanded => "expanded",
54    }
55}
56
57/// Capture a rendered body to the snapshot store. Best-
58/// effort: a `None` writer (snapshots disabled), lock
59/// poisoning, or "sqlite init failed" (inner `Option` is
60/// `None`) all collapse to silent no-ops — snapshot
61/// capture must never block or corrupt the readout-fire
62/// path.
63///
64/// `subject_id` is normally produced by
65/// [`ReadoutContext::subject_id`](super::ReadoutContext::subject_id)
66/// — the trait default folds `subject_name` +
67/// `subject_labels` into the conventional `name@labels`
68/// shape; session-scoped contexts override to a literal
69/// `"session"`.
70pub fn capture(
71    writer: Option<&SnapshotWriter>,
72    slot: &str,
73    exec_id: u64,
74    subject_kind: &str,
75    subject_id: &str,
76    readout_name: &str,
77    lod: &str,
78    rendered: &str,
79) {
80    let Some(writer) = writer else {
81        return;
82    };
83    let plain = strip_ansi(rendered);
84    let now_nanos = std::time::SystemTime::now()
85        .duration_since(std::time::UNIX_EPOCH)
86        .map(|d| d.as_nanos() as i64)
87        .unwrap_or(0);
88    let body_ansi = if rendered != plain {
89        Some(rendered.as_bytes())
90    } else {
91        None
92    };
93    let mut guard = match writer.lock() {
94        Ok(g) => g,
95        Err(_) => return,
96    };
97    if let Some(reporter) = guard.as_mut() {
98        reporter.upsert_readout_snapshot(
99            slot,
100            exec_id,
101            subject_kind,
102            subject_id,
103            readout_name,
104            lod,
105            now_nanos,
106            body_ansi,
107            &plain,
108        );
109    }
110}
111
112/// Strip ANSI SGR escape sequences (`\x1b[...m`) from a
113/// rendered string, leaving the plain text. Used to
114/// derive the `body_plain` column from the styled
115/// `body_ansi` blob.
116///
117/// This is the same algorithm as
118/// `nmbrs-runtime::activity::truncate_to_width` uses to
119/// skip escapes when measuring visible width — kept here
120/// so snapshot capture has no surface dependency on the
121/// activity module.
122pub fn strip_ansi(s: &str) -> String {
123    let mut out = String::with_capacity(s.len());
124    let mut chars = s.char_indices();
125    while let Some((_, c)) = chars.next() {
126        if c == '\x1b' {
127            // Walk past `[` / `(` / etc. and the
128            // terminating letter (m / K / J / …). Skips
129            // CSI parameters and intermediate bytes
130            // without trying to interpret them — we just
131            // want to drop the whole escape from the
132            // visible stream.
133            for (_, ch) in chars.by_ref() {
134                if ch.is_ascii_alphabetic() {
135                    break;
136                }
137            }
138            continue;
139        }
140        out.push(c);
141    }
142    out
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn strip_ansi_removes_sgr_runs() {
151        assert_eq!(
152            strip_ansi("\x1b[34m[setup]\x1b[0m 100% \x1b[32m\u{2713}\x1b[0m"),
153            "[setup] 100% \u{2713}",
154        );
155    }
156
157    #[test]
158    fn strip_ansi_handles_no_escapes() {
159        assert_eq!(strip_ansi("plain text"), "plain text");
160    }
161
162    #[test]
163    fn strip_ansi_preserves_non_escape_unicode() {
164        assert_eq!(strip_ansi("(profile=alpha) ✓"), "(profile=alpha) ✓");
165    }
166
167    #[test]
168    fn strip_ansi_handles_carriage_return_and_clear() {
169        // The inline-status thread emits `\r\x1b[K`; the
170        // strip helper drops only the SGR-shaped escape.
171        // The `\r` is preserved as-is — that's a control
172        // character but not an SGR escape. Snapshot store
173        // captures the rendered body, not the carriage
174        // return preamble.
175        assert_eq!(strip_ansi("\x1b[Khello"), "hello",);
176    }
177}