use serde::{Deserialize, Serialize};
use std::io::Write;
use std::path::PathBuf;
use std::sync::{Mutex, Once};
use std::time::Instant;
use tracing_subscriber::fmt::format::FmtSpan;
use tracing_subscriber::prelude::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TestPhase {
Setup,
Execute,
Verify,
Teardown,
}
impl std::fmt::Display for TestPhase {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Setup => write!(f, "setup"),
Self::Execute => write!(f, "execute"),
Self::Verify => write!(f, "verify"),
Self::Teardown => write!(f, "teardown"),
}
}
}
#[allow(dead_code)]
static GLOBAL_LOGGING_INIT: Once = Once::new();
#[allow(dead_code)]
pub fn init_global_test_logging() {
GLOBAL_LOGGING_INIT.call_once(|| {
let log_file = create_global_log_file();
let file_layer = log_file.map(|file| {
tracing_subscriber::fmt::layer()
.json()
.with_writer(Mutex::new(file))
.with_span_events(FmtSpan::CLOSE)
.with_current_span(true)
.with_thread_ids(true)
.with_file(true)
.with_line_number(true)
});
let stderr_layer = tracing_subscriber::fmt::layer()
.with_test_writer()
.with_target(true)
.with_level(true)
.compact();
let level = std::env::var("RCH_TEST_LOG_LEVEL").unwrap_or_else(|_| "info".to_string());
let filter = tracing_subscriber::EnvFilter::try_new(format!("rch={level},rchd={level}"))
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
let subscriber = tracing_subscriber::registry()
.with(filter)
.with(file_layer)
.with(stderr_layer);
let _ = tracing::subscriber::set_global_default(subscriber);
});
}
#[allow(dead_code)]
fn create_global_log_file() -> Option<std::fs::File> {
if let Ok(custom_path) = std::env::var("RCH_TEST_LOG_FILE") {
if let Some(parent) = PathBuf::from(&custom_path).parent() {
let _ = std::fs::create_dir_all(parent);
}
return std::fs::File::create(&custom_path).ok();
}
let log_dir = find_target_dir().join("test-logs");
let _ = std::fs::create_dir_all(&log_dir);
std::fs::File::create(log_dir.join("all_tests.jsonl")).ok()
}
#[allow(dead_code)]
fn find_target_dir() -> PathBuf {
if let Ok(target_dir) = std::env::var("CARGO_TARGET_DIR") {
return PathBuf::from(target_dir);
}
let mut cwd = std::env::current_dir().unwrap_or_default();
loop {
let target = cwd.join("target");
if target.is_dir() {
return target;
}
if !cwd.pop() {
return PathBuf::from("target");
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestLogEntry {
pub timestamp: String,
pub test_name: String,
pub phase: TestPhase,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
}
impl TestLogEntry {
pub fn new(test_name: &str, phase: TestPhase, message: impl Into<String>) -> Self {
Self {
timestamp: chrono::Utc::now().to_rfc3339(),
test_name: test_name.to_string(),
phase,
message: message.into(),
data: None,
duration_ms: None,
}
}
#[must_use]
pub fn with_data(mut self, data: serde_json::Value) -> Self {
self.data = Some(data);
self
}
#[must_use]
pub fn with_duration(mut self, duration_ms: u64) -> Self {
self.duration_ms = Some(duration_ms);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestResult {
pub test_name: String,
pub passed: bool,
pub duration_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub stdout: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stderr: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub env: Option<std::collections::HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub terminal_info: Option<TerminalInfo>,
pub logs: Vec<TestLogEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TerminalInfo {
pub stdout_tty: bool,
pub stderr_tty: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub term: Option<String>,
pub no_color: bool,
pub force_color: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub width: Option<u16>,
}
impl TerminalInfo {
pub fn capture() -> Self {
use std::io::IsTerminal;
Self {
stdout_tty: std::io::stdout().is_terminal(),
stderr_tty: std::io::stderr().is_terminal(),
term: std::env::var("TERM").ok(),
no_color: std::env::var("NO_COLOR").is_ok(),
force_color: std::env::var("FORCE_COLOR").is_ok(),
width: terminal_width(),
}
}
}
fn terminal_width() -> Option<u16> {
if let Ok(cols) = std::env::var("COLUMNS")
&& let Ok(w) = cols.parse()
{
return Some(w);
}
None
}
pub struct TestLogger {
test_name: String,
start_time: Instant,
logs: Mutex<Vec<TestLogEntry>>,
log_file: Option<Mutex<std::fs::File>>,
}
impl TestLogger {
pub fn for_test(test_name: &str) -> Self {
let log_file = Self::create_log_file(test_name).ok();
let logger = Self {
test_name: test_name.to_string(),
start_time: Instant::now(),
logs: Mutex::new(Vec::new()),
log_file: log_file.map(Mutex::new),
};
logger.log(TestPhase::Setup, "TEST START");
logger
}
fn create_log_file(test_name: &str) -> std::io::Result<std::fs::File> {
let log_dir = if let Ok(target_dir) = std::env::var("CARGO_TARGET_DIR") {
PathBuf::from(target_dir).join("test-logs")
} else {
let mut cwd = std::env::current_dir().unwrap_or_default();
loop {
let target = cwd.join("target");
if target.is_dir() {
break target.join("test-logs");
}
if !cwd.pop() {
break PathBuf::from("target/test-logs");
}
}
};
std::fs::create_dir_all(&log_dir)?;
let safe_name = test_name.replace("::", "_").replace(['/', '\\'], "_");
let log_path = log_dir.join(format!("{safe_name}.jsonl"));
std::fs::File::create(log_path)
}
pub fn log(&self, phase: TestPhase, message: impl Into<String>) {
let duration_ms = self.start_time.elapsed().as_millis() as u64;
let entry = TestLogEntry::new(&self.test_name, phase, message).with_duration(duration_ms);
self.write_entry(&entry);
}
pub fn log_with_data(
&self,
phase: TestPhase,
message: impl Into<String>,
data: serde_json::Value,
) {
let duration_ms = self.start_time.elapsed().as_millis() as u64;
let entry = TestLogEntry::new(&self.test_name, phase, message)
.with_duration(duration_ms)
.with_data(data);
self.write_entry(&entry);
}
fn write_entry(&self, entry: &TestLogEntry) {
if let Ok(mut logs) = self.logs.lock() {
logs.push(entry.clone());
}
if let Some(file) = &self.log_file
&& let Ok(mut f) = file.lock()
&& let Ok(json) = serde_json::to_string(entry)
{
let _ = writeln!(f, "{json}");
}
tracing::info!(
test = %self.test_name,
phase = %entry.phase,
duration_ms = entry.duration_ms,
"{}",
entry.message
);
}
pub fn pass(self) {
self.log(TestPhase::Verify, "TEST PASS");
}
pub fn fail(self, reason: impl Into<String>) {
self.log_with_data(
TestPhase::Verify,
"TEST FAIL",
serde_json::json!({ "reason": reason.into() }),
);
}
pub fn elapsed_ms(&self) -> u64 {
self.start_time.elapsed().as_millis() as u64
}
pub fn into_result(self, passed: bool) -> TestResult {
let duration_ms = self.elapsed_ms();
let logs = self.logs.lock().map(|l| l.clone()).unwrap_or_default();
TestResult {
test_name: self.test_name.clone(),
passed,
duration_ms,
stdout: None,
stderr: None,
exit_code: None,
env: None,
terminal_info: Some(TerminalInfo::capture()),
logs,
}
}
}
pub struct TestGuard {
inner: Option<TestLogger>,
}
impl TestGuard {
pub fn new(test_name: &str) -> Self {
let enabled = Self::is_enabled();
Self {
inner: if enabled {
init_global_test_logging();
Some(TestLogger::for_test(test_name))
} else {
None
},
}
}
fn is_enabled() -> bool {
match std::env::var("RCH_TEST_LOGGING").as_deref() {
Ok("1" | "true") => true,
Ok("0" | "false") => false,
_ => {
std::env::var("CI").is_ok()
}
}
}
pub fn log(&self, phase: TestPhase, message: impl Into<String>) {
if let Some(logger) = &self.inner {
logger.log(phase, message);
}
}
pub fn log_with_data(
&self,
phase: TestPhase,
message: impl Into<String>,
data: serde_json::Value,
) {
if let Some(logger) = &self.inner {
logger.log_with_data(phase, message, data);
}
}
}
impl Drop for TestGuard {
fn drop(&mut self) {
if let Some(logger) = self.inner.take() {
if std::thread::panicking() {
logger.fail("test panicked");
} else {
logger.pass();
}
}
}
}
#[macro_export]
macro_rules! test_guard {
() => {{
fn _f() {}
fn _type_name_of<T>(_: T) -> &'static str {
std::any::type_name::<T>()
}
let name = _type_name_of(_f);
let name = name.strip_suffix("::_f").unwrap_or(name);
let name = name.rsplit("::").next().unwrap_or(name);
$crate::testing::TestGuard::new(name)
}};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_log_entry_serializes_correctly() {
let entry = TestLogEntry::new("test_example", TestPhase::Setup, "Hello")
.with_duration(42)
.with_data(serde_json::json!({"key": "value"}));
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("test_example"));
assert!(json.contains("setup"));
assert!(json.contains("Hello"));
assert!(json.contains("42"));
assert!(json.contains("key"));
}
#[test]
fn test_logger_creates_entries() {
let logger = TestLogger::for_test("test_logger_creates_entries");
logger.log(TestPhase::Execute, "Running test");
logger.log(TestPhase::Verify, "Checking result");
let result = logger.into_result(true);
assert_eq!(result.test_name, "test_logger_creates_entries");
assert!(result.passed);
assert!(result.logs.len() >= 3); }
#[test]
fn test_terminal_info_captures_env() {
let info = TerminalInfo::capture();
let _ = info.stdout_tty;
let _ = info.stderr_tty;
}
#[test]
fn test_phase_display() {
assert_eq!(TestPhase::Setup.to_string(), "setup");
assert_eq!(TestPhase::Execute.to_string(), "execute");
assert_eq!(TestPhase::Verify.to_string(), "verify");
assert_eq!(TestPhase::Teardown.to_string(), "teardown");
}
#[test]
fn test_guard_creates_without_panic() {
let _guard = TestGuard::new("test_guard_creates_without_panic");
}
#[test]
fn test_guard_logs_messages_when_enabled() {
let guard = TestGuard {
inner: Some(TestLogger::for_test("test_guard_logs_enabled")),
};
guard.log(TestPhase::Execute, "Test message");
guard.log_with_data(
TestPhase::Verify,
"With data",
serde_json::json!({"key": "value"}),
);
}
#[test]
fn test_guard_disabled_is_noop() {
let guard = TestGuard { inner: None };
guard.log(TestPhase::Execute, "This is a no-op");
guard.log_with_data(
TestPhase::Verify,
"Also a no-op",
serde_json::json!({"key": "value"}),
);
}
#[test]
fn test_guard_drop_logs_pass_on_success() {
let guard = TestGuard {
inner: Some(TestLogger::for_test("test_guard_drop_pass")),
};
guard.log(TestPhase::Execute, "Before drop");
}
}