runmat_runtime/
console.rs1use once_cell::sync::OnceCell;
2use runmat_builtins::Value;
3use runmat_thread_local::runmat_thread_local;
4use runmat_time::unix_timestamp_ms;
5use std::cell::RefCell;
6use std::io::Write;
7use std::path::PathBuf;
8use std::sync::{Arc, RwLock};
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum ConsoleStream {
13 Stdout,
14 Stderr,
15 ClearScreen,
16}
17
18#[derive(Clone, Debug)]
20pub struct ConsoleEntry {
21 pub stream: ConsoleStream,
22 pub text: String,
23 pub timestamp_ms: u64,
24}
25
26type StreamForwarder = dyn Fn(&ConsoleEntry) + Send + Sync + 'static;
27
28runmat_thread_local! {
29 static THREAD_BUFFER: RefCell<Vec<ConsoleEntry>> = const { RefCell::new(Vec::new()) };
30 static LAST_VALUE_OUTPUT: RefCell<Option<Value>> = const { RefCell::new(None) };
31 static CAPTURE_STACK: RefCell<Vec<Vec<ConsoleEntry>>> = const { RefCell::new(Vec::new()) };
32 static DIARY_STATE: RefCell<DiaryState> = RefCell::new(DiaryState::default());
33}
34
35static FORWARDER: OnceCell<RwLock<Option<Arc<StreamForwarder>>>> = OnceCell::new();
36
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct DiaryStateSnapshot {
39 pub enabled: bool,
40 pub filename: PathBuf,
41}
42
43impl Default for DiaryStateSnapshot {
44 fn default() -> Self {
45 Self {
46 enabled: false,
47 filename: PathBuf::from("diary"),
48 }
49 }
50}
51
52#[derive(Debug)]
53struct DiaryState {
54 enabled: bool,
55 filename: PathBuf,
56 last_error: Option<String>,
57}
58
59impl Default for DiaryState {
60 fn default() -> Self {
61 Self {
62 enabled: false,
63 filename: PathBuf::from("diary"),
64 last_error: None,
65 }
66 }
67}
68
69impl From<DiaryStateSnapshot> for DiaryState {
70 fn from(snapshot: DiaryStateSnapshot) -> Self {
71 Self {
72 enabled: snapshot.enabled,
73 filename: snapshot.filename,
74 last_error: None,
75 }
76 }
77}
78
79pub struct ConsoleCaptureGuard {
81 active: bool,
82}
83
84fn now_ms() -> u64 {
85 unix_timestamp_ms().min(u64::MAX as u128) as u64
86}
87
88pub fn record_console_output(stream: ConsoleStream, text: impl Into<String>) {
91 let entry = ConsoleEntry {
92 stream,
93 text: text.into(),
94 timestamp_ms: now_ms(),
95 };
96 if CAPTURE_STACK.with(|captures| {
97 let mut captures = captures.borrow_mut();
98 if let Some(current) = captures.last_mut() {
99 current.push(entry.clone());
100 true
101 } else {
102 false
103 }
104 }) {
105 return;
106 }
107
108 THREAD_BUFFER.with(|buf| buf.borrow_mut().push(entry.clone()));
109 write_diary_entry(&entry);
110
111 if let Some(forwarder) = FORWARDER
112 .get()
113 .and_then(|lock| lock.read().ok().map(|guard| guard.as_ref().cloned()))
114 .flatten()
115 {
116 forwarder(&entry);
117 }
118}
119
120pub fn record_clear_screen() {
122 record_console_output(ConsoleStream::ClearScreen, String::new());
123}
124
125pub fn record_console_line(stream: ConsoleStream, text: impl Into<String>) {
127 let mut text = text.into();
128 if !text.ends_with('\n') {
129 text.push('\n');
130 }
131 record_console_output(stream, text);
132}
133
134pub fn reset_thread_buffer() {
137 THREAD_BUFFER.with(|buf| buf.borrow_mut().clear());
138 LAST_VALUE_OUTPUT.with(|value| value.borrow_mut().take());
139}
140
141pub fn take_thread_buffer() -> Vec<ConsoleEntry> {
143 THREAD_BUFFER.with(|buf| buf.borrow_mut().drain(..).collect())
144}
145
146pub fn append_thread_buffer(entries: impl IntoIterator<Item = ConsoleEntry>) {
149 THREAD_BUFFER.with(|buf| {
150 let mut buf = buf.borrow_mut();
151 buf.extend(entries);
152 buf.sort_by_key(|entry| entry.timestamp_ms);
153 });
154}
155
156pub fn install_forwarder(forwarder: Option<Arc<StreamForwarder>>) {
159 let lock = FORWARDER.get_or_init(|| RwLock::new(None));
160 if let Ok(mut guard) = lock.write() {
161 *guard = forwarder;
162 }
163}
164
165pub fn record_value_output(label: Option<&str>, value: &Value) {
167 LAST_VALUE_OUTPUT.with(|last| {
168 *last.borrow_mut() = Some(value.clone());
169 });
170 let value_text = match value {
171 Value::Object(obj) if obj.is_class("datetime") => {
172 crate::builtins::datetime::datetime_display_text(value)
173 .ok()
174 .flatten()
175 .unwrap_or_else(|| value.to_string())
176 }
177 Value::Object(obj) if obj.is_class("duration") => {
178 crate::builtins::duration::duration_display_text(value)
179 .ok()
180 .flatten()
181 .unwrap_or_else(|| value.to_string())
182 }
183 _ => value.to_string(),
184 };
185 let text = if let Some(name) = label {
186 if is_unlabeled_nd_page_display(&value_text) {
187 inject_label_into_nd_page_headers(name, &value_text)
188 } else if value_text.contains('\n') {
189 format!("{name} =\n{value_text}")
190 } else {
191 format!("{name} = {value_text}")
192 }
193 } else {
194 value_text
195 };
196 record_console_line(ConsoleStream::Stdout, text);
197}
198
199pub fn take_last_value_output() -> Option<Value> {
200 LAST_VALUE_OUTPUT.with(|value| value.borrow_mut().take())
201}
202
203pub fn begin_capture() -> ConsoleCaptureGuard {
210 CAPTURE_STACK.with(|captures| captures.borrow_mut().push(Vec::new()));
211 ConsoleCaptureGuard { active: true }
212}
213
214impl ConsoleCaptureGuard {
215 pub fn finish(mut self) -> String {
216 self.active = false;
217 let entries =
218 CAPTURE_STACK.with(|captures| captures.borrow_mut().pop().unwrap_or_default());
219 captured_text(entries)
220 }
221}
222
223impl Drop for ConsoleCaptureGuard {
224 fn drop(&mut self) {
225 if self.active {
226 CAPTURE_STACK.with(|captures| {
227 captures.borrow_mut().pop();
228 });
229 }
230 }
231}
232
233fn captured_text(entries: Vec<ConsoleEntry>) -> String {
234 let mut out = String::new();
235 for entry in entries {
236 if matches!(entry.stream, ConsoleStream::Stdout | ConsoleStream::Stderr) {
237 out.push_str(&entry.text);
238 }
239 }
240 out
241}
242
243pub fn diary_enabled() -> bool {
244 DIARY_STATE.with(|state| state.borrow().enabled)
245}
246
247pub fn diary_filename() -> PathBuf {
248 DIARY_STATE.with(|state| state.borrow().filename.clone())
249}
250
251pub fn diary_state_snapshot() -> DiaryStateSnapshot {
252 DIARY_STATE.with(|state| {
253 let state = state.borrow();
254 DiaryStateSnapshot {
255 enabled: state.enabled,
256 filename: state.filename.clone(),
257 }
258 })
259}
260
261pub fn replace_diary_state(snapshot: DiaryStateSnapshot) -> DiaryStateSnapshot {
262 DIARY_STATE.with(|state| {
263 let previous = {
264 let state = state.borrow();
265 DiaryStateSnapshot {
266 enabled: state.enabled,
267 filename: state.filename.clone(),
268 }
269 };
270 *state.borrow_mut() = DiaryState::from(snapshot);
271 previous
272 })
273}
274
275pub fn take_diary_error() -> Option<String> {
276 DIARY_STATE.with(|state| state.borrow_mut().last_error.take())
277}
278
279pub fn set_diary_filename(filename: impl Into<PathBuf>) {
280 DIARY_STATE.with(|state| {
281 let mut state = state.borrow_mut();
282 state.filename = filename.into();
283 state.enabled = true;
284 state.last_error = None;
285 });
286}
287
288pub fn set_diary_filename_checked(filename: impl Into<PathBuf>) -> std::io::Result<()> {
289 let filename = filename.into();
290 open_diary_append(&filename).map(|_| ())?;
291 set_diary_filename(filename);
292 Ok(())
293}
294
295pub fn set_diary_enabled(enabled: bool) {
296 DIARY_STATE.with(|state| {
297 let mut state = state.borrow_mut();
298 state.enabled = enabled;
299 if enabled {
300 state.last_error = None;
301 }
302 });
303}
304
305pub fn set_diary_enabled_checked(enabled: bool) -> std::io::Result<()> {
306 if enabled {
307 ensure_diary_writable()?;
308 }
309 set_diary_enabled(enabled);
310 Ok(())
311}
312
313pub fn toggle_diary() -> std::io::Result<()> {
314 if diary_enabled() {
315 set_diary_enabled(false);
316 } else {
317 set_diary_enabled_checked(true)?;
318 }
319 Ok(())
320}
321
322pub fn ensure_diary_writable() -> std::io::Result<()> {
323 let filename = diary_filename();
324 open_diary_append(&filename).map(|_| ())
325}
326
327pub fn record_diary_command(text: &str) {
329 if !diary_enabled() {
330 return;
331 }
332 let mut owned = text.to_string();
333 if !owned.ends_with('\n') {
334 owned.push('\n');
335 }
336 if let Err(err) = write_diary_text(&owned) {
337 record_diary_failure(err);
338 }
339}
340
341fn write_diary_entry(entry: &ConsoleEntry) {
342 if !matches!(entry.stream, ConsoleStream::Stdout | ConsoleStream::Stderr) {
343 return;
344 }
345 if diary_enabled() {
346 if let Err(err) = write_diary_text(&entry.text) {
347 record_diary_failure(err);
348 }
349 }
350}
351
352fn record_diary_failure(err: std::io::Error) {
353 DIARY_STATE.with(|state| {
354 let mut state = state.borrow_mut();
355 state.enabled = false;
356 state.last_error = Some(format!("diary: failed to write diary file ({err})"));
357 });
358}
359
360fn write_diary_text(text: &str) -> std::io::Result<()> {
361 let filename = diary_filename();
362 let mut file = open_diary_append(&filename)?;
363 file.write_all(text.as_bytes())?;
364 file.flush()
365}
366
367fn open_diary_append(path: &PathBuf) -> std::io::Result<runmat_filesystem::File> {
368 let mut options = runmat_filesystem::OpenOptions::new();
369 options.create(true).append(true);
370 options.open(path)
371}
372
373fn is_unlabeled_nd_page_display(text: &str) -> bool {
374 text.lines()
375 .any(|line| line.trim_start().starts_with("(:, :") && line.trim_end().ends_with('='))
376}
377
378fn inject_label_into_nd_page_headers(label: &str, text: &str) -> String {
379 let mut out = String::new();
380 for (idx, line) in text.lines().enumerate() {
381 if idx > 0 {
382 out.push('\n');
383 }
384 let trimmed = line.trim_start();
385 if trimmed.starts_with("(:, :") && trimmed.trim_end().ends_with('=') {
386 out.push_str(label);
387 out.push_str(trimmed);
388 } else {
389 out.push_str(line);
390 }
391 }
392 out
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 fn entry(text: &str, timestamp_ms: u64) -> ConsoleEntry {
400 ConsoleEntry {
401 stream: ConsoleStream::Stdout,
402 text: text.to_string(),
403 timestamp_ms,
404 }
405 }
406
407 #[test]
408 fn append_thread_buffer_orders_entries_by_timestamp_stably() {
409 reset_thread_buffer();
410 append_thread_buffer(vec![entry("late", 20)]);
411 append_thread_buffer(vec![entry("early", 10), entry("same-time", 20)]);
412
413 let entries = take_thread_buffer();
414 let texts = entries
415 .into_iter()
416 .map(|entry| entry.text)
417 .collect::<Vec<_>>();
418 assert_eq!(texts, vec!["early", "late", "same-time"]);
419 }
420
421 #[test]
422 fn capture_diverts_console_text_from_thread_buffer() {
423 let _lock = runmat_filesystem::provider_override_lock();
424 set_diary_enabled(false);
425 reset_thread_buffer();
426 let capture = begin_capture();
427 record_console_line(ConsoleStream::Stdout, "inside");
428 let text = capture.finish();
429 record_console_line(ConsoleStream::Stdout, "outside");
430
431 assert_eq!(text, "inside\n");
432 let entries = take_thread_buffer();
433 assert_eq!(entries.len(), 1);
434 assert_eq!(entries[0].text, "outside\n");
435 }
436}