Skip to main content

ftui_runtime/
evidence_sink.rs

1#![forbid(unsafe_code)]
2
3//! JSONL evidence sink for deterministic diagnostics.
4//!
5//! This provides a shared, line-oriented sink that can be wired into runtime
6//! policies (diff/resize/budget) to emit JSONL evidence to a single destination.
7//! Ordering is deterministic with respect to call order because writes are
8//! serialized behind a mutex, and flush behavior is explicit and configurable.
9//!
10//! ## Size cap
11//!
12//! File-backed sinks enforce a maximum size (default 50 MiB). Once the cap is
13//! reached, further writes are silently dropped to prevent unbounded disk
14//! growth. The cap can be configured via [`EvidenceSinkConfig::max_bytes`].
15
16use std::fs::OpenOptions;
17use std::io::{self, BufWriter, Write};
18use std::path::PathBuf;
19use std::sync::{Arc, Mutex};
20
21/// Schema version for JSONL evidence lines.
22pub const EVIDENCE_SCHEMA_VERSION: &str = "ftui-evidence-v1";
23
24/// Default maximum evidence file size: 50 MiB.
25pub const DEFAULT_MAX_EVIDENCE_BYTES: u64 = 50 * 1024 * 1024;
26
27/// Destination for evidence JSONL output.
28#[derive(Debug, Clone)]
29pub enum EvidenceSinkDestination {
30    /// Write to stdout.
31    Stdout,
32    /// Append to a file at the given path.
33    File(PathBuf),
34}
35
36impl EvidenceSinkDestination {
37    /// Convenience helper for file destinations.
38    #[must_use]
39    pub fn file(path: impl Into<PathBuf>) -> Self {
40        Self::File(path.into())
41    }
42}
43
44/// Configuration for evidence logging.
45#[derive(Debug, Clone)]
46pub struct EvidenceSinkConfig {
47    /// Whether evidence logging is enabled.
48    pub enabled: bool,
49    /// Output destination for JSONL lines.
50    pub destination: EvidenceSinkDestination,
51    /// Flush after every line (recommended for tests/e2e capture).
52    pub flush_on_write: bool,
53    /// Maximum total bytes to write before silently stopping.
54    /// Only enforced for file destinations. `0` means unlimited.
55    /// Defaults to [`DEFAULT_MAX_EVIDENCE_BYTES`] (50 MiB).
56    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    /// Create a disabled sink config.
72    #[must_use]
73    pub fn disabled() -> Self {
74        Self::default()
75    }
76
77    /// Enable logging to stdout with flush-on-write.
78    #[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    /// Enable logging to a file with flush-on-write.
89    #[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    /// Set whether logging is enabled.
100    #[must_use]
101    pub fn with_enabled(mut self, enabled: bool) -> Self {
102        self.enabled = enabled;
103        self
104    }
105
106    /// Set the destination for evidence output.
107    #[must_use]
108    pub fn with_destination(mut self, destination: EvidenceSinkDestination) -> Self {
109        self.destination = destination;
110        self
111    }
112
113    /// Set flush-on-write behavior.
114    #[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    /// Set maximum bytes before the sink silently stops writing.
121    /// Use `0` for unlimited (not recommended for file destinations).
122    #[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    /// Maximum bytes allowed. `0` means unlimited.
133    max_bytes: u64,
134    /// Whether the size cap is enforced for this sink.
135    cap_enabled: bool,
136    /// Approximate total bytes written so far (including the initial file size).
137    bytes_written: u64,
138    /// Set to true once the cap is hit; prevents further writes.
139    capped: bool,
140}
141
142/// Shared, line-oriented JSONL sink for evidence logging.
143#[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    /// Build an evidence sink from config. Returns `Ok(None)` when disabled.
156    ///
157    /// For file destinations the existing file size is counted toward the cap
158    /// so that restarting a process does not reset the budget. If the file
159    /// already exceeds `max_bytes` the sink is returned in a "capped" state
160    /// and no further bytes will be written.
161    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    /// Write a single JSONL line with newline and optional flush.
194    ///
195    /// If the file size cap has been reached, the write is silently dropped
196    /// and `Ok(())` is returned so callers never see an error from capping.
197    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        // Silently drop writes once the cap is hit.
204        if inner.capped {
205            return Ok(());
206        }
207
208        let line_bytes = line.len() as u64 + 1; // +1 for newline
209
210        // Check whether this write would exceed the cap.
211        if inner.cap_enabled
212            && inner.max_bytes > 0
213            && inner.bytes_written + line_bytes > inner.max_bytes
214        {
215            inner.capped = true;
216            // Best-effort: flush what we have so the file ends cleanly.
217            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    /// Flush any buffered output.
231    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        // Set a very small cap: 100 bytes.
362        let config = EvidenceSinkConfig::enabled_file(&path).with_max_bytes(100);
363        let sink = EvidenceSink::from_config(&config).unwrap().unwrap();
364
365        // Each line is ~30 bytes + newline. Write many times.
366        for i in 0..100 {
367            // Should never error, even after cap.
368            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        // At least one line should have been written.
380        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        // Pre-fill the file with 200 bytes.
388        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        // This write should be silently dropped since file already exceeds cap.
394        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}