Skip to main content

nmbrs_runtime/
output_channel.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! SRD-87 Output Channel — the single typed terminal-output conduit.
5//!
6//! Push 1 lands the trait, the **op-output bucket**, and a test-capture
7//! impl. Every adapter op line submits through
8//! [`OutputChannel::op_output`]; the installed impl alone decides where
9//! the bytes land. The remaining buckets (`log` / `status` / `raster`)
10//! and the consolidation of `RunObserver` + `DisplaySink` arrive in
11//! later pushes (SRD-87 §11/§13).
12//!
13//! The op-output channel is **selected once per run** from the run's
14//! context (`silent_console`, `is_tty`) — replacing the prior
15//! `console_reserved_for_adapter` global flag that `op_output` consulted
16//! inline. A console-owning adapter (`silent_console`) and a piped run
17//! both own a raw stdout surface; an interactive dashboard routes op
18//! output through the live display so it composites without the raw-mode
19//! staircase. The console-owning stdout adapter printing nothing on a
20//! TTY (the SRD-87 §2 defect) falls out as a consequence: under the raw
21//! impl the op line is the adapter's, written to the surface it owns,
22//! never suppressed alongside the diagnostics.
23
24use std::sync::{Arc, Mutex};
25
26use arc_swap::ArcSwapOption;
27
28use crate::observer::{LogLevel, colorize_log_line};
29
30/// The single conduit for the one user terminal. Exactly one impl is
31/// installed per run via [`install`]; producers submit to a bucket and
32/// only the impl touches an fd. Every method is non-blocking.
33///
34/// Push 1 defines the **op-output** bucket only; `log` / `status` /
35/// `raster` land in later pushes (SRD-87 §4).
36pub trait OutputChannel: Send + Sync {
37    /// op-output bucket: one adapter-rendered op line. Reaches the
38    /// terminal/file the channel owns — never around it.
39    fn op_output(&self, line: &str);
40
41    // SRD-100 P2 — the status bucket is removed. The live phase status is
42    // folded at the display consumer from the `active_phases` snapshot
43    // (`nmbrs_tui::status_fold`), not submitted as a pre-rendered string to
44    // a single channel slot. No keyed submission survives, so per SRD-100
45    // §6 the bucket + `RunObserver::set_status_line` are deleted rather than
46    // re-keyed.
47
48    /// log bucket: project one diagnostic line to the **live terminal**
49    /// (SRD-87 §5). This is the *output* half only — the durable
50    /// `session.log` write and the fold-ring append are L1 intake, done by
51    /// `observer::log_categorized` before this is ever called, and the
52    /// `sink_active`/`min_level` gate (whether a line reaches the live
53    /// surface at all) stays with the observer. The default writes the
54    /// colorized line to stderr — the fd the channel owns. A sink-active
55    /// interactive run never reaches here (the sink renders from the ring);
56    /// a console-owning run never reaches here (suppressed upstream); piped
57    /// and bootstrap runs land here.
58    fn log(&self, level: LogLevel, message: &str) {
59        eprintln!("{}", colorize_log_line(level, message));
60    }
61
62    /// raster bucket: a fully-rendered, self-contained terminal frame
63    /// (braille / ANSI cells) — e.g. the plotter's canvas (SRD-87 §5). The
64    /// producer owns layout (cursor controls, line endings); the channel
65    /// owns the fd. The default writes the frame bytes raw to the stdout the
66    /// channel owns — correct for a console-owning adapter (the plotter owns
67    /// the screen) and for a piped plot redirected to a file.
68    fn raster(&self, frame: &str) {
69        use std::io::Write;
70        let mut out = std::io::stdout().lock();
71        let _ = out.write_all(frame.as_bytes());
72        let _ = out.flush();
73    }
74}
75
76/// op-output routed **through the live display**: the active
77/// `LogOnlySink` / `TuiSink` composites the line into its scrollback,
78/// avoiding the raw-mode staircase. Selected for an interactive
79/// dashboard that is not console-owning (`tui=terminal` / `on`).
80pub struct DisplayRoutedChannel;
81
82impl OutputChannel for DisplayRoutedChannel {
83    fn op_output(&self, line: &str) {
84        crate::observer::log(crate::observer::LogLevel::Info, line);
85    }
86}
87
88/// op-output written **raw to the stdout the producer owns**, plus a
89/// durable `session.log` capture. Selected for a console-owning adapter
90/// on a TTY (it owns the screen) AND for a piped/redirected run (so
91/// `nmbrs run | grep` and `> file` keep working). This is the impl that
92/// makes a console-owning stdout adapter actually print: the line is the
93/// adapter's, written to the surface it owns, never suppressed with the
94/// diagnostics.
95pub struct RawStdoutChannel;
96
97impl OutputChannel for RawStdoutChannel {
98    fn op_output(&self, line: &str) {
99        crate::observer::op_output_raw(line);
100    }
101}
102
103/// Test-capture impl: records every op-output line for assertions and
104/// writes no fd. The SRD-87 §12 surface-agreement / no-bypass tests
105/// install one of these and inspect [`Self::op_lines`].
106#[derive(Default, Clone)]
107pub struct CaptureChannel {
108    op_lines: Arc<Mutex<Vec<String>>>,
109    log_lines: Arc<Mutex<Vec<(LogLevel, String)>>>,
110    raster_frames: Arc<Mutex<Vec<String>>>,
111}
112
113impl CaptureChannel {
114    pub fn new() -> Self {
115        Self::default()
116    }
117
118    /// Every op-output line submitted so far, in submission order.
119    pub fn op_lines(&self) -> Vec<String> {
120        self.op_lines
121            .lock()
122            .unwrap_or_else(|e| e.into_inner())
123            .clone()
124    }
125
126    /// Every log-bucket line submitted so far, in submission order.
127    pub fn log_lines(&self) -> Vec<(LogLevel, String)> {
128        self.log_lines
129            .lock()
130            .unwrap_or_else(|e| e.into_inner())
131            .clone()
132    }
133
134    /// Every raster-bucket frame submitted so far, in submission order.
135    pub fn raster_frames(&self) -> Vec<String> {
136        self.raster_frames
137            .lock()
138            .unwrap_or_else(|e| e.into_inner())
139            .clone()
140    }
141}
142
143impl OutputChannel for CaptureChannel {
144    fn op_output(&self, line: &str) {
145        self.op_lines
146            .lock()
147            .unwrap_or_else(|e| e.into_inner())
148            .push(line.to_string());
149    }
150
151    fn log(&self, level: LogLevel, message: &str) {
152        self.log_lines
153            .lock()
154            .unwrap_or_else(|e| e.into_inner())
155            .push((level, message.to_string()));
156    }
157
158    fn raster(&self, frame: &str) {
159        self.raster_frames
160            .lock()
161            .unwrap_or_else(|e| e.into_inner())
162            .push(frame.to_string());
163    }
164}
165
166/// Which op-output impl a run's context selects (SRD-87 §10). Factored
167/// out so the selection is unit-testable without standing up a surface.
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum ChannelKind {
170    /// Raw to the owned stdout + `session.log` — console-owning adapter
171    /// or a pipe.
172    RawStdout,
173    /// Through the live display — an interactive, non-console-owning
174    /// dashboard.
175    DisplayRouted,
176}
177
178/// The op-output routing a run's context implies. A console-owning
179/// adapter (`silent_console`) owns a raw surface; a piped run
180/// (`!is_tty`) owns the pipe; only an interactive dashboard routes
181/// through the display.
182pub fn select_kind(silent_console: bool, is_tty: bool) -> ChannelKind {
183    if is_tty && !silent_console {
184        ChannelKind::DisplayRouted
185    } else {
186        ChannelKind::RawStdout
187    }
188}
189
190/// Build the op-output channel a run's context selects.
191pub fn select(silent_console: bool, is_tty: bool) -> Arc<dyn OutputChannel> {
192    match select_kind(silent_console, is_tty) {
193        ChannelKind::DisplayRouted => Arc::new(DisplayRoutedChannel),
194        ChannelKind::RawStdout => Arc::new(RawStdoutChannel),
195    }
196}
197
198/// Sized newtype so the process-global can hold the unsized
199/// `Arc<dyn OutputChannel>` inside an `ArcSwapOption` (which stores a
200/// `Sized` `Arc<Holder>`).
201struct Holder(Arc<dyn OutputChannel>);
202
203/// The installed channel for this process. `None` until a run installs
204/// one; bootstrap and unit tests with no run see `None` and the
205/// `op_output` free fn falls back to the raw path (SRD-87 §6 carve-out).
206/// `ArcSwapOption` so a run installs lock-free and tests can swap/reset.
207static CHANNEL: ArcSwapOption<Holder> = ArcSwapOption::const_empty();
208
209/// Install the op-output channel for this run (SRD-87 §10 — chosen once
210/// per context). Replaces any prior install.
211pub fn install(channel: Arc<dyn OutputChannel>) {
212    CHANNEL.store(Some(Arc::new(Holder(channel))));
213}
214
215/// Clear the installed channel (end of run; tests).
216pub fn clear() {
217    CHANNEL.store(None);
218}
219
220/// The installed channel, if any. SRD-88 — **task-local-first**: a
221/// concurrent in-process execution that scoped its own channel
222/// (`ExecutionContext.channel`) resolves to it; everything else (single-run,
223/// bootstrap, tests) falls back to the process-global `CHANNEL` (axiom A1, a
224/// true no-op until a context scopes one).
225pub fn installed() -> Option<Arc<dyn OutputChannel>> {
226    if let Some(ch) = crate::execution_context::current_channel() {
227        return Some(ch);
228    }
229    CHANNEL.load_full().map(|h| h.0.clone())
230}
231
232/// Project a diagnostic line to the **log bucket** (the live terminal).
233/// Called by `observer::log_categorized`/the observer impls *after* the
234/// L1 intake (session.log + fold ring) and *after* the
235/// `sink_active`/`min_level` gate has decided the line should reach the
236/// live surface (SRD-87 §5). Routes to the installed channel — the sole
237/// fd owner — falling back to a direct colorized stderr write before any
238/// channel is installed (bootstrap), so behavior is unchanged.
239pub fn log_to_surface(level: LogLevel, message: &str) {
240    if let Some(ch) = installed() {
241        ch.log(level, message);
242    } else {
243        eprintln!("{}", colorize_log_line(level, message));
244    }
245}
246
247/// Submit a **raster** frame (a self-contained, pre-rendered terminal
248/// canvas) to the channel. Producers — the plotter — call this instead of
249/// `print!`-ing the frame themselves (SRD-87 §5). Routes to the installed
250/// channel (the fd owner), falling back to a raw stdout write before any
251/// channel is installed (bootstrap / unit tests), so behavior is unchanged.
252pub fn raster(frame: &str) {
253    if let Some(ch) = installed() {
254        ch.raster(frame);
255    } else {
256        use std::io::Write;
257        let mut out = std::io::stdout().lock();
258        let _ = out.write_all(frame.as_bytes());
259        let _ = out.flush();
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn capture_channel_records_op_output_in_order() {
269        let ch = CaptureChannel::new();
270        ch.op_output("id-0");
271        ch.op_output("id-1");
272        assert_eq!(ch.op_lines(), vec!["id-0".to_string(), "id-1".to_string()]);
273    }
274
275    #[test]
276    fn capture_channel_records_raster_frames() {
277        let ch = CaptureChannel::new();
278        ch.raster("frame-a");
279        ch.raster("frame-b");
280        assert_eq!(
281            ch.raster_frames(),
282            vec!["frame-a".to_string(), "frame-b".to_string()]
283        );
284    }
285
286    #[test]
287    fn capture_channel_records_log_lines() {
288        let ch = CaptureChannel::new();
289        ch.log(LogLevel::Info, "started");
290        ch.log(LogLevel::Warn, "careful");
291        assert_eq!(
292            ch.log_lines(),
293            vec![
294                (LogLevel::Info, "started".to_string()),
295                (LogLevel::Warn, "careful".to_string()),
296            ]
297        );
298    }
299
300    #[test]
301    fn select_kind_console_owning_and_piped_are_raw() {
302        // Console-owning adapter on a TTY: it owns the screen → raw, so
303        // its output reaches the surface instead of being suppressed with
304        // the diagnostics (the SRD-87 §2 defect).
305        assert_eq!(select_kind(true, true), ChannelKind::RawStdout);
306        // Piped / redirected: raw, so `nmbrs run | grep` keeps working.
307        assert_eq!(select_kind(false, false), ChannelKind::RawStdout);
308        // Console-owning but not a TTY (e.g. `> file`): still raw.
309        assert_eq!(select_kind(true, false), ChannelKind::RawStdout);
310        // Interactive dashboard, not console-owning: route through the
311        // display so it composites without the raw-mode staircase.
312        assert_eq!(select_kind(false, true), ChannelKind::DisplayRouted);
313    }
314}