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}