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::{self, 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                if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
170                    fs::create_dir_all(parent)?;
171                }
172                let existing_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
173                let file = OpenOptions::new().create(true).append(true).open(path)?;
174                (Box::new(file), existing_size)
175            }
176        };
177
178        let cap_enabled = matches!(&config.destination, EvidenceSinkDestination::File(_));
179        let already_capped =
180            cap_enabled && config.max_bytes > 0 && existing_bytes >= config.max_bytes;
181
182        let inner = EvidenceSinkInner {
183            writer: BufWriter::new(writer),
184            flush_on_write: config.flush_on_write,
185            max_bytes: config.max_bytes,
186            cap_enabled,
187            bytes_written: existing_bytes,
188            capped: already_capped,
189        };
190
191        Ok(Some(Self {
192            inner: Arc::new(Mutex::new(inner)),
193        }))
194    }
195
196    /// Write a single JSONL line with newline and optional flush.
197    ///
198    /// If the file size cap has been reached, the write is silently dropped
199    /// and `Ok(())` is returned so callers never see an error from capping.
200    pub fn write_jsonl(&self, line: &str) -> io::Result<()> {
201        let mut inner = match self.inner.lock() {
202            Ok(guard) => guard,
203            Err(poisoned) => poisoned.into_inner(),
204        };
205
206        // Silently drop writes once the cap is hit.
207        if inner.capped {
208            return Ok(());
209        }
210
211        let line_bytes = u64::try_from(line.len())
212            .unwrap_or(u64::MAX)
213            .saturating_add(1); // +1 for newline
214        let new_total = inner.bytes_written.checked_add(line_bytes);
215
216        // Check whether this write would exceed the cap.
217        if inner.cap_enabled && inner.max_bytes > 0 && new_total.is_none_or(|n| n > inner.max_bytes)
218        {
219            inner.capped = true;
220            // Best-effort: flush what we have so the file ends cleanly.
221            let _ = inner.writer.flush();
222            return Ok(());
223        }
224
225        inner.writer.write_all(line.as_bytes())?;
226        inner.writer.write_all(b"\n")?;
227        inner.bytes_written = new_total.unwrap_or(u64::MAX);
228        if inner.flush_on_write {
229            inner.writer.flush()?;
230        }
231        Ok(())
232    }
233
234    /// Flush any buffered output.
235    pub fn flush(&self) -> io::Result<()> {
236        let mut inner = match self.inner.lock() {
237            Ok(guard) => guard,
238            Err(poisoned) => poisoned.into_inner(),
239        };
240        inner.writer.flush()
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn schema_version_stable() {
250        assert_eq!(EVIDENCE_SCHEMA_VERSION, "ftui-evidence-v1");
251    }
252
253    #[test]
254    fn config_default_is_disabled() {
255        let config = EvidenceSinkConfig::default();
256        assert!(!config.enabled);
257        assert!(config.flush_on_write);
258        assert!(matches!(
259            config.destination,
260            EvidenceSinkDestination::Stdout
261        ));
262    }
263
264    #[test]
265    fn config_disabled_matches_default() {
266        let config = EvidenceSinkConfig::disabled();
267        assert!(!config.enabled);
268    }
269
270    #[test]
271    fn config_enabled_stdout() {
272        let config = EvidenceSinkConfig::enabled_stdout();
273        assert!(config.enabled);
274        assert!(config.flush_on_write);
275        assert!(matches!(
276            config.destination,
277            EvidenceSinkDestination::Stdout
278        ));
279    }
280
281    #[test]
282    fn config_enabled_file() {
283        let config = EvidenceSinkConfig::enabled_file("/tmp/test.jsonl");
284        assert!(config.enabled);
285        assert!(config.flush_on_write);
286        assert!(matches!(
287            config.destination,
288            EvidenceSinkDestination::File(_)
289        ));
290    }
291
292    #[test]
293    fn config_builder_chain() {
294        let config = EvidenceSinkConfig::default()
295            .with_enabled(true)
296            .with_destination(EvidenceSinkDestination::Stdout)
297            .with_flush_on_write(false);
298        assert!(config.enabled);
299        assert!(!config.flush_on_write);
300    }
301
302    #[test]
303    fn destination_file_helper() {
304        let dest = EvidenceSinkDestination::file("/tmp/evidence.jsonl");
305        assert!(
306            matches!(dest, EvidenceSinkDestination::File(p) if p.to_str() == Some("/tmp/evidence.jsonl"))
307        );
308    }
309
310    #[test]
311    fn disabled_config_returns_none() {
312        let config = EvidenceSinkConfig::disabled();
313        let sink = EvidenceSink::from_config(&config).unwrap();
314        assert!(sink.is_none());
315    }
316
317    #[test]
318    fn enabled_file_sink_writes_jsonl() {
319        let tmp = tempfile::NamedTempFile::new().unwrap();
320        let path = tmp.path().to_path_buf();
321        let config = EvidenceSinkConfig::enabled_file(&path);
322        let sink = EvidenceSink::from_config(&config).unwrap().unwrap();
323
324        sink.write_jsonl(r#"{"event":"test","value":1}"#).unwrap();
325        sink.write_jsonl(r#"{"event":"test","value":2}"#).unwrap();
326        sink.flush().unwrap();
327
328        let content = std::fs::read_to_string(&path).unwrap();
329        let lines: Vec<&str> = content.lines().collect();
330        assert_eq!(lines.len(), 2);
331        assert_eq!(lines[0], r#"{"event":"test","value":1}"#);
332        assert_eq!(lines[1], r#"{"event":"test","value":2}"#);
333    }
334
335    #[test]
336    fn sink_is_clone_and_shared() {
337        let tmp = tempfile::NamedTempFile::new().unwrap();
338        let path = tmp.path().to_path_buf();
339        let config = EvidenceSinkConfig::enabled_file(&path);
340        let sink = EvidenceSink::from_config(&config).unwrap().unwrap();
341        let sink2 = sink.clone();
342
343        sink.write_jsonl(r#"{"from":"sink1"}"#).unwrap();
344        sink2.write_jsonl(r#"{"from":"sink2"}"#).unwrap();
345        sink.flush().unwrap();
346
347        let content = std::fs::read_to_string(&path).unwrap();
348        let lines: Vec<&str> = content.lines().collect();
349        assert_eq!(lines.len(), 2);
350    }
351
352    #[test]
353    fn sink_debug_impl() {
354        let tmp = tempfile::NamedTempFile::new().unwrap();
355        let config = EvidenceSinkConfig::enabled_file(tmp.path());
356        let sink = EvidenceSink::from_config(&config).unwrap().unwrap();
357        let debug = format!("{:?}", sink);
358        assert!(debug.contains("EvidenceSink"));
359    }
360
361    #[test]
362    fn file_sink_caps_at_max_bytes() {
363        let tmp = tempfile::NamedTempFile::new().unwrap();
364        let path = tmp.path().to_path_buf();
365        // Set a very small cap: 100 bytes.
366        let config = EvidenceSinkConfig::enabled_file(&path).with_max_bytes(100);
367        let sink = EvidenceSink::from_config(&config).unwrap().unwrap();
368
369        // Each line is ~30 bytes + newline. Write many times.
370        for i in 0..100 {
371            // Should never error, even after cap.
372            sink.write_jsonl(&format!(r#"{{"event":"test","i":{i}}}"#))
373                .unwrap();
374        }
375        sink.flush().unwrap();
376
377        let content = std::fs::read_to_string(&path).unwrap();
378        let size = content.len();
379        assert!(
380            size <= 100,
381            "file should not exceed cap of 100 bytes, got {size}"
382        );
383        // At least one line should have been written.
384        assert!(!content.is_empty(), "at least one line should be written");
385    }
386
387    #[test]
388    fn file_sink_caps_on_preexisting_large_file() {
389        let tmp = tempfile::NamedTempFile::new().unwrap();
390        let path = tmp.path().to_path_buf();
391        // Pre-fill the file with 200 bytes.
392        std::fs::write(&path, "x".repeat(200)).unwrap();
393
394        let config = EvidenceSinkConfig::enabled_file(&path).with_max_bytes(100);
395        let sink = EvidenceSink::from_config(&config).unwrap().unwrap();
396
397        // This write should be silently dropped since file already exceeds cap.
398        sink.write_jsonl(r#"{"event":"should_be_dropped"}"#)
399            .unwrap();
400        sink.flush().unwrap();
401
402        let content = std::fs::read_to_string(&path).unwrap();
403        assert!(
404            !content.contains("should_be_dropped"),
405            "no new data should be written to an already-oversized file"
406        );
407    }
408
409    #[test]
410    fn file_sink_creates_parent_directories() {
411        let tmp = tempfile::tempdir().unwrap();
412        let path = tmp.path().join("nested").join("evidence.jsonl");
413        let config = EvidenceSinkConfig::enabled_file(&path);
414        let sink = EvidenceSink::from_config(&config).unwrap().unwrap();
415
416        sink.write_jsonl(r#"{"event":"nested"}"#).unwrap();
417        sink.flush().unwrap();
418
419        let content = std::fs::read_to_string(&path).unwrap();
420        assert_eq!(content, "{\"event\":\"nested\"}\n");
421    }
422
423    #[test]
424    fn file_sink_caps_on_byte_counter_overflow() {
425        let sink = EvidenceSink {
426            inner: Arc::new(Mutex::new(EvidenceSinkInner {
427                writer: BufWriter::new(Box::new(io::sink())),
428                flush_on_write: true,
429                max_bytes: u64::MAX,
430                cap_enabled: true,
431                bytes_written: u64::MAX - 1,
432                capped: false,
433            })),
434        };
435
436        sink.write_jsonl("{}").unwrap();
437
438        let inner = sink.inner.lock().unwrap();
439        assert!(
440            inner.capped,
441            "overflowing cap accounting should cap the sink"
442        );
443        assert_eq!(inner.bytes_written, u64::MAX - 1);
444    }
445
446    #[test]
447    fn unlimited_max_bytes_allows_unbounded_writes() {
448        let tmp = tempfile::NamedTempFile::new().unwrap();
449        let path = tmp.path().to_path_buf();
450        let config = EvidenceSinkConfig::enabled_file(&path).with_max_bytes(0);
451        let sink = EvidenceSink::from_config(&config).unwrap().unwrap();
452
453        for i in 0..1000 {
454            sink.write_jsonl(&format!(r#"{{"i":{i}}}"#)).unwrap();
455        }
456        sink.flush().unwrap();
457
458        let content = std::fs::read_to_string(&path).unwrap();
459        let lines: Vec<&str> = content.lines().collect();
460        assert_eq!(lines.len(), 1000, "all 1000 lines should be written");
461    }
462
463    #[test]
464    fn default_max_bytes_is_50mib() {
465        let config = EvidenceSinkConfig::default();
466        assert_eq!(config.max_bytes, DEFAULT_MAX_EVIDENCE_BYTES);
467        assert_eq!(config.max_bytes, 50 * 1024 * 1024);
468    }
469}