capsule_runtime/
diagnostic.rs1use std::io::Write;
21use std::path::{Path, PathBuf};
22use std::sync::Mutex;
23
24use crate::agent::append_bootstrap_log;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Emitted {
29 Written,
31 Logged,
33 Dropped,
35}
36
37static FALLBACK_WORKDIR: Mutex<Option<PathBuf>> = Mutex::new(None);
42
43pub fn set_diagnostic_workdir(workdir: &Path) {
51 let mut slot = FALLBACK_WORKDIR
52 .lock()
53 .unwrap_or_else(|poisoned| poisoned.into_inner());
54 *slot = Some(workdir.to_path_buf());
55}
56
57pub fn diagnostic_workdir() -> Option<PathBuf> {
59 FALLBACK_WORKDIR
60 .lock()
61 .unwrap_or_else(|poisoned| poisoned.into_inner())
62 .clone()
63}
64
65pub fn to_stdout(line: &str) -> Emitted {
67 let fallback = diagnostic_workdir();
68 emit_to(&mut std::io::stdout().lock(), line, fallback.as_deref())
69}
70
71pub fn to_stderr(line: &str) -> Emitted {
73 let fallback = diagnostic_workdir();
74 emit_to(&mut std::io::stderr().lock(), line, fallback.as_deref())
75}
76
77pub fn raw_to_stdout(chunk: &str) -> Emitted {
82 let fallback = diagnostic_workdir();
83 emit_raw_to(&mut std::io::stdout().lock(), chunk, fallback.as_deref())
84}
85
86pub(crate) fn emit_to<W: Write>(out: &mut W, line: &str, fallback: Option<&Path>) -> Emitted {
88 let written = out
89 .write_all(line.as_bytes())
90 .and_then(|()| out.write_all(b"\n"))
91 .and_then(|()| out.flush());
92 match written {
93 Ok(()) => Emitted::Written,
94 Err(_) => fall_back(line, fallback),
95 }
96}
97
98pub(crate) fn emit_raw_to<W: Write>(out: &mut W, chunk: &str, fallback: Option<&Path>) -> Emitted {
101 let written = out.write_all(chunk.as_bytes()).and_then(|()| out.flush());
102 match written {
103 Ok(()) => Emitted::Written,
104 Err(_) => fall_back(chunk.trim_end_matches('\n'), fallback),
105 }
106}
107
108fn fall_back(line: &str, fallback: Option<&Path>) -> Emitted {
109 match fallback {
110 Some(workdir) => {
111 append_bootstrap_log(workdir, line);
112 Emitted::Logged
113 }
114 None => Emitted::Dropped,
115 }
116}
117
118#[macro_export]
124macro_rules! runtime_out {
125 ($($arg:tt)*) => {{
126 $crate::diagnostic::to_stdout(&::std::format!($($arg)*));
127 }};
128}
129
130#[macro_export]
132macro_rules! runtime_err {
133 ($($arg:tt)*) => {{
134 $crate::diagnostic::to_stderr(&::std::format!($($arg)*));
135 }};
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use std::io::{Error, ErrorKind};
142
143 struct BrokenPipe;
145
146 impl Write for BrokenPipe {
147 fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
148 Err(Error::new(
149 ErrorKind::BrokenPipe,
150 "Broken pipe (os error 32)",
151 ))
152 }
153
154 fn flush(&mut self) -> std::io::Result<()> {
155 Err(Error::new(
156 ErrorKind::BrokenPipe,
157 "Broken pipe (os error 32)",
158 ))
159 }
160 }
161
162 #[test]
163 fn refused_line_lands_in_the_bootstrap_log() {
164 let workdir = tempfile::tempdir().expect("temp workdir");
165 let outcome = emit_to(
166 &mut BrokenPipe,
167 "{\"url\":\"http://127.0.0.1:1/\"}",
168 Some(workdir.path()),
169 );
170 assert_eq!(outcome, Emitted::Logged);
171 let log = workdir.path().join("logs").join("bootstrap.log");
172 let contents = std::fs::read_to_string(&log).expect("bootstrap.log was written");
173 assert_eq!(contents, "{\"url\":\"http://127.0.0.1:1/\"}\n");
174 }
175
176 #[test]
177 fn refused_line_with_no_workdir_is_dropped() {
178 let outcome = emit_to(&mut BrokenPipe, "a warning raised during staging", None);
179 assert_eq!(outcome, Emitted::Dropped);
180 }
181
182 #[test]
183 fn accepted_line_gets_exactly_one_newline() {
184 let mut sink: Vec<u8> = Vec::new();
185 let outcome = emit_to(&mut sink, "session: ses_01", None);
186 assert_eq!(outcome, Emitted::Written);
187 assert_eq!(String::from_utf8(sink).unwrap(), "session: ses_01\n");
188 }
189
190 #[test]
191 fn raw_chunk_is_written_verbatim() {
192 let mut sink: Vec<u8> = Vec::new();
193 let outcome = emit_raw_to(&mut sink, "child line\n", None);
194 assert_eq!(outcome, Emitted::Written);
195 assert_eq!(String::from_utf8(sink).unwrap(), "child line\n");
196 }
197
198 #[test]
199 fn refused_raw_chunk_is_logged_without_a_doubled_newline() {
200 let workdir = tempfile::tempdir().expect("temp workdir");
201 let outcome = emit_raw_to(&mut BrokenPipe, "child line\n", Some(workdir.path()));
202 assert_eq!(outcome, Emitted::Logged);
203 let log = workdir.path().join("logs").join("bootstrap.log");
204 let contents = std::fs::read_to_string(&log).expect("bootstrap.log was written");
205 assert_eq!(contents, "child line\n");
206 }
207}