Skip to main content

rch_common/testing/
log.rs

1//! Structured test logging for CI debugging.
2//!
3//! Provides JSONL output for test execution to make debugging failed tests easier.
4//!
5//! # Global JSONL Logging
6//!
7//! For automatic JSONL output from all tests without code changes, call
8//! `init_global_test_logging()` once in your test setup:
9//!
10//! ```ignore
11//! use rch_common::testing::init_global_test_logging;
12//!
13//! #[ctor::ctor]
14//! fn setup() {
15//!     init_global_test_logging();
16//! }
17//! ```
18//!
19//! Or in individual tests:
20//!
21//! ```ignore
22//! #[test]
23//! fn test_example() {
24//!     init_global_test_logging();  // Safe to call multiple times
25//!     tracing::info!("This will be captured in JSONL");
26//! }
27//! ```
28
29use 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/// Test execution phases.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum TestPhase {
41    /// Test initialization.
42    Setup,
43    /// Main test execution.
44    Execute,
45    /// Result verification.
46    Verify,
47    /// Resource cleanup.
48    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// This is an opt-in API (typically called from other crates' tests).
63// In rch-common itself it may be unused, so we suppress dead_code warnings
64// to keep `cargo clippy -- -D warnings` clean.
65#[allow(dead_code)]
66static GLOBAL_LOGGING_INIT: Once = Once::new();
67
68/// Initialize global JSONL logging for all tests.
69///
70/// This sets up a tracing subscriber that outputs all events in JSON format to:
71/// - `target/test-logs/all_tests.jsonl` - aggregated JSONL output
72/// - stderr (with test_writer for cargo test visibility)
73///
74/// Safe to call multiple times - initialization only happens once.
75///
76/// # Environment Variables
77///
78/// - `RCH_TEST_LOG_FILE`: Override the log file path (default: `target/test-logs/all_tests.jsonl`)
79/// - `RCH_TEST_LOG_LEVEL`: Set log level filter (default: `info`)
80///
81/// # Example
82///
83/// ```ignore
84/// use rch_common::testing::init_global_test_logging;
85///
86/// #[test]
87/// fn test_with_logging() {
88///     init_global_test_logging();
89///     tracing::info!(key = "value", "Test message");
90///     // Output: {"timestamp":"...","level":"INFO","target":"...","fields":{"key":"value","message":"Test message"}}
91/// }
92/// ```
93#[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        // Create JSON formatting layer for file output
99        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        // Create human-readable layer for test output
111        let stderr_layer = tracing_subscriber::fmt::layer()
112            .with_test_writer()
113            .with_target(true)
114            .with_level(true)
115            .compact();
116
117        // Get log level from environment or default to info
118        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/// Create the global log file for all tests.
132#[allow(dead_code)]
133fn create_global_log_file() -> Option<std::fs::File> {
134    // Check for custom path
135    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    // Default: target/test-logs/all_tests.jsonl
143    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/// Find the target directory by searching up from current dir.
149#[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/// A structured log entry for test execution.
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct TestLogEntry {
170    /// ISO-8601 timestamp.
171    pub timestamp: String,
172    /// Name of the test.
173    pub test_name: String,
174    /// Current phase of test execution.
175    pub phase: TestPhase,
176    /// Log message.
177    pub message: String,
178    /// Optional structured data.
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub data: Option<serde_json::Value>,
181    /// Duration since test start in milliseconds.
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub duration_ms: Option<u64>,
184}
185
186impl TestLogEntry {
187    /// Create a new log entry.
188    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    /// Add structured data to the log entry.
200    #[must_use]
201    pub fn with_data(mut self, data: serde_json::Value) -> Self {
202        self.data = Some(data);
203        self
204    }
205
206    /// Add duration to the log entry.
207    #[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/// Aggregated test result for summary output.
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct TestResult {
217    /// Name of the test.
218    pub test_name: String,
219    /// Whether the test passed.
220    pub passed: bool,
221    /// Total duration in milliseconds.
222    pub duration_ms: u64,
223    /// Captured stdout (if any).
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub stdout: Option<String>,
226    /// Captured stderr (if any).
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub stderr: Option<String>,
229    /// Exit code (for command-based tests).
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub exit_code: Option<i32>,
232    /// Environment variables at test time.
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub env: Option<std::collections::HashMap<String, String>>,
235    /// Terminal information.
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub terminal_info: Option<TerminalInfo>,
238    /// All log entries for this test.
239    pub logs: Vec<TestLogEntry>,
240}
241
242/// Terminal metadata for debugging output issues.
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct TerminalInfo {
245    /// Whether stdout is a TTY.
246    pub stdout_tty: bool,
247    /// Whether stderr is a TTY.
248    pub stderr_tty: bool,
249    /// TERM environment variable value.
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub term: Option<String>,
252    /// NO_COLOR environment variable present.
253    pub no_color: bool,
254    /// FORCE_COLOR environment variable present.
255    pub force_color: bool,
256    /// Terminal width if available.
257    #[serde(skip_serializing_if = "Option::is_none")]
258    pub width: Option<u16>,
259}
260
261impl TerminalInfo {
262    /// Capture current terminal information.
263    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
277/// Get terminal width from COLUMNS env or termsize.
278fn terminal_width() -> Option<u16> {
279    // Try COLUMNS env first
280    if let Ok(cols) = std::env::var("COLUMNS")
281        && let Ok(w) = cols.parse()
282    {
283        return Some(w);
284    }
285    // Could use termsize crate here, but keep it simple for now
286    None
287}
288
289/// Structured test logger that writes JSONL output.
290///
291/// Creates a log file per test in `target/test-logs/` for post-mortem debugging.
292pub 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    /// Create a new test logger for the given test name.
301    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        // Log test start
312        logger.log(TestPhase::Setup, "TEST START");
313
314        logger
315    }
316
317    /// Create log file in target/test-logs/.
318    ///
319    /// Uses CARGO_TARGET_DIR or falls back to looking for target/ in current dir
320    /// or parent directories.
321    fn create_log_file(test_name: &str) -> std::io::Result<std::fs::File> {
322        // Try to find workspace target dir from environment
323        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            // Look for target/ directory starting from current dir
327            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                    // Fallback to current directory
335                    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    /// Log a message for a specific phase.
349    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    /// Log a message with structured data.
357    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    /// Write a log entry to file and memory.
372    fn write_entry(&self, entry: &TestLogEntry) {
373        // Store in memory
374        if let Ok(mut logs) = self.logs.lock() {
375            logs.push(entry.clone());
376        }
377
378        // Write to file
379        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        // Also emit to tracing for immediate visibility
387        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    /// Mark test as passed and log completion.
397    pub fn pass(self) {
398        self.log(TestPhase::Verify, "TEST PASS");
399    }
400
401    /// Mark test as failed and log completion.
402    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    /// Get the elapsed duration.
411    pub fn elapsed_ms(&self) -> u64 {
412        self.start_time.elapsed().as_millis() as u64
413    }
414
415    /// Build a test result summary.
416    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
434/// Zero-boilerplate test logger that auto-logs pass/fail on drop.
435///
436/// Unlike `TestLogger`, this guard automatically logs TEST PASS when dropped
437/// normally, or TEST FAIL when dropped during a panic. This eliminates the
438/// need to explicitly call `.pass()` or `.fail()`.
439///
440/// # Usage
441///
442/// ```ignore
443/// use rch_common::testing::TestGuard;
444///
445/// #[test]
446/// fn test_example() {
447///     let _guard = TestGuard::new("test_example");
448///     // ... test logic ...
449///     // TEST PASS logged automatically when _guard drops
450/// }
451/// ```
452///
453/// # Environment Variables
454///
455/// - `RCH_TEST_LOGGING=1`: Enable logging (default: enabled in CI, disabled locally)
456/// - `RCH_TEST_LOGGING=0`: Disable logging
457///
458/// When disabled, the guard is a no-op for maximum performance.
459pub struct TestGuard {
460    inner: Option<TestLogger>,
461}
462
463impl TestGuard {
464    /// Create a new test guard.
465    ///
466    /// If test logging is enabled (see environment variables), this creates
467    /// a TestLogger and logs TEST START. Otherwise, it's a no-op.
468    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    /// Check if test logging is enabled.
481    ///
482    /// Returns true if:
483    /// - `RCH_TEST_LOGGING=1` is set, OR
484    /// - Running in CI (CI=true) and `RCH_TEST_LOGGING` is not `0`
485    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                // Default: enable in CI, disable locally
491                std::env::var("CI").is_ok()
492            }
493        }
494    }
495
496    /// Log a message during test execution.
497    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    /// Log a message with structured data.
504    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/// Create a TestGuard using the current function name.
529///
530/// This macro extracts the test function name automatically.
531///
532/// # Usage
533///
534/// ```ignore
535/// use rch_common::testing::test_guard;
536///
537/// #[test]
538/// fn test_something() {
539///     let _guard = test_guard!();
540///     // ... test logic ...
541/// }
542/// ```
543#[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); // START + 2 logs
585    }
586
587    #[test]
588    fn test_terminal_info_captures_env() {
589        let info = TerminalInfo::capture();
590        // Just verify it doesn't panic and produces valid data
591        // The capture function should always succeed
592        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        // Note: This test depends on the current env state
607        // In CI, logging is enabled by default; locally it's disabled
608        let _guard = TestGuard::new("test_guard_creates_without_panic");
609        // Just verify it doesn't panic regardless of enabled state
610    }
611
612    #[test]
613    fn test_guard_logs_messages_when_enabled() {
614        // Create a guard with an explicit inner logger to test logging
615        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        // Drop happens automatically, logging TEST PASS
625    }
626
627    #[test]
628    fn test_guard_disabled_is_noop() {
629        // Create a guard with inner=None to simulate disabled state
630        let guard = TestGuard { inner: None };
631        // Should not panic even when disabled
632        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        // Drop should be silent
639    }
640
641    #[test]
642    fn test_guard_drop_logs_pass_on_success() {
643        // Create guard with logger and verify drop behavior
644        let guard = TestGuard {
645            inner: Some(TestLogger::for_test("test_guard_drop_pass")),
646        };
647        // Manually call log to verify it's working
648        guard.log(TestPhase::Execute, "Before drop");
649        // Drop will log TEST PASS since we're not panicking
650    }
651}