santh-tracing 0.2.0

Consistent tracing setup for CLI tools - stderr/JSON/file sinks, a secret-redacting writer, and operation spans
Documentation
//! A [`std::io::Write`] adapter that redacts secrets from log output.
//!
//! The Santh safe-defaults contract requires that secrets are never written
//! to logs. `RedactingWriter` wraps any writer and runs each complete line
//! through [`santh_error::redact_secrets`] - the single, canonical redaction
//! routine shared across the ecosystem - before forwarding it downstream.
//! Partial (newline-less) input is buffered and redacted on [`flush`](std::io::Write::flush)
//! so a secret split across writes is never emitted in the clear.

use std::io::{self, Write};

use santh_error::redact_secrets;

/// Wraps a writer and redacts known secret shapes from every line written
/// through it. Redaction is line-oriented: complete lines are redacted and
/// forwarded immediately; a trailing partial line is held until the next
/// newline or until [`flush`](Write::flush).
pub struct RedactingWriter<W: Write> {
    inner: W,
    pending: Vec<u8>,
}

impl<W: Write> RedactingWriter<W> {
    /// Wrap `inner`, redacting secrets from everything written through it.
    pub fn new(inner: W) -> Self {
        Self {
            inner,
            pending: Vec::new(),
        }
    }

    /// Redact and forward every complete (newline-terminated) line currently
    /// buffered, leaving any trailing partial line pending.
    fn forward_complete_lines(&mut self) -> io::Result<()> {
        while let Some(newline) = self.pending.iter().position(|&b| b == b'\n') {
            let line: Vec<u8> = self.pending.drain(..=newline).collect();
            let redacted = redact_secrets(&String::from_utf8_lossy(&line));
            self.inner.write_all(redacted.as_bytes())?;
        }
        Ok(())
    }
}

impl<W: Write> Write for RedactingWriter<W> {
    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
        self.pending.extend_from_slice(buf);
        self.forward_complete_lines()?;
        Ok(buf.len())
    }

    fn flush(&mut self) -> io::Result<()> {
        if !self.pending.is_empty() {
            let redacted = redact_secrets(&String::from_utf8_lossy(&self.pending));
            self.inner.write_all(redacted.as_bytes())?;
            self.pending.clear();
        }
        self.inner.flush()
    }
}