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}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn schema_version_stable() {
177        assert_eq!(EVIDENCE_SCHEMA_VERSION, "ftui-evidence-v1");
178    }
179
180    #[test]
181    fn config_default_is_disabled() {
182        let config = EvidenceSinkConfig::default();
183        assert!(!config.enabled);
184        assert!(config.flush_on_write);
185        assert!(matches!(
186            config.destination,
187            EvidenceSinkDestination::Stdout
188        ));
189    }
190
191    #[test]
192    fn config_disabled_matches_default() {
193        let config = EvidenceSinkConfig::disabled();
194        assert!(!config.enabled);
195    }
196
197    #[test]
198    fn config_enabled_stdout() {
199        let config = EvidenceSinkConfig::enabled_stdout();
200        assert!(config.enabled);
201        assert!(config.flush_on_write);
202        assert!(matches!(
203            config.destination,
204            EvidenceSinkDestination::Stdout
205        ));
206    }
207
208    #[test]
209    fn config_enabled_file() {
210        let config = EvidenceSinkConfig::enabled_file("/tmp/test.jsonl");
211        assert!(config.enabled);
212        assert!(config.flush_on_write);
213        assert!(matches!(
214            config.destination,
215            EvidenceSinkDestination::File(_)
216        ));
217    }
218
219    #[test]
220    fn config_builder_chain() {
221        let config = EvidenceSinkConfig::default()
222            .with_enabled(true)
223            .with_destination(EvidenceSinkDestination::Stdout)
224            .with_flush_on_write(false);
225        assert!(config.enabled);
226        assert!(!config.flush_on_write);
227    }
228
229    #[test]
230    fn destination_file_helper() {
231        let dest = EvidenceSinkDestination::file("/tmp/evidence.jsonl");
232        assert!(
233            matches!(dest, EvidenceSinkDestination::File(p) if p.to_str() == Some("/tmp/evidence.jsonl"))
234        );
235    }
236
237    #[test]
238    fn disabled_config_returns_none() {
239        let config = EvidenceSinkConfig::disabled();
240        let sink = EvidenceSink::from_config(&config).unwrap();
241        assert!(sink.is_none());
242    }
243
244    #[test]
245    fn enabled_file_sink_writes_jsonl() {
246        let tmp = tempfile::NamedTempFile::new().unwrap();
247        let path = tmp.path().to_path_buf();
248        let config = EvidenceSinkConfig::enabled_file(&path);
249        let sink = EvidenceSink::from_config(&config).unwrap().unwrap();
250
251        sink.write_jsonl(r#"{"event":"test","value":1}"#).unwrap();
252        sink.write_jsonl(r#"{"event":"test","value":2}"#).unwrap();
253        sink.flush().unwrap();
254
255        let content = std::fs::read_to_string(&path).unwrap();
256        let lines: Vec<&str> = content.lines().collect();
257        assert_eq!(lines.len(), 2);
258        assert_eq!(lines[0], r#"{"event":"test","value":1}"#);
259        assert_eq!(lines[1], r#"{"event":"test","value":2}"#);
260    }
261
262    #[test]
263    fn sink_is_clone_and_shared() {
264        let tmp = tempfile::NamedTempFile::new().unwrap();
265        let path = tmp.path().to_path_buf();
266        let config = EvidenceSinkConfig::enabled_file(&path);
267        let sink = EvidenceSink::from_config(&config).unwrap().unwrap();
268        let sink2 = sink.clone();
269
270        sink.write_jsonl(r#"{"from":"sink1"}"#).unwrap();
271        sink2.write_jsonl(r#"{"from":"sink2"}"#).unwrap();
272        sink.flush().unwrap();
273
274        let content = std::fs::read_to_string(&path).unwrap();
275        let lines: Vec<&str> = content.lines().collect();
276        assert_eq!(lines.len(), 2);
277    }
278
279    #[test]
280    fn sink_debug_impl() {
281        let tmp = tempfile::NamedTempFile::new().unwrap();
282        let config = EvidenceSinkConfig::enabled_file(tmp.path());
283        let sink = EvidenceSink::from_config(&config).unwrap().unwrap();
284        let debug = format!("{:?}", sink);
285        assert!(debug.contains("EvidenceSink"));
286    }
287}