procyon 0.1.2

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
//! Diagnostics sink for anomalies that must not be silent but also must not reach the UI.
//!
//! A TUI owns the terminal, so `eprintln!` corrupts the frame instead of informing anyone. The
//! cases this exists for — bytes dropped from a stream, frontmatter this parser cannot model —
//! are recoverable by construction: the run continues, and losing the record of what was dropped
//! is what turns a lossy recovery into an unexplainable one.
//!
//! Warnings accumulate in memory (bounded) and, when `PROCYON_LOG` names a file, are appended
//! there as they happen. Tests read them back with [`drain`].

use std::io::Write;
use std::sync::{Mutex, OnceLock};

/// Beyond this the oldest warnings are dropped: a stream that goes bad tends to go bad per chunk,
/// and an unbounded sink would then grow with the response.
const MAX_RETAINED: usize = 256;

fn sink() -> &'static Mutex<Vec<String>> {
    static SINK: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
    SINK.get_or_init(|| Mutex::new(Vec::new()))
}

/// Records a warning. Never fails and never blocks on I/O the caller cares about.
pub fn warn(message: impl Into<String>) {
    let message = message.into();
    append_to_file(&message);

    if let Ok(mut warnings) = sink().lock() {
        if warnings.len() >= MAX_RETAINED {
            warnings.remove(0);
        }
        warnings.push(message);
    }
}

/// Takes the warnings recorded so far, leaving the sink empty.
// The sink's readers are the tests and whatever surfaces diagnostics later; `warn` is the half
// that has callers today.
#[allow(dead_code)]
pub fn drain() -> Vec<String> {
    sink()
        .lock()
        .map(|mut warnings| std::mem::take(&mut *warnings))
        .unwrap_or_default()
}

/// Returns the number of warnings currently retained.
#[allow(dead_code)]
pub fn count() -> usize {
    sink().lock().map(|w| w.len()).unwrap_or(0)
}

// Opt-in and best-effort: a diagnostics channel that can itself fail the run would be worse than
// no channel, and writing to the user's data directory unasked is not this module's call.
fn append_to_file(message: &str) {
    let Ok(path) = std::env::var("PROCYON_LOG") else {
        return;
    };
    if path.is_empty() {
        return;
    }

    let line = format!("{} {}\n", chrono::Utc::now().to_rfc3339(), message);
    if let Ok(mut file) = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(&path)
    {
        let _ = file.write_all(line.as_bytes());
    }
}

/// Serializes the tests that assert on the sink's contents.
///
/// The sink is process-wide, so any test that drains it would otherwise steal the warnings of a
/// test running in parallel. Every such test takes this first.
#[cfg(test)]
pub fn test_lock() -> std::sync::MutexGuard<'static, ()> {
    static EXCLUSIVE: Mutex<()> = Mutex::new(());
    EXCLUSIVE.lock().unwrap_or_else(|e| e.into_inner())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_warning_is_retained_and_drained_once() {
        let _guard = test_lock();
        drain();
        warn("first");
        warn("second");

        let warnings = drain();
        assert_eq!(warnings, vec!["first", "second"]);
        assert!(
            drain().is_empty(),
            "draining twice must not repeat warnings"
        );
    }

    #[test]
    fn the_sink_is_bounded() {
        let _guard = test_lock();
        drain();
        for i in 0..MAX_RETAINED + 10 {
            warn(format!("w{}", i));
        }
        assert_eq!(count(), MAX_RETAINED);
        drain();
    }
}