1#![forbid(unsafe_code)]
2
3use std::fs::{self, OpenOptions};
17use std::io::{self, BufWriter, Write};
18use std::path::PathBuf;
19use std::sync::{Arc, Mutex};
20
21pub const EVIDENCE_SCHEMA_VERSION: &str = "ftui-evidence-v1";
23
24pub const DEFAULT_MAX_EVIDENCE_BYTES: u64 = 50 * 1024 * 1024;
26
27#[derive(Debug, Clone)]
29pub enum EvidenceSinkDestination {
30 Stdout,
32 File(PathBuf),
34}
35
36impl EvidenceSinkDestination {
37 #[must_use]
39 pub fn file(path: impl Into<PathBuf>) -> Self {
40 Self::File(path.into())
41 }
42}
43
44#[derive(Debug, Clone)]
46pub struct EvidenceSinkConfig {
47 pub enabled: bool,
49 pub destination: EvidenceSinkDestination,
51 pub flush_on_write: bool,
53 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 #[must_use]
73 pub fn disabled() -> Self {
74 Self::default()
75 }
76
77 #[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 #[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 #[must_use]
101 pub fn with_enabled(mut self, enabled: bool) -> Self {
102 self.enabled = enabled;
103 self
104 }
105
106 #[must_use]
108 pub fn with_destination(mut self, destination: EvidenceSinkDestination) -> Self {
109 self.destination = destination;
110 self
111 }
112
113 #[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 #[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 max_bytes: u64,
134 cap_enabled: bool,
136 bytes_written: u64,
138 capped: bool,
140}
141
142#[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 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 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 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); let new_total = inner.bytes_written.checked_add(line_bytes);
215
216 if inner.cap_enabled && inner.max_bytes > 0 && new_total.is_none_or(|n| n > inner.max_bytes)
218 {
219 inner.capped = true;
220 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 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 let config = EvidenceSinkConfig::enabled_file(&path).with_max_bytes(100);
367 let sink = EvidenceSink::from_config(&config).unwrap().unwrap();
368
369 for i in 0..100 {
371 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 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 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 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}