#![forbid(unsafe_code)]
use std::fs::OpenOptions;
use std::io::{self, BufWriter, Write};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
pub const EVIDENCE_SCHEMA_VERSION: &str = "ftui-evidence-v1";
pub const DEFAULT_MAX_EVIDENCE_BYTES: u64 = 50 * 1024 * 1024;
#[derive(Debug, Clone)]
pub enum EvidenceSinkDestination {
Stdout,
File(PathBuf),
}
impl EvidenceSinkDestination {
#[must_use]
pub fn file(path: impl Into<PathBuf>) -> Self {
Self::File(path.into())
}
}
#[derive(Debug, Clone)]
pub struct EvidenceSinkConfig {
pub enabled: bool,
pub destination: EvidenceSinkDestination,
pub flush_on_write: bool,
pub max_bytes: u64,
}
impl Default for EvidenceSinkConfig {
fn default() -> Self {
Self {
enabled: false,
destination: EvidenceSinkDestination::Stdout,
flush_on_write: true,
max_bytes: DEFAULT_MAX_EVIDENCE_BYTES,
}
}
}
impl EvidenceSinkConfig {
#[must_use]
pub fn disabled() -> Self {
Self::default()
}
#[must_use]
pub fn enabled_stdout() -> Self {
Self {
enabled: true,
destination: EvidenceSinkDestination::Stdout,
flush_on_write: true,
max_bytes: DEFAULT_MAX_EVIDENCE_BYTES,
}
}
#[must_use]
pub fn enabled_file(path: impl Into<PathBuf>) -> Self {
Self {
enabled: true,
destination: EvidenceSinkDestination::file(path),
flush_on_write: true,
max_bytes: DEFAULT_MAX_EVIDENCE_BYTES,
}
}
#[must_use]
pub fn with_enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
#[must_use]
pub fn with_destination(mut self, destination: EvidenceSinkDestination) -> Self {
self.destination = destination;
self
}
#[must_use]
pub fn with_flush_on_write(mut self, enabled: bool) -> Self {
self.flush_on_write = enabled;
self
}
#[must_use]
pub fn with_max_bytes(mut self, max_bytes: u64) -> Self {
self.max_bytes = max_bytes;
self
}
}
struct EvidenceSinkInner {
writer: BufWriter<Box<dyn Write + Send>>,
flush_on_write: bool,
max_bytes: u64,
cap_enabled: bool,
bytes_written: u64,
capped: bool,
}
#[derive(Clone)]
pub struct EvidenceSink {
inner: Arc<Mutex<EvidenceSinkInner>>,
}
impl std::fmt::Debug for EvidenceSink {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EvidenceSink").finish()
}
}
impl EvidenceSink {
pub fn from_config(config: &EvidenceSinkConfig) -> io::Result<Option<Self>> {
if !config.enabled {
return Ok(None);
}
let (writer, existing_bytes): (Box<dyn Write + Send>, u64) = match &config.destination {
EvidenceSinkDestination::Stdout => (Box::new(io::stdout()), 0),
EvidenceSinkDestination::File(path) => {
let existing_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
let file = OpenOptions::new().create(true).append(true).open(path)?;
(Box::new(file), existing_size)
}
};
let cap_enabled = matches!(&config.destination, EvidenceSinkDestination::File(_));
let already_capped =
cap_enabled && config.max_bytes > 0 && existing_bytes >= config.max_bytes;
let inner = EvidenceSinkInner {
writer: BufWriter::new(writer),
flush_on_write: config.flush_on_write,
max_bytes: config.max_bytes,
cap_enabled,
bytes_written: existing_bytes,
capped: already_capped,
};
Ok(Some(Self {
inner: Arc::new(Mutex::new(inner)),
}))
}
pub fn write_jsonl(&self, line: &str) -> io::Result<()> {
let mut inner = match self.inner.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
if inner.capped {
return Ok(());
}
let line_bytes = line.len() as u64 + 1;
if inner.cap_enabled
&& inner.max_bytes > 0
&& inner.bytes_written + line_bytes > inner.max_bytes
{
inner.capped = true;
let _ = inner.writer.flush();
return Ok(());
}
inner.writer.write_all(line.as_bytes())?;
inner.writer.write_all(b"\n")?;
inner.bytes_written += line_bytes;
if inner.flush_on_write {
inner.writer.flush()?;
}
Ok(())
}
pub fn flush(&self) -> io::Result<()> {
let mut inner = match self.inner.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
inner.writer.flush()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_version_stable() {
assert_eq!(EVIDENCE_SCHEMA_VERSION, "ftui-evidence-v1");
}
#[test]
fn config_default_is_disabled() {
let config = EvidenceSinkConfig::default();
assert!(!config.enabled);
assert!(config.flush_on_write);
assert!(matches!(
config.destination,
EvidenceSinkDestination::Stdout
));
}
#[test]
fn config_disabled_matches_default() {
let config = EvidenceSinkConfig::disabled();
assert!(!config.enabled);
}
#[test]
fn config_enabled_stdout() {
let config = EvidenceSinkConfig::enabled_stdout();
assert!(config.enabled);
assert!(config.flush_on_write);
assert!(matches!(
config.destination,
EvidenceSinkDestination::Stdout
));
}
#[test]
fn config_enabled_file() {
let config = EvidenceSinkConfig::enabled_file("/tmp/test.jsonl");
assert!(config.enabled);
assert!(config.flush_on_write);
assert!(matches!(
config.destination,
EvidenceSinkDestination::File(_)
));
}
#[test]
fn config_builder_chain() {
let config = EvidenceSinkConfig::default()
.with_enabled(true)
.with_destination(EvidenceSinkDestination::Stdout)
.with_flush_on_write(false);
assert!(config.enabled);
assert!(!config.flush_on_write);
}
#[test]
fn destination_file_helper() {
let dest = EvidenceSinkDestination::file("/tmp/evidence.jsonl");
assert!(
matches!(dest, EvidenceSinkDestination::File(p) if p.to_str() == Some("/tmp/evidence.jsonl"))
);
}
#[test]
fn disabled_config_returns_none() {
let config = EvidenceSinkConfig::disabled();
let sink = EvidenceSink::from_config(&config).unwrap();
assert!(sink.is_none());
}
#[test]
fn enabled_file_sink_writes_jsonl() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let config = EvidenceSinkConfig::enabled_file(&path);
let sink = EvidenceSink::from_config(&config).unwrap().unwrap();
sink.write_jsonl(r#"{"event":"test","value":1}"#).unwrap();
sink.write_jsonl(r#"{"event":"test","value":2}"#).unwrap();
sink.flush().unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0], r#"{"event":"test","value":1}"#);
assert_eq!(lines[1], r#"{"event":"test","value":2}"#);
}
#[test]
fn sink_is_clone_and_shared() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let config = EvidenceSinkConfig::enabled_file(&path);
let sink = EvidenceSink::from_config(&config).unwrap().unwrap();
let sink2 = sink.clone();
sink.write_jsonl(r#"{"from":"sink1"}"#).unwrap();
sink2.write_jsonl(r#"{"from":"sink2"}"#).unwrap();
sink.flush().unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 2);
}
#[test]
fn sink_debug_impl() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let config = EvidenceSinkConfig::enabled_file(tmp.path());
let sink = EvidenceSink::from_config(&config).unwrap().unwrap();
let debug = format!("{:?}", sink);
assert!(debug.contains("EvidenceSink"));
}
#[test]
fn file_sink_caps_at_max_bytes() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let config = EvidenceSinkConfig::enabled_file(&path).with_max_bytes(100);
let sink = EvidenceSink::from_config(&config).unwrap().unwrap();
for i in 0..100 {
sink.write_jsonl(&format!(r#"{{"event":"test","i":{i}}}"#))
.unwrap();
}
sink.flush().unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let size = content.len();
assert!(
size <= 100,
"file should not exceed cap of 100 bytes, got {size}"
);
assert!(!content.is_empty(), "at least one line should be written");
}
#[test]
fn file_sink_caps_on_preexisting_large_file() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
std::fs::write(&path, "x".repeat(200)).unwrap();
let config = EvidenceSinkConfig::enabled_file(&path).with_max_bytes(100);
let sink = EvidenceSink::from_config(&config).unwrap().unwrap();
sink.write_jsonl(r#"{"event":"should_be_dropped"}"#)
.unwrap();
sink.flush().unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(
!content.contains("should_be_dropped"),
"no new data should be written to an already-oversized file"
);
}
#[test]
fn unlimited_max_bytes_allows_unbounded_writes() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let config = EvidenceSinkConfig::enabled_file(&path).with_max_bytes(0);
let sink = EvidenceSink::from_config(&config).unwrap().unwrap();
for i in 0..1000 {
sink.write_jsonl(&format!(r#"{{"i":{i}}}"#)).unwrap();
}
sink.flush().unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 1000, "all 1000 lines should be written");
}
#[test]
fn default_max_bytes_is_50mib() {
let config = EvidenceSinkConfig::default();
assert_eq!(config.max_bytes, DEFAULT_MAX_EVIDENCE_BYTES);
assert_eq!(config.max_bytes, 50 * 1024 * 1024);
}
}