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
10use std::fs::OpenOptions;
11use std::io::{self, BufWriter, Write};
12use std::path::PathBuf;
13use std::sync::{Arc, Mutex};
14
15/// Schema version for JSONL evidence lines.
16pub const EVIDENCE_SCHEMA_VERSION: &str = "ftui-evidence-v1";
17
18/// Destination for evidence JSONL output.
19#[derive(Debug, Clone)]
20pub enum EvidenceSinkDestination {
21    /// Write to stdout.
22    Stdout,
23    /// Append to a file at the given path.
24    File(PathBuf),
25}
26
27impl EvidenceSinkDestination {
28    /// Convenience helper for file destinations.
29    #[must_use]
30    pub fn file(path: impl Into<PathBuf>) -> Self {
31        Self::File(path.into())
32    }
33}
34
35/// Configuration for evidence logging.
36#[derive(Debug, Clone)]
37pub struct EvidenceSinkConfig {
38    /// Whether evidence logging is enabled.
39    pub enabled: bool,
40    /// Output destination for JSONL lines.
41    pub destination: EvidenceSinkDestination,
42    /// Flush after every line (recommended for tests/e2e capture).
43    pub flush_on_write: bool,
44}
45
46impl Default for EvidenceSinkConfig {
47    fn default() -> Self {
48        Self {
49            enabled: false,
50            destination: EvidenceSinkDestination::Stdout,
51            flush_on_write: true,
52        }
53    }
54}
55
56impl EvidenceSinkConfig {
57    /// Create a disabled sink config.
58    #[must_use]
59    pub fn disabled() -> Self {
60        Self::default()
61    }
62
63    /// Enable logging to stdout with flush-on-write.
64    #[must_use]
65    pub fn enabled_stdout() -> Self {
66        Self {
67            enabled: true,
68            destination: EvidenceSinkDestination::Stdout,
69            flush_on_write: true,
70        }
71    }
72
73    /// Enable logging to a file with flush-on-write.
74    #[must_use]
75    pub fn enabled_file(path: impl Into<PathBuf>) -> Self {
76        Self {
77            enabled: true,
78            destination: EvidenceSinkDestination::file(path),
79            flush_on_write: true,
80        }
81    }
82
83    /// Set whether logging is enabled.
84    #[must_use]
85    pub fn with_enabled(mut self, enabled: bool) -> Self {
86        self.enabled = enabled;
87        self
88    }
89
90    /// Set the destination for evidence output.
91    #[must_use]
92    pub fn with_destination(mut self, destination: EvidenceSinkDestination) -> Self {
93        self.destination = destination;
94        self
95    }
96
97    /// Set flush-on-write behavior.
98    #[must_use]
99    pub fn with_flush_on_write(mut self, enabled: bool) -> Self {
100        self.flush_on_write = enabled;
101        self
102    }
103}
104
105struct EvidenceSinkInner {
106    writer: BufWriter<Box<dyn Write + Send>>,
107    flush_on_write: bool,
108}
109
110/// Shared, line-oriented JSONL sink for evidence logging.
111#[derive(Clone)]
112pub struct EvidenceSink {
113    inner: Arc<Mutex<EvidenceSinkInner>>,
114}
115
116impl std::fmt::Debug for EvidenceSink {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        f.debug_struct("EvidenceSink").finish()
119    }
120}
121
122impl EvidenceSink {
123    /// Build an evidence sink from config. Returns `Ok(None)` when disabled.
124    pub fn from_config(config: &EvidenceSinkConfig) -> io::Result<Option<Self>> {
125        if !config.enabled {
126            return Ok(None);
127        }
128
129        let writer: Box<dyn Write + Send> = match &config.destination {
130            EvidenceSinkDestination::Stdout => Box::new(io::stdout()),
131            EvidenceSinkDestination::File(path) => {
132                let file = OpenOptions::new().create(true).append(true).open(path)?;
133                Box::new(file)
134            }
135        };
136
137        let inner = EvidenceSinkInner {
138            writer: BufWriter::new(writer),
139            flush_on_write: config.flush_on_write,
140        };
141
142        Ok(Some(Self {
143            inner: Arc::new(Mutex::new(inner)),
144        }))
145    }
146
147    /// Write a single JSONL line with newline and optional flush.
148    pub fn write_jsonl(&self, line: &str) -> io::Result<()> {
149        let mut inner = match self.inner.lock() {
150            Ok(guard) => guard,
151            Err(poisoned) => poisoned.into_inner(),
152        };
153        inner.writer.write_all(line.as_bytes())?;
154        inner.writer.write_all(b"\n")?;
155        if inner.flush_on_write {
156            inner.writer.flush()?;
157        }
158        Ok(())
159    }
160
161    /// Flush any buffered output.
162    pub fn flush(&self) -> io::Result<()> {
163        let mut inner = match self.inner.lock() {
164            Ok(guard) => guard,
165            Err(poisoned) => poisoned.into_inner(),
166        };
167        inner.writer.flush()
168    }
169}