ftui_runtime/
evidence_sink.rs1#![forbid(unsafe_code)]
2
3use std::fs::OpenOptions;
11use std::io::{self, BufWriter, Write};
12use std::path::PathBuf;
13use std::sync::{Arc, Mutex};
14
15pub const EVIDENCE_SCHEMA_VERSION: &str = "ftui-evidence-v1";
17
18#[derive(Debug, Clone)]
20pub enum EvidenceSinkDestination {
21 Stdout,
23 File(PathBuf),
25}
26
27impl EvidenceSinkDestination {
28 #[must_use]
30 pub fn file(path: impl Into<PathBuf>) -> Self {
31 Self::File(path.into())
32 }
33}
34
35#[derive(Debug, Clone)]
37pub struct EvidenceSinkConfig {
38 pub enabled: bool,
40 pub destination: EvidenceSinkDestination,
42 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 #[must_use]
59 pub fn disabled() -> Self {
60 Self::default()
61 }
62
63 #[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 #[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 #[must_use]
85 pub fn with_enabled(mut self, enabled: bool) -> Self {
86 self.enabled = enabled;
87 self
88 }
89
90 #[must_use]
92 pub fn with_destination(mut self, destination: EvidenceSinkDestination) -> Self {
93 self.destination = destination;
94 self
95 }
96
97 #[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#[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 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 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 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}