Skip to main content

shape_runtime/
output_adapter.rs

1//! Output adapter trait for handling print() results.
2//!
3//! Different execution modes (script vs REPL vs notebook) control how
4//! print() output is surfaced.
5//!
6//! Per ADR-006 §2.7.4 (output adapter ruling), [`PrintResult`] and
7//! [`PrintSpan`] live in `shape-runtime` (see [`crate::print_result`])
8//! and `OutputAdapter::print` returns a [`KindedSlot`] — the
9//! GENERIC_CARRIER single-value shape (§2.7.1.2). Adapters that have no
10//! sensible heap value to return (script mode) return
11//! `KindedSlot::none()`; adapters that surface the structured output
12//! (REPL) attach the `PrintResult` to a typed-object heap value before
13//! returning. In Phase 1.B the `from_print_result` heap-construction is
14//! deferred — REPL mode currently returns `none()` and a follow-up wires
15//! `PrintResult` through a typed schema.
16
17use crate::print_result::PrintResult;
18use shape_value::KindedSlot;
19use std::sync::{Arc, Mutex};
20
21/// Trait for handling print() output.
22///
23/// Different execution modes can provide different adapters:
24/// - Scripts: [`StdoutAdapter`] (print and discard spans)
25/// - REPL: [`ReplAdapter`] (preserve spans for reformatting)
26/// - Tests: [`MockAdapter`] (capture output)
27/// - Hosts (server / notebook): [`SharedCaptureAdapter`]
28pub trait OutputAdapter: Send + Sync {
29    /// Handle print() output.
30    ///
31    /// Returns the value that print() yields. Scripts return
32    /// [`KindedSlot::none()`]; REPL adapters MAY surface the
33    /// `PrintResult` via a future typed-schema heap value but currently
34    /// also return `none()` until that schema lands.
35    fn print(&mut self, result: PrintResult) -> KindedSlot;
36
37    /// Handle Content HTML from printing a Content value.
38    /// Default implementation does nothing (terminal adapters don't need HTML).
39    fn print_content_html(&mut self, _html: String) {}
40
41    /// Clone the adapter (for trait object cloning)
42    fn clone_box(&self) -> Box<dyn OutputAdapter>;
43}
44
45// Implement Clone for Box<dyn OutputAdapter>
46impl Clone for Box<dyn OutputAdapter> {
47    fn clone(&self) -> Self {
48        self.clone_box()
49    }
50}
51
52/// Standard output adapter — prints to stdout and discards spans.
53///
54/// Used for script execution where spans aren't needed.
55#[derive(Debug, Clone)]
56pub struct StdoutAdapter;
57
58impl OutputAdapter for StdoutAdapter {
59    fn print(&mut self, result: PrintResult) -> KindedSlot {
60        println!("{}", result.rendered);
61        KindedSlot::none()
62    }
63
64    fn clone_box(&self) -> Box<dyn OutputAdapter> {
65        Box::new(self.clone())
66    }
67}
68
69/// REPL output adapter — preserves spans for reformatting.
70///
71/// Pre-bulldozer this returned a `ValueWord::from_print_result(..)`
72/// pointing at a `RareHeapData::PrintResult` heap arm. Post-ADR-006 that
73/// path is gone; the typed-schema replacement (`PrintResult` as a
74/// `HeapValue::TypedObject` with a runtime-registered schema) is a
75/// follow-up. For now the REPL adapter consumes the rendered text and
76/// returns `none()`; the structured `PrintResult` is dropped until the
77/// schema lands.
78#[derive(Debug, Clone)]
79pub struct ReplAdapter;
80
81impl OutputAdapter for ReplAdapter {
82    fn print(&mut self, _result: PrintResult) -> KindedSlot {
83        KindedSlot::none()
84    }
85
86    fn clone_box(&self) -> Box<dyn OutputAdapter> {
87        Box::new(self.clone())
88    }
89}
90
91/// Mock adapter for testing — captures output without printing.
92#[derive(Debug, Clone, Default)]
93pub struct MockAdapter {
94    /// Captured print outputs
95    pub captured: Vec<String>,
96}
97
98impl MockAdapter {
99    pub fn new() -> Self {
100        MockAdapter {
101            captured: Vec::new(),
102        }
103    }
104
105    /// Get all captured output
106    pub fn output(&self) -> Vec<String> {
107        self.captured.clone()
108    }
109
110    /// Clear captured output
111    pub fn clear(&mut self) {
112        self.captured.clear();
113    }
114}
115
116impl OutputAdapter for MockAdapter {
117    fn print(&mut self, result: PrintResult) -> KindedSlot {
118        self.captured.push(result.rendered.clone());
119        KindedSlot::none()
120    }
121
122    fn clone_box(&self) -> Box<dyn OutputAdapter> {
123        Box::new(self.clone())
124    }
125}
126
127/// Shared capture adapter for host integrations (server/notebook).
128///
129/// Captures rendered print output into shared state so the host can
130/// surface it in API responses without scraping stdout.
131/// Also captures Content HTML when Content values are printed.
132#[derive(Debug, Clone, Default)]
133pub struct SharedCaptureAdapter {
134    captured: Arc<Mutex<Vec<String>>>,
135    captured_full: Arc<Mutex<Vec<PrintResult>>>,
136    content_html: Arc<Mutex<Vec<String>>>,
137}
138
139impl SharedCaptureAdapter {
140    pub fn new() -> Self {
141        Self::default()
142    }
143
144    /// Get all captured output lines.
145    pub fn output(&self) -> Vec<String> {
146        self.captured
147            .lock()
148            .map(|v| v.clone())
149            .unwrap_or_else(|_| Vec::new())
150    }
151
152    /// Clear captured output lines.
153    pub fn clear(&self) {
154        if let Ok(mut v) = self.captured.lock() {
155            v.clear();
156        }
157    }
158
159    /// Push Content HTML captured from print(content_value).
160    pub fn push_content_html(&self, html: String) {
161        if let Ok(mut v) = self.content_html.lock() {
162            v.push(html);
163        }
164    }
165
166    /// Get all captured Content HTML fragments.
167    pub fn content_html(&self) -> Vec<String> {
168        self.content_html
169            .lock()
170            .map(|v| v.clone())
171            .unwrap_or_default()
172    }
173
174    /// Get all captured full PrintResults (with spans).
175    pub fn print_results(&self) -> Vec<PrintResult> {
176        self.captured_full
177            .lock()
178            .map(|v| v.clone())
179            .unwrap_or_default()
180    }
181}
182
183impl OutputAdapter for SharedCaptureAdapter {
184    fn print(&mut self, result: PrintResult) -> KindedSlot {
185        if let Ok(mut v) = self.captured.lock() {
186            v.push(result.rendered.clone());
187        }
188        if let Ok(mut v) = self.captured_full.lock() {
189            v.push(result);
190        }
191        KindedSlot::none()
192    }
193
194    fn print_content_html(&mut self, html: String) {
195        self.push_content_html(html);
196    }
197
198    fn clone_box(&self) -> Box<dyn OutputAdapter> {
199        Box::new(self.clone())
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use crate::print_result::PrintSpan;
207
208    fn make_test_result() -> PrintResult {
209        PrintResult {
210            rendered: "Test output".to_string(),
211            spans: vec![PrintSpan::Literal {
212                text: "Test output".to_string(),
213                start: 0,
214                end: 11,
215                span_id: "span_1".to_string(),
216            }],
217        }
218    }
219
220    #[test]
221    fn test_stdout_adapter_returns_none() {
222        let mut adapter = StdoutAdapter;
223        let result = make_test_result();
224        let returned = adapter.print(result);
225
226        assert_eq!(returned.slot().raw(), 0, "script-mode print returns none");
227    }
228
229    #[test]
230    fn test_repl_adapter_returns_none_phase1b() {
231        // Pre-ADR-006 this returned `ValueWord::from_print_result(..)`. The
232        // typed-schema replacement is deferred; current behaviour is to
233        // drop the structured payload and return `none()`.
234        let mut adapter = ReplAdapter;
235        let result = make_test_result();
236        let returned = adapter.print(result);
237        assert_eq!(returned.slot().raw(), 0);
238    }
239
240    #[test]
241    fn test_mock_adapter_captures() {
242        let mut adapter = MockAdapter::new();
243
244        adapter.print(PrintResult {
245            rendered: "Output 1".to_string(),
246            spans: vec![],
247        });
248        adapter.print(PrintResult {
249            rendered: "Output 2".to_string(),
250            spans: vec![],
251        });
252
253        assert_eq!(adapter.output(), vec!["Output 1", "Output 2"]);
254
255        adapter.clear();
256        assert_eq!(adapter.output().len(), 0);
257    }
258
259    #[test]
260    fn test_shared_capture_adapter_captures() {
261        let mut adapter = SharedCaptureAdapter::new();
262
263        adapter.print(PrintResult {
264            rendered: "Output A".to_string(),
265            spans: vec![],
266        });
267        adapter.print(PrintResult {
268            rendered: "Output B".to_string(),
269            spans: vec![],
270        });
271
272        assert_eq!(adapter.output(), vec!["Output A", "Output B"]);
273
274        adapter.clear();
275        assert!(adapter.output().is_empty());
276    }
277}