1use serde::{Deserialize, Serialize};
30use std::io::Write;
31use std::path::PathBuf;
32use std::sync::{Mutex, Once};
33use std::time::Instant;
34use tracing_subscriber::fmt::format::FmtSpan;
35use tracing_subscriber::prelude::*;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum TestPhase {
41 Setup,
43 Execute,
45 Verify,
47 Teardown,
49}
50
51impl std::fmt::Display for TestPhase {
52 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 match self {
54 Self::Setup => write!(f, "setup"),
55 Self::Execute => write!(f, "execute"),
56 Self::Verify => write!(f, "verify"),
57 Self::Teardown => write!(f, "teardown"),
58 }
59 }
60}
61
62#[allow(dead_code)]
66static GLOBAL_LOGGING_INIT: Once = Once::new();
67
68#[allow(dead_code)]
94pub fn init_global_test_logging() {
95 GLOBAL_LOGGING_INIT.call_once(|| {
96 let log_file = create_global_log_file();
97
98 let file_layer = log_file.map(|file| {
100 tracing_subscriber::fmt::layer()
101 .json()
102 .with_writer(Mutex::new(file))
103 .with_span_events(FmtSpan::CLOSE)
104 .with_current_span(true)
105 .with_thread_ids(true)
106 .with_file(true)
107 .with_line_number(true)
108 });
109
110 let stderr_layer = tracing_subscriber::fmt::layer()
112 .with_test_writer()
113 .with_target(true)
114 .with_level(true)
115 .compact();
116
117 let level = std::env::var("RCH_TEST_LOG_LEVEL").unwrap_or_else(|_| "info".to_string());
119 let filter = tracing_subscriber::EnvFilter::try_new(format!("rch={level},rchd={level}"))
120 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
121
122 let subscriber = tracing_subscriber::registry()
123 .with(filter)
124 .with(file_layer)
125 .with(stderr_layer);
126
127 let _ = tracing::subscriber::set_global_default(subscriber);
128 });
129}
130
131#[allow(dead_code)]
133fn create_global_log_file() -> Option<std::fs::File> {
134 if let Ok(custom_path) = std::env::var("RCH_TEST_LOG_FILE") {
136 if let Some(parent) = PathBuf::from(&custom_path).parent() {
137 let _ = std::fs::create_dir_all(parent);
138 }
139 return std::fs::File::create(&custom_path).ok();
140 }
141
142 let log_dir = find_target_dir().join("test-logs");
144 let _ = std::fs::create_dir_all(&log_dir);
145 std::fs::File::create(log_dir.join("all_tests.jsonl")).ok()
146}
147
148#[allow(dead_code)]
150fn find_target_dir() -> PathBuf {
151 if let Ok(target_dir) = std::env::var("CARGO_TARGET_DIR") {
152 return PathBuf::from(target_dir);
153 }
154
155 let mut cwd = std::env::current_dir().unwrap_or_default();
156 loop {
157 let target = cwd.join("target");
158 if target.is_dir() {
159 return target;
160 }
161 if !cwd.pop() {
162 return PathBuf::from("target");
163 }
164 }
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct TestLogEntry {
170 pub timestamp: String,
172 pub test_name: String,
174 pub phase: TestPhase,
176 pub message: String,
178 #[serde(skip_serializing_if = "Option::is_none")]
180 pub data: Option<serde_json::Value>,
181 #[serde(skip_serializing_if = "Option::is_none")]
183 pub duration_ms: Option<u64>,
184}
185
186impl TestLogEntry {
187 pub fn new(test_name: &str, phase: TestPhase, message: impl Into<String>) -> Self {
189 Self {
190 timestamp: chrono::Utc::now().to_rfc3339(),
191 test_name: test_name.to_string(),
192 phase,
193 message: message.into(),
194 data: None,
195 duration_ms: None,
196 }
197 }
198
199 #[must_use]
201 pub fn with_data(mut self, data: serde_json::Value) -> Self {
202 self.data = Some(data);
203 self
204 }
205
206 #[must_use]
208 pub fn with_duration(mut self, duration_ms: u64) -> Self {
209 self.duration_ms = Some(duration_ms);
210 self
211 }
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct TestResult {
217 pub test_name: String,
219 pub passed: bool,
221 pub duration_ms: u64,
223 #[serde(skip_serializing_if = "Option::is_none")]
225 pub stdout: Option<String>,
226 #[serde(skip_serializing_if = "Option::is_none")]
228 pub stderr: Option<String>,
229 #[serde(skip_serializing_if = "Option::is_none")]
231 pub exit_code: Option<i32>,
232 #[serde(skip_serializing_if = "Option::is_none")]
234 pub env: Option<std::collections::HashMap<String, String>>,
235 #[serde(skip_serializing_if = "Option::is_none")]
237 pub terminal_info: Option<TerminalInfo>,
238 pub logs: Vec<TestLogEntry>,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct TerminalInfo {
245 pub stdout_tty: bool,
247 pub stderr_tty: bool,
249 #[serde(skip_serializing_if = "Option::is_none")]
251 pub term: Option<String>,
252 pub no_color: bool,
254 pub force_color: bool,
256 #[serde(skip_serializing_if = "Option::is_none")]
258 pub width: Option<u16>,
259}
260
261impl TerminalInfo {
262 pub fn capture() -> Self {
264 use std::io::IsTerminal;
265
266 Self {
267 stdout_tty: std::io::stdout().is_terminal(),
268 stderr_tty: std::io::stderr().is_terminal(),
269 term: std::env::var("TERM").ok(),
270 no_color: std::env::var("NO_COLOR").is_ok(),
271 force_color: std::env::var("FORCE_COLOR").is_ok(),
272 width: terminal_width(),
273 }
274 }
275}
276
277fn terminal_width() -> Option<u16> {
279 if let Ok(cols) = std::env::var("COLUMNS")
281 && let Ok(w) = cols.parse()
282 {
283 return Some(w);
284 }
285 None
287}
288
289pub struct TestLogger {
293 test_name: String,
294 start_time: Instant,
295 logs: Mutex<Vec<TestLogEntry>>,
296 log_file: Option<Mutex<std::fs::File>>,
297}
298
299impl TestLogger {
300 pub fn for_test(test_name: &str) -> Self {
302 let log_file = Self::create_log_file(test_name).ok();
303
304 let logger = Self {
305 test_name: test_name.to_string(),
306 start_time: Instant::now(),
307 logs: Mutex::new(Vec::new()),
308 log_file: log_file.map(Mutex::new),
309 };
310
311 logger.log(TestPhase::Setup, "TEST START");
313
314 logger
315 }
316
317 fn create_log_file(test_name: &str) -> std::io::Result<std::fs::File> {
322 let log_dir = if let Ok(target_dir) = std::env::var("CARGO_TARGET_DIR") {
324 PathBuf::from(target_dir).join("test-logs")
325 } else {
326 let mut cwd = std::env::current_dir().unwrap_or_default();
328 loop {
329 let target = cwd.join("target");
330 if target.is_dir() {
331 break target.join("test-logs");
332 }
333 if !cwd.pop() {
334 break PathBuf::from("target/test-logs");
336 }
337 }
338 };
339
340 std::fs::create_dir_all(&log_dir)?;
341
342 let safe_name = test_name.replace("::", "_").replace(['/', '\\'], "_");
343 let log_path = log_dir.join(format!("{safe_name}.jsonl"));
344
345 std::fs::File::create(log_path)
346 }
347
348 pub fn log(&self, phase: TestPhase, message: impl Into<String>) {
350 let duration_ms = self.start_time.elapsed().as_millis() as u64;
351 let entry = TestLogEntry::new(&self.test_name, phase, message).with_duration(duration_ms);
352
353 self.write_entry(&entry);
354 }
355
356 pub fn log_with_data(
358 &self,
359 phase: TestPhase,
360 message: impl Into<String>,
361 data: serde_json::Value,
362 ) {
363 let duration_ms = self.start_time.elapsed().as_millis() as u64;
364 let entry = TestLogEntry::new(&self.test_name, phase, message)
365 .with_duration(duration_ms)
366 .with_data(data);
367
368 self.write_entry(&entry);
369 }
370
371 fn write_entry(&self, entry: &TestLogEntry) {
373 if let Ok(mut logs) = self.logs.lock() {
375 logs.push(entry.clone());
376 }
377
378 if let Some(file) = &self.log_file
380 && let Ok(mut f) = file.lock()
381 && let Ok(json) = serde_json::to_string(entry)
382 {
383 let _ = writeln!(f, "{json}");
384 }
385
386 tracing::info!(
388 test = %self.test_name,
389 phase = %entry.phase,
390 duration_ms = entry.duration_ms,
391 "{}",
392 entry.message
393 );
394 }
395
396 pub fn pass(self) {
398 self.log(TestPhase::Verify, "TEST PASS");
399 }
400
401 pub fn fail(self, reason: impl Into<String>) {
403 self.log_with_data(
404 TestPhase::Verify,
405 "TEST FAIL",
406 serde_json::json!({ "reason": reason.into() }),
407 );
408 }
409
410 pub fn elapsed_ms(&self) -> u64 {
412 self.start_time.elapsed().as_millis() as u64
413 }
414
415 pub fn into_result(self, passed: bool) -> TestResult {
417 let duration_ms = self.elapsed_ms();
418 let logs = self.logs.lock().map(|l| l.clone()).unwrap_or_default();
419
420 TestResult {
421 test_name: self.test_name.clone(),
422 passed,
423 duration_ms,
424 stdout: None,
425 stderr: None,
426 exit_code: None,
427 env: None,
428 terminal_info: Some(TerminalInfo::capture()),
429 logs,
430 }
431 }
432}
433
434pub struct TestGuard {
460 inner: Option<TestLogger>,
461}
462
463impl TestGuard {
464 pub fn new(test_name: &str) -> Self {
469 let enabled = Self::is_enabled();
470 Self {
471 inner: if enabled {
472 init_global_test_logging();
473 Some(TestLogger::for_test(test_name))
474 } else {
475 None
476 },
477 }
478 }
479
480 fn is_enabled() -> bool {
486 match std::env::var("RCH_TEST_LOGGING").as_deref() {
487 Ok("1" | "true") => true,
488 Ok("0" | "false") => false,
489 _ => {
490 std::env::var("CI").is_ok()
492 }
493 }
494 }
495
496 pub fn log(&self, phase: TestPhase, message: impl Into<String>) {
498 if let Some(logger) = &self.inner {
499 logger.log(phase, message);
500 }
501 }
502
503 pub fn log_with_data(
505 &self,
506 phase: TestPhase,
507 message: impl Into<String>,
508 data: serde_json::Value,
509 ) {
510 if let Some(logger) = &self.inner {
511 logger.log_with_data(phase, message, data);
512 }
513 }
514}
515
516impl Drop for TestGuard {
517 fn drop(&mut self) {
518 if let Some(logger) = self.inner.take() {
519 if std::thread::panicking() {
520 logger.fail("test panicked");
521 } else {
522 logger.pass();
523 }
524 }
525 }
526}
527
528#[macro_export]
544macro_rules! test_guard {
545 () => {{
546 fn _f() {}
547 fn _type_name_of<T>(_: T) -> &'static str {
548 std::any::type_name::<T>()
549 }
550 let name = _type_name_of(_f);
551 let name = name.strip_suffix("::_f").unwrap_or(name);
552 let name = name.rsplit("::").next().unwrap_or(name);
553 $crate::testing::TestGuard::new(name)
554 }};
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560
561 #[test]
562 fn test_log_entry_serializes_correctly() {
563 let entry = TestLogEntry::new("test_example", TestPhase::Setup, "Hello")
564 .with_duration(42)
565 .with_data(serde_json::json!({"key": "value"}));
566
567 let json = serde_json::to_string(&entry).unwrap();
568 assert!(json.contains("test_example"));
569 assert!(json.contains("setup"));
570 assert!(json.contains("Hello"));
571 assert!(json.contains("42"));
572 assert!(json.contains("key"));
573 }
574
575 #[test]
576 fn test_logger_creates_entries() {
577 let logger = TestLogger::for_test("test_logger_creates_entries");
578 logger.log(TestPhase::Execute, "Running test");
579 logger.log(TestPhase::Verify, "Checking result");
580
581 let result = logger.into_result(true);
582 assert_eq!(result.test_name, "test_logger_creates_entries");
583 assert!(result.passed);
584 assert!(result.logs.len() >= 3); }
586
587 #[test]
588 fn test_terminal_info_captures_env() {
589 let info = TerminalInfo::capture();
590 let _ = info.stdout_tty;
593 let _ = info.stderr_tty;
594 }
595
596 #[test]
597 fn test_phase_display() {
598 assert_eq!(TestPhase::Setup.to_string(), "setup");
599 assert_eq!(TestPhase::Execute.to_string(), "execute");
600 assert_eq!(TestPhase::Verify.to_string(), "verify");
601 assert_eq!(TestPhase::Teardown.to_string(), "teardown");
602 }
603
604 #[test]
605 fn test_guard_creates_without_panic() {
606 let _guard = TestGuard::new("test_guard_creates_without_panic");
609 }
611
612 #[test]
613 fn test_guard_logs_messages_when_enabled() {
614 let guard = TestGuard {
616 inner: Some(TestLogger::for_test("test_guard_logs_enabled")),
617 };
618 guard.log(TestPhase::Execute, "Test message");
619 guard.log_with_data(
620 TestPhase::Verify,
621 "With data",
622 serde_json::json!({"key": "value"}),
623 );
624 }
626
627 #[test]
628 fn test_guard_disabled_is_noop() {
629 let guard = TestGuard { inner: None };
631 guard.log(TestPhase::Execute, "This is a no-op");
633 guard.log_with_data(
634 TestPhase::Verify,
635 "Also a no-op",
636 serde_json::json!({"key": "value"}),
637 );
638 }
640
641 #[test]
642 fn test_guard_drop_logs_pass_on_success() {
643 let guard = TestGuard {
645 inner: Some(TestLogger::for_test("test_guard_drop_pass")),
646 };
647 guard.log(TestPhase::Execute, "Before drop");
649 }
651}