ftui_runtime/
evidence_sink.rs1#![forbid(unsafe_code)]
2
3use std::fs::OpenOptions;
17use std::io::{self, BufWriter, Write};
18use std::path::PathBuf;
19use std::sync::{Arc, Mutex};
20
21pub const EVIDENCE_SCHEMA_VERSION: &str = "ftui-evidence-v1";
23
24pub const DEFAULT_MAX_EVIDENCE_BYTES: u64 = 50 * 1024 * 1024;
26
27#[derive(Debug, Clone)]
29pub enum EvidenceSinkDestination {
30 Stdout,
32 File(PathBuf),
34}
35
36impl EvidenceSinkDestination {
37 #[must_use]
39 pub fn file(path: impl Into<PathBuf>) -> Self {
40 Self::File(path.into())
41 }
42}
43
44#[derive(Debug, Clone)]
46pub struct EvidenceSinkConfig {
47 pub enabled: bool,
49 pub destination: EvidenceSinkDestination,
51 pub flush_on_write: bool,
53 pub max_bytes: u64,
57}
58
59impl Default for EvidenceSinkConfig {
60 fn default() -> Self {
61 Self {
62 enabled: false,
63 destination: EvidenceSinkDestination::Stdout,
64 flush_on_write: true,
65 max_bytes: DEFAULT_MAX_EVIDENCE_BYTES,
66 }
67 }
68}
69
70impl EvidenceSinkConfig {
71 #[must_use]
73 pub fn disabled() -> Self {
74 Self::default()
75 }
76
77 #[must_use]
79 pub fn enabled_stdout() -> Self {
80 Self {
81 enabled: true,
82 destination: EvidenceSinkDestination::Stdout,
83 flush_on_write: true,
84 max_bytes: DEFAULT_MAX_EVIDENCE_BYTES,
85 }
86 }
87
88 #[must_use]
90 pub fn enabled_file(path: impl Into<PathBuf>) -> Self {
91 Self {
92 enabled: true,
93 destination: EvidenceSinkDestination::file(path),
94 flush_on_write: true,
95 max_bytes: DEFAULT_MAX_EVIDENCE_BYTES,
96 }
97 }
98
99 #[must_use]
101 pub fn with_enabled(mut self, enabled: bool) -> Self {
102 self.enabled = enabled;
103 self
104 }
105
106 #[must_use]
108 pub fn with_destination(mut self, destination: EvidenceSinkDestination) -> Self {
109 self.destination = destination;
110 self
111 }
112
113 #[must_use]
115 pub fn with_flush_on_write(mut self, enabled: bool) -> Self {
116 self.flush_on_write = enabled;
117 self
118 }
119
120 #[must_use]
123 pub fn with_max_bytes(mut self, max_bytes: u64) -> Self {
124 self.max_bytes = max_bytes;
125 self
126 }
127}
128
129struct EvidenceSinkInner {
130 writer: BufWriter<Box<dyn Write + Send>>,
131 flush_on_write: bool,
132 max_bytes: u64,
134 cap_enabled: bool,
136 bytes_written: u64,
138 capped: bool,
140}
141
142#[derive(Clone)]
144pub struct EvidenceSink {
145 inner: Arc<Mutex<EvidenceSinkInner>>,
146}
147
148impl std::fmt::Debug for EvidenceSink {
149 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150 f.debug_struct("EvidenceSink").finish()
151 }
152}
153
154impl EvidenceSink {
155 pub fn from_config(config: &EvidenceSinkConfig) -> io::Result<Option<Self>> {
162 if !config.enabled {
163 return Ok(None);
164 }
165
166 let (writer, existing_bytes): (Box<dyn Write + Send>, u64) = match &config.destination {
167 EvidenceSinkDestination::Stdout => (Box::new(io::stdout()), 0),
168 EvidenceSinkDestination::File(path) => {
169 let existing_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
170 let file = OpenOptions::new().create(true).append(true).open(path)?;
171 (Box::new(file), existing_size)
172 }
173 };
174
175 let cap_enabled = matches!(&config.destination, EvidenceSinkDestination::File(_));
176 let already_capped =
177 cap_enabled && config.max_bytes > 0 && existing_bytes >= config.max_bytes;
178
179 let inner = EvidenceSinkInner {
180 writer: BufWriter::new(writer),
181 flush_on_write: config.flush_on_write,
182 max_bytes: config.max_bytes,
183 cap_enabled,
184 bytes_written: existing_bytes,
185 capped: already_capped,
186 };
187
188 Ok(Some(Self {
189 inner: Arc::new(Mutex::new(inner)),
190 }))
191 }
192
193 pub fn write_jsonl(&self, line: &str) -> io::Result<()> {
198 let mut inner = match self.inner.lock() {
199 Ok(guard) => guard,
200 Err(poisoned) => poisoned.into_inner(),
201 };
202
203 if inner.capped {
205 return Ok(());
206 }
207
208 let line_bytes = line.len() as u64 + 1; if inner.cap_enabled
212 && inner.max_bytes > 0
213 && inner.bytes_written + line_bytes > inner.max_bytes
214 {
215 inner.capped = true;
216 let _ = inner.writer.flush();
218 return Ok(());
219 }
220
221 inner.writer.write_all(line.as_bytes())?;
222 inner.writer.write_all(b"\n")?;
223 inner.bytes_written += line_bytes;
224 if inner.flush_on_write {
225 inner.writer.flush()?;
226 }
227 Ok(())
228 }
229
230 pub fn flush(&self) -> io::Result<()> {
232 let mut inner = match self.inner.lock() {
233 Ok(guard) => guard,
234 Err(poisoned) => poisoned.into_inner(),
235 };
236 inner.writer.flush()
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn schema_version_stable() {
246 assert_eq!(EVIDENCE_SCHEMA_VERSION, "ftui-evidence-v1");
247 }
248
249 #[test]
250 fn config_default_is_disabled() {
251 let config = EvidenceSinkConfig::default();
252 assert!(!config.enabled);
253 assert!(config.flush_on_write);
254 assert!(matches!(
255 config.destination,
256 EvidenceSinkDestination::Stdout
257 ));
258 }
259
260 #[test]
261 fn config_disabled_matches_default() {
262 let config = EvidenceSinkConfig::disabled();
263 assert!(!config.enabled);
264 }
265
266 #[test]
267 fn config_enabled_stdout() {
268 let config = EvidenceSinkConfig::enabled_stdout();
269 assert!(config.enabled);
270 assert!(config.flush_on_write);
271 assert!(matches!(
272 config.destination,
273 EvidenceSinkDestination::Stdout
274 ));
275 }
276
277 #[test]
278 fn config_enabled_file() {
279 let config = EvidenceSinkConfig::enabled_file("/tmp/test.jsonl");
280 assert!(config.enabled);
281 assert!(config.flush_on_write);
282 assert!(matches!(
283 config.destination,
284 EvidenceSinkDestination::File(_)
285 ));
286 }
287
288 #[test]
289 fn config_builder_chain() {
290 let config = EvidenceSinkConfig::default()
291 .with_enabled(true)
292 .with_destination(EvidenceSinkDestination::Stdout)
293 .with_flush_on_write(false);
294 assert!(config.enabled);
295 assert!(!config.flush_on_write);
296 }
297
298 #[test]
299 fn destination_file_helper() {
300 let dest = EvidenceSinkDestination::file("/tmp/evidence.jsonl");
301 assert!(
302 matches!(dest, EvidenceSinkDestination::File(p) if p.to_str() == Some("/tmp/evidence.jsonl"))
303 );
304 }
305
306 #[test]
307 fn disabled_config_returns_none() {
308 let config = EvidenceSinkConfig::disabled();
309 let sink = EvidenceSink::from_config(&config).unwrap();
310 assert!(sink.is_none());
311 }
312
313 #[test]
314 fn enabled_file_sink_writes_jsonl() {
315 let tmp = tempfile::NamedTempFile::new().unwrap();
316 let path = tmp.path().to_path_buf();
317 let config = EvidenceSinkConfig::enabled_file(&path);
318 let sink = EvidenceSink::from_config(&config).unwrap().unwrap();
319
320 sink.write_jsonl(r#"{"event":"test","value":1}"#).unwrap();
321 sink.write_jsonl(r#"{"event":"test","value":2}"#).unwrap();
322 sink.flush().unwrap();
323
324 let content = std::fs::read_to_string(&path).unwrap();
325 let lines: Vec<&str> = content.lines().collect();
326 assert_eq!(lines.len(), 2);
327 assert_eq!(lines[0], r#"{"event":"test","value":1}"#);
328 assert_eq!(lines[1], r#"{"event":"test","value":2}"#);
329 }
330
331 #[test]
332 fn sink_is_clone_and_shared() {
333 let tmp = tempfile::NamedTempFile::new().unwrap();
334 let path = tmp.path().to_path_buf();
335 let config = EvidenceSinkConfig::enabled_file(&path);
336 let sink = EvidenceSink::from_config(&config).unwrap().unwrap();
337 let sink2 = sink.clone();
338
339 sink.write_jsonl(r#"{"from":"sink1"}"#).unwrap();
340 sink2.write_jsonl(r#"{"from":"sink2"}"#).unwrap();
341 sink.flush().unwrap();
342
343 let content = std::fs::read_to_string(&path).unwrap();
344 let lines: Vec<&str> = content.lines().collect();
345 assert_eq!(lines.len(), 2);
346 }
347
348 #[test]
349 fn sink_debug_impl() {
350 let tmp = tempfile::NamedTempFile::new().unwrap();
351 let config = EvidenceSinkConfig::enabled_file(tmp.path());
352 let sink = EvidenceSink::from_config(&config).unwrap().unwrap();
353 let debug = format!("{:?}", sink);
354 assert!(debug.contains("EvidenceSink"));
355 }
356
357 #[test]
358 fn file_sink_caps_at_max_bytes() {
359 let tmp = tempfile::NamedTempFile::new().unwrap();
360 let path = tmp.path().to_path_buf();
361 let config = EvidenceSinkConfig::enabled_file(&path).with_max_bytes(100);
363 let sink = EvidenceSink::from_config(&config).unwrap().unwrap();
364
365 for i in 0..100 {
367 sink.write_jsonl(&format!(r#"{{"event":"test","i":{i}}}"#))
369 .unwrap();
370 }
371 sink.flush().unwrap();
372
373 let content = std::fs::read_to_string(&path).unwrap();
374 let size = content.len();
375 assert!(
376 size <= 100,
377 "file should not exceed cap of 100 bytes, got {size}"
378 );
379 assert!(!content.is_empty(), "at least one line should be written");
381 }
382
383 #[test]
384 fn file_sink_caps_on_preexisting_large_file() {
385 let tmp = tempfile::NamedTempFile::new().unwrap();
386 let path = tmp.path().to_path_buf();
387 std::fs::write(&path, "x".repeat(200)).unwrap();
389
390 let config = EvidenceSinkConfig::enabled_file(&path).with_max_bytes(100);
391 let sink = EvidenceSink::from_config(&config).unwrap().unwrap();
392
393 sink.write_jsonl(r#"{"event":"should_be_dropped"}"#)
395 .unwrap();
396 sink.flush().unwrap();
397
398 let content = std::fs::read_to_string(&path).unwrap();
399 assert!(
400 !content.contains("should_be_dropped"),
401 "no new data should be written to an already-oversized file"
402 );
403 }
404
405 #[test]
406 fn unlimited_max_bytes_allows_unbounded_writes() {
407 let tmp = tempfile::NamedTempFile::new().unwrap();
408 let path = tmp.path().to_path_buf();
409 let config = EvidenceSinkConfig::enabled_file(&path).with_max_bytes(0);
410 let sink = EvidenceSink::from_config(&config).unwrap().unwrap();
411
412 for i in 0..1000 {
413 sink.write_jsonl(&format!(r#"{{"i":{i}}}"#)).unwrap();
414 }
415 sink.flush().unwrap();
416
417 let content = std::fs::read_to_string(&path).unwrap();
418 let lines: Vec<&str> = content.lines().collect();
419 assert_eq!(lines.len(), 1000, "all 1000 lines should be written");
420 }
421
422 #[test]
423 fn default_max_bytes_is_50mib() {
424 let config = EvidenceSinkConfig::default();
425 assert_eq!(config.max_bytes, DEFAULT_MAX_EVIDENCE_BYTES);
426 assert_eq!(config.max_bytes, 50 * 1024 * 1024);
427 }
428}