Skip to main content

rch_common/e2e/
logging.rs

1//! E2E Test Logging Library
2//!
3//! Provides comprehensive logging infrastructure for end-to-end tests.
4//!
5//! - Real-time console output (human-readable)
6//! - Per-test JSONL log files under the active Cargo target directory
7//!   (`$CARGO_TARGET_DIR/test-logs/` when set, otherwise `target/test-logs/`)
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use std::collections::VecDeque;
12use std::fmt;
13use std::fs::{self, File};
14use std::io::{BufWriter, Write as IoWrite};
15use std::path::{Path, PathBuf};
16use std::sync::{Arc, Mutex, RwLock};
17use std::time::{Duration, Instant};
18
19/// Find the workspace root by walking up from a path until we find a Cargo.toml
20/// that contains `[workspace]` or has a `target/` subdirectory with actual builds.
21fn find_workspace_root(start: &Path) -> Option<PathBuf> {
22    let mut current = start.to_path_buf();
23    // Handle case where start is a file (e.g., manifest path)
24    if current.is_file() {
25        current = current.parent()?.to_path_buf();
26    }
27
28    // First pass: look for workspace root marker
29    let mut candidate = current.clone();
30    loop {
31        let cargo_toml = candidate.join("Cargo.toml");
32        if cargo_toml.exists() {
33            // Check if this is the workspace root by looking for [workspace]
34            if let Ok(contents) = std::fs::read_to_string(&cargo_toml)
35                && contents.contains("[workspace]")
36            {
37                return Some(candidate);
38            }
39            // Also check if target/debug or target/release exists (indicates build root)
40            let target = candidate.join("target");
41            if target.join("debug").exists() || target.join("release").exists() {
42                return Some(candidate);
43            }
44        }
45        // Move up one level
46        match candidate.parent() {
47            Some(parent) if parent != candidate => candidate = parent.to_path_buf(),
48            _ => break,
49        }
50    }
51
52    // Fallback: walk up and find first directory with target/
53    loop {
54        if current.join("target").exists() {
55            return Some(current);
56        }
57        match current.parent() {
58            Some(parent) if parent != current => current = parent.to_path_buf(),
59            _ => break,
60        }
61    }
62
63    // Last resort: just use the start directory
64    start.parent().map(|p| p.to_path_buf())
65}
66
67/// Log severity levels for E2E tests
68#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
69#[serde(rename_all = "lowercase")]
70pub enum LogLevel {
71    /// Very fine-grained diagnostic information
72    Trace,
73    /// Detailed diagnostic information
74    Debug,
75    /// Normal operational information
76    Info,
77    /// Potential issues that don't prevent operation
78    Warn,
79    /// Errors that may cause test failure
80    Error,
81}
82
83impl fmt::Display for LogLevel {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        let s = match self {
86            LogLevel::Trace => "TRACE",
87            LogLevel::Debug => "DEBUG",
88            LogLevel::Info => "INFO",
89            LogLevel::Warn => "WARN",
90            LogLevel::Error => "ERROR",
91        };
92        write!(f, "{s}")
93    }
94}
95
96impl LogLevel {
97    /// Returns the ANSI color code for this log level
98    pub fn color_code(&self) -> &'static str {
99        match self {
100            LogLevel::Trace => "\x1b[90m", // Gray
101            LogLevel::Debug => "\x1b[36m", // Cyan
102            LogLevel::Info => "\x1b[32m",  // Green
103            LogLevel::Warn => "\x1b[33m",  // Yellow
104            LogLevel::Error => "\x1b[31m", // Red
105        }
106    }
107}
108
109/// Source of a log entry
110#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(rename_all = "snake_case")]
112pub enum LogSource {
113    /// Log from the test harness itself
114    Harness,
115    /// Stdout from a spawned process
116    ProcessStdout { name: String, pid: u32 },
117    /// Stderr from a spawned process
118    ProcessStderr { name: String, pid: u32 },
119    /// Log from the daemon process
120    Daemon,
121    /// Log from a worker process
122    Worker { id: String },
123    /// Log from the hook process
124    Hook,
125    /// Custom source
126    Custom(String),
127}
128
129impl fmt::Display for LogSource {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        match self {
132            LogSource::Harness => write!(f, "harness"),
133            LogSource::ProcessStdout { name, pid } => write!(f, "{name}:{pid}:stdout"),
134            LogSource::ProcessStderr { name, pid } => write!(f, "{name}:{pid}:stderr"),
135            LogSource::Daemon => write!(f, "daemon"),
136            LogSource::Worker { id } => write!(f, "worker:{id}"),
137            LogSource::Hook => write!(f, "hook"),
138            LogSource::Custom(s) => write!(f, "{s}"),
139        }
140    }
141}
142
143/// A single log entry
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct LogEntry {
146    /// Timestamp when the log was created
147    pub timestamp: DateTime<Utc>,
148    /// Elapsed time since test start
149    pub elapsed_ms: u64,
150    /// Severity level
151    pub level: LogLevel,
152    /// Source of the log
153    pub source: LogSource,
154    /// Log message
155    pub message: String,
156    /// Optional context key-value pairs
157    #[serde(default, skip_serializing_if = "Vec::is_empty")]
158    pub context: Vec<(String, String)>,
159}
160
161impl fmt::Display for LogEntry {
162    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163        write!(
164            f,
165            "[{:>6}ms] [{:<5}] [{}] {}",
166            self.elapsed_ms, self.level, self.source, self.message
167        )?;
168        if !self.context.is_empty() {
169            write!(f, " {{")?;
170            for (i, (k, v)) in self.context.iter().enumerate() {
171                if i > 0 {
172                    write!(f, ", ")?;
173                }
174                write!(f, "{k}={v}")?;
175            }
176            write!(f, "}}")?;
177        }
178        Ok(())
179    }
180}
181
182impl LogEntry {
183    /// Format the log entry with ANSI colors
184    pub fn format_colored(&self) -> String {
185        let reset = "\x1b[0m";
186        let color = self.level.color_code();
187        let dim = "\x1b[2m";
188
189        let ctx = if self.context.is_empty() {
190            String::new()
191        } else {
192            let pairs: Vec<_> = self
193                .context
194                .iter()
195                .map(|(k, v)| format!("{k}={v}"))
196                .collect();
197            format!(" {dim}{{{}}}{reset}", pairs.join(", "))
198        };
199
200        format!(
201            "{dim}[{:>6}ms]{reset} {color}[{:<5}]{reset} {dim}[{}]{reset} {}{ctx}",
202            self.elapsed_ms, self.level, self.source, self.message
203        )
204    }
205}
206
207/// Stable schema version for reliability phase events.
208pub const RELIABILITY_EVENT_SCHEMA_VERSION: &str = "1.0.0";
209
210/// Reliability test phase used for lifecycle-oriented logging.
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
212#[serde(rename_all = "snake_case")]
213pub enum ReliabilityPhase {
214    Setup,
215    Execute,
216    Verify,
217    Cleanup,
218}
219
220impl fmt::Display for ReliabilityPhase {
221    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
222        let phase = match self {
223            Self::Setup => "setup",
224            Self::Execute => "execute",
225            Self::Verify => "verify",
226            Self::Cleanup => "cleanup",
227        };
228        write!(f, "{phase}")
229    }
230}
231
232/// Context payload attached to each reliability phase event.
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234pub struct ReliabilityContext {
235    pub worker_id: Option<String>,
236    pub repo_set: Vec<String>,
237    pub pressure_state: Option<String>,
238    pub triage_actions: Vec<String>,
239    pub decision_code: String,
240    pub fallback_reason: Option<String>,
241}
242
243impl ReliabilityContext {
244    /// Build a context with required decision code and no optional fields.
245    pub fn decision_only(decision_code: impl Into<String>) -> Self {
246        Self {
247            worker_id: None,
248            repo_set: Vec::new(),
249            pressure_state: None,
250            triage_actions: Vec::new(),
251            decision_code: decision_code.into(),
252            fallback_reason: None,
253        }
254    }
255}
256
257/// Machine-readable reliability phase event schema.
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct ReliabilityPhaseEvent {
260    pub schema_version: String,
261    pub timestamp: DateTime<Utc>,
262    pub elapsed_ms: u64,
263    pub level: LogLevel,
264    pub phase: ReliabilityPhase,
265    pub scenario_id: String,
266    pub message: String,
267    pub context: ReliabilityContext,
268    pub artifact_paths: Vec<String>,
269}
270
271/// Input contract for emitting reliability phase events.
272#[derive(Debug, Clone)]
273pub struct ReliabilityEventInput {
274    pub level: LogLevel,
275    pub phase: ReliabilityPhase,
276    pub scenario_id: String,
277    pub message: String,
278    pub context: ReliabilityContext,
279    pub artifact_paths: Vec<String>,
280}
281
282impl ReliabilityEventInput {
283    /// Convenience constructor for phase+scenario+decision-only events.
284    pub fn with_decision(
285        phase: ReliabilityPhase,
286        scenario_id: impl Into<String>,
287        message: impl Into<String>,
288        decision_code: impl Into<String>,
289    ) -> Self {
290        Self {
291            level: LogLevel::Info,
292            phase,
293            scenario_id: scenario_id.into(),
294            message: message.into(),
295            context: ReliabilityContext::decision_only(decision_code),
296            artifact_paths: Vec::new(),
297        }
298    }
299}
300
301/// Configuration for the test logger
302#[derive(Debug, Clone)]
303pub struct LoggerConfig {
304    /// Minimum log level to capture
305    pub min_level: LogLevel,
306    /// Whether to print logs to stdout in real-time
307    pub print_realtime: bool,
308    /// Whether to use ANSI colors when printing
309    pub use_colors: bool,
310    /// Maximum number of entries to keep in memory (0 = unlimited)
311    pub max_entries: usize,
312    /// Directory for persisting logs
313    pub log_dir: Option<PathBuf>,
314}
315
316impl Default for LoggerConfig {
317    fn default() -> Self {
318        Self {
319            min_level: LogLevel::Debug,
320            print_realtime: true,
321            use_colors: true,
322            max_entries: 10_000,
323            log_dir: None,
324        }
325    }
326}
327
328/// Thread-safe test logger that captures logs during E2E tests
329#[derive(Clone)]
330pub struct TestLogger {
331    config: Arc<RwLock<LoggerConfig>>,
332    entries: Arc<Mutex<VecDeque<LogEntry>>>,
333    start_time: Instant,
334    test_name: Arc<String>,
335    file_writer: Arc<Mutex<Option<BufWriter<File>>>>,
336    reliability_writer: Arc<Mutex<Option<BufWriter<File>>>>,
337    reliability_log_path: Arc<Option<PathBuf>>,
338    artifact_root: Arc<Option<PathBuf>>,
339}
340
341impl TestLogger {
342    /// Create a new test logger with the given configuration
343    pub fn new(test_name: &str, config: LoggerConfig) -> Self {
344        let mut file_writer = None;
345        let mut reliability_writer = None;
346        let mut reliability_log_path = None;
347        let mut artifact_root = None;
348
349        if let Some(ref dir) = config.log_dir
350            && fs::create_dir_all(dir).is_ok()
351        {
352            let sanitized_test_name = test_name.replace("::", "_").replace(' ', "_");
353            let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
354
355            let log_path = dir.join(format!("{sanitized_test_name}_{timestamp}.jsonl"));
356            match File::create(&log_path) {
357                Ok(file) => file_writer = Some(BufWriter::new(file)),
358                Err(error) => {
359                    eprintln!(
360                        "Warning: Failed to create log file {}: {error}",
361                        log_path.display()
362                    );
363                }
364            }
365
366            let reliability_path = dir.join(format!(
367                "reliability_{sanitized_test_name}_{timestamp}.jsonl"
368            ));
369            match File::create(&reliability_path) {
370                Ok(file) => {
371                    reliability_writer = Some(BufWriter::new(file));
372                    reliability_log_path = Some(reliability_path);
373                }
374                Err(error) => {
375                    eprintln!(
376                        "Warning: Failed to create reliability log file {}: {error}",
377                        reliability_path.display()
378                    );
379                }
380            }
381
382            let artifacts_dir = dir.join("artifacts");
383            if fs::create_dir_all(&artifacts_dir).is_ok() {
384                artifact_root = Some(artifacts_dir);
385            }
386        }
387
388        Self {
389            config: Arc::new(RwLock::new(config)),
390            entries: Arc::new(Mutex::new(VecDeque::new())),
391            start_time: Instant::now(),
392            test_name: Arc::new(test_name.to_string()),
393            file_writer: Arc::new(Mutex::new(file_writer)),
394            reliability_writer: Arc::new(Mutex::new(reliability_writer)),
395            reliability_log_path: Arc::new(reliability_log_path),
396            artifact_root: Arc::new(artifact_root),
397        }
398    }
399
400    /// Create a logger with default configuration
401    pub fn default_for_test(test_name: &str) -> Self {
402        Self::new(test_name, LoggerConfig::default())
403    }
404
405    /// Get the test name
406    pub fn test_name(&self) -> &str {
407        &self.test_name
408    }
409
410    /// Get elapsed time since logger creation
411    pub fn elapsed(&self) -> Duration {
412        self.start_time.elapsed()
413    }
414
415    /// Log an entry with the given level and source
416    pub fn log(&self, level: LogLevel, source: LogSource, message: impl Into<String>) {
417        self.log_with_context(level, source, message, Vec::new());
418    }
419
420    /// Log an entry with context key-value pairs
421    pub fn log_with_context(
422        &self,
423        level: LogLevel,
424        source: LogSource,
425        message: impl Into<String>,
426        context: Vec<(String, String)>,
427    ) {
428        let config = self.config.read().unwrap();
429        if level < config.min_level {
430            return;
431        }
432
433        let entry = LogEntry {
434            timestamp: Utc::now(),
435            elapsed_ms: self.start_time.elapsed().as_millis() as u64,
436            level,
437            source,
438            message: message.into(),
439            context,
440        };
441
442        // Print to stdout if configured
443        if config.print_realtime {
444            if config.use_colors {
445                println!("{}", entry.format_colored());
446            } else {
447                println!("{entry}");
448            }
449        }
450
451        // Write JSONL to file if configured
452        if let Ok(mut writer) = self.file_writer.lock()
453            && let Some(ref mut w) = *writer
454            && let Ok(json) = serde_json::to_string(&entry)
455        {
456            let _ = writeln!(w, "{json}");
457            let _ = w.flush();
458        }
459
460        // Store in memory
461        let mut entries = self.entries.lock().unwrap();
462        entries.push_back(entry);
463        if config.max_entries > 0 && entries.len() > config.max_entries {
464            entries.pop_front();
465        }
466    }
467
468    /// Returns the reliability JSONL path if reliability logging is enabled.
469    pub fn reliability_log_path(&self) -> Option<&Path> {
470        self.reliability_log_path.as_deref()
471    }
472
473    /// Emit a structured reliability event using the stable schema contract.
474    pub fn log_reliability_event(&self, input: ReliabilityEventInput) -> ReliabilityPhaseEvent {
475        let event = ReliabilityPhaseEvent {
476            schema_version: RELIABILITY_EVENT_SCHEMA_VERSION.to_string(),
477            timestamp: Utc::now(),
478            elapsed_ms: self.start_time.elapsed().as_millis() as u64,
479            level: input.level,
480            phase: input.phase,
481            scenario_id: input.scenario_id,
482            message: input.message,
483            context: input.context,
484            artifact_paths: input.artifact_paths,
485        };
486
487        let mut log_context = vec![
488            ("schema_version".to_string(), event.schema_version.clone()),
489            ("phase".to_string(), event.phase.to_string()),
490            ("scenario_id".to_string(), event.scenario_id.clone()),
491            (
492                "decision_code".to_string(),
493                event.context.decision_code.clone(),
494            ),
495        ];
496        if let Some(worker_id) = event.context.worker_id.as_ref() {
497            log_context.push(("worker_id".to_string(), worker_id.clone()));
498        }
499        if !event.context.repo_set.is_empty() {
500            log_context.push(("repo_set".to_string(), event.context.repo_set.join(",")));
501        }
502        if let Some(pressure_state) = event.context.pressure_state.as_ref() {
503            log_context.push(("pressure_state".to_string(), pressure_state.clone()));
504        }
505        if !event.context.triage_actions.is_empty() {
506            log_context.push((
507                "triage_actions".to_string(),
508                event.context.triage_actions.join(","),
509            ));
510        }
511        if let Some(fallback_reason) = event.context.fallback_reason.as_ref() {
512            log_context.push(("fallback_reason".to_string(), fallback_reason.clone()));
513        }
514        if !event.artifact_paths.is_empty() {
515            log_context.push(("artifact_paths".to_string(), event.artifact_paths.join(",")));
516        }
517
518        self.log_with_context(
519            event.level,
520            LogSource::Harness,
521            format!("[{}] {}", event.phase, event.message),
522            log_context,
523        );
524
525        if let Ok(mut writer_guard) = self.reliability_writer.lock()
526            && let Some(ref mut writer) = *writer_guard
527            && let Ok(serialized) = serde_json::to_string(&event)
528        {
529            let _ = writeln!(writer, "{serialized}");
530            let _ = writer.flush();
531        }
532
533        event
534    }
535
536    /// Persist a text artifact for replay/postmortem analysis.
537    pub fn capture_artifact_text(
538        &self,
539        scenario_id: &str,
540        artifact_name: &str,
541        content: &str,
542    ) -> std::io::Result<PathBuf> {
543        let Some(artifact_root) = self.artifact_root.as_deref() else {
544            return Err(std::io::Error::other(
545                "artifact capture requires logger log_dir to be configured",
546            ));
547        };
548
549        let scenario_dir = artifact_root.join(Self::sanitize_artifact_component(scenario_id));
550        fs::create_dir_all(&scenario_dir)?;
551        let artifact_path = scenario_dir.join(format!(
552            "{}.txt",
553            Self::sanitize_artifact_component(artifact_name)
554        ));
555        fs::write(&artifact_path, content)?;
556        Ok(artifact_path)
557    }
558
559    /// Persist a JSON artifact for replay/postmortem analysis.
560    pub fn capture_artifact_json<T: Serialize>(
561        &self,
562        scenario_id: &str,
563        artifact_name: &str,
564        value: &T,
565    ) -> std::io::Result<PathBuf> {
566        let serialized = serde_json::to_string_pretty(value).map_err(|error| {
567            std::io::Error::other(format!("failed to serialize artifact json: {error}"))
568        })?;
569        let Some(artifact_root) = self.artifact_root.as_deref() else {
570            return Err(std::io::Error::other(
571                "artifact capture requires logger log_dir to be configured",
572            ));
573        };
574
575        let scenario_dir = artifact_root.join(Self::sanitize_artifact_component(scenario_id));
576        fs::create_dir_all(&scenario_dir)?;
577        let artifact_path = scenario_dir.join(format!(
578            "{}.json",
579            Self::sanitize_artifact_component(artifact_name)
580        ));
581        fs::write(&artifact_path, serialized)?;
582        Ok(artifact_path)
583    }
584
585    fn sanitize_artifact_component(raw: &str) -> String {
586        let mut cleaned = String::with_capacity(raw.len());
587        for ch in raw.chars() {
588            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' {
589                cleaned.push(ch);
590            } else {
591                cleaned.push('_');
592            }
593        }
594        if cleaned.is_empty() {
595            "artifact".to_string()
596        } else {
597            cleaned
598        }
599    }
600
601    /// Log a trace message from the harness
602    pub fn trace(&self, message: impl Into<String>) {
603        self.log(LogLevel::Trace, LogSource::Harness, message);
604    }
605
606    /// Log a debug message from the harness
607    pub fn debug(&self, message: impl Into<String>) {
608        self.log(LogLevel::Debug, LogSource::Harness, message);
609    }
610
611    /// Log an info message from the harness
612    pub fn info(&self, message: impl Into<String>) {
613        self.log(LogLevel::Info, LogSource::Harness, message);
614    }
615
616    /// Log a warning message from the harness
617    pub fn warn(&self, message: impl Into<String>) {
618        self.log(LogLevel::Warn, LogSource::Harness, message);
619    }
620
621    /// Log an error message from the harness
622    pub fn error(&self, message: impl Into<String>) {
623        self.log(LogLevel::Error, LogSource::Harness, message);
624    }
625
626    /// Log process stdout
627    pub fn log_stdout(&self, process_name: &str, pid: u32, message: impl Into<String>) {
628        self.log(
629            LogLevel::Debug,
630            LogSource::ProcessStdout {
631                name: process_name.to_string(),
632                pid,
633            },
634            message,
635        );
636    }
637
638    /// Log process stderr
639    pub fn log_stderr(&self, process_name: &str, pid: u32, message: impl Into<String>) {
640        self.log(
641            LogLevel::Warn,
642            LogSource::ProcessStderr {
643                name: process_name.to_string(),
644                pid,
645            },
646            message,
647        );
648    }
649
650    /// Log a daemon message
651    pub fn log_daemon(&self, level: LogLevel, message: impl Into<String>) {
652        self.log(level, LogSource::Daemon, message);
653    }
654
655    /// Log a worker message
656    pub fn log_worker(&self, worker_id: &str, level: LogLevel, message: impl Into<String>) {
657        self.log(
658            level,
659            LogSource::Worker {
660                id: worker_id.to_string(),
661            },
662            message,
663        );
664    }
665
666    /// Log a hook message
667    pub fn log_hook(&self, level: LogLevel, message: impl Into<String>) {
668        self.log(level, LogSource::Hook, message);
669    }
670
671    /// Get all log entries
672    pub fn entries(&self) -> Vec<LogEntry> {
673        self.entries.lock().unwrap().iter().cloned().collect()
674    }
675
676    /// Get entries filtered by level
677    pub fn entries_by_level(&self, min_level: LogLevel) -> Vec<LogEntry> {
678        self.entries
679            .lock()
680            .unwrap()
681            .iter()
682            .filter(|e| e.level >= min_level)
683            .cloned()
684            .collect()
685    }
686
687    /// Get entries filtered by source
688    pub fn entries_by_source(&self, source_prefix: &str) -> Vec<LogEntry> {
689        let prefix = source_prefix.to_lowercase();
690        self.entries
691            .lock()
692            .unwrap()
693            .iter()
694            .filter(|e| e.source.to_string().to_lowercase().starts_with(&prefix))
695            .cloned()
696            .collect()
697    }
698
699    /// Search entries by message content
700    pub fn search(&self, pattern: &str) -> Vec<LogEntry> {
701        let pattern_lower = pattern.to_lowercase();
702        self.entries
703            .lock()
704            .unwrap()
705            .iter()
706            .filter(|e| e.message.to_lowercase().contains(&pattern_lower))
707            .cloned()
708            .collect()
709    }
710
711    /// Check if any errors were logged
712    pub fn has_errors(&self) -> bool {
713        self.entries
714            .lock()
715            .unwrap()
716            .iter()
717            .any(|e| e.level == LogLevel::Error)
718    }
719
720    /// Get error count
721    pub fn error_count(&self) -> usize {
722        self.entries
723            .lock()
724            .unwrap()
725            .iter()
726            .filter(|e| e.level == LogLevel::Error)
727            .count()
728    }
729
730    /// Get warning count
731    pub fn warn_count(&self) -> usize {
732        self.entries
733            .lock()
734            .unwrap()
735            .iter()
736            .filter(|e| e.level == LogLevel::Warn)
737            .count()
738    }
739
740    /// Clear all entries
741    pub fn clear(&self) {
742        self.entries.lock().unwrap().clear();
743    }
744
745    /// Export logs to JSON
746    pub fn export_json(&self) -> String {
747        let entries = self.entries();
748        serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string())
749    }
750
751    /// Export logs to a JSON file
752    pub fn export_json_to_file(&self, path: &Path) -> std::io::Result<()> {
753        let json = self.export_json();
754        fs::write(path, json)
755    }
756
757    /// Generate a test summary
758    pub fn summary(&self) -> TestLogSummary {
759        let entries = self.entries.lock().unwrap();
760        let mut summary = TestLogSummary {
761            test_name: self.test_name.to_string(),
762            total_entries: entries.len(),
763            duration_ms: self.elapsed().as_millis() as u64,
764            counts_by_level: [
765                (LogLevel::Trace, 0),
766                (LogLevel::Debug, 0),
767                (LogLevel::Info, 0),
768                (LogLevel::Warn, 0),
769                (LogLevel::Error, 0),
770            ]
771            .into_iter()
772            .collect(),
773            first_error: None,
774            last_error: None,
775        };
776
777        for entry in entries.iter() {
778            *summary.counts_by_level.entry(entry.level).or_insert(0) += 1;
779            if entry.level == LogLevel::Error {
780                if summary.first_error.is_none() {
781                    summary.first_error = Some(entry.message.clone());
782                }
783                summary.last_error = Some(entry.message.clone());
784            }
785        }
786
787        summary
788    }
789
790    /// Print a formatted summary to stdout
791    pub fn print_summary(&self) {
792        let summary = self.summary();
793        println!("\n{}", "=".repeat(60));
794        println!("Test Log Summary: {}", summary.test_name);
795        println!("{}", "=".repeat(60));
796        println!("Duration: {}ms", summary.duration_ms);
797        println!("Total entries: {}", summary.total_entries);
798        println!(
799            "  TRACE: {}",
800            summary.counts_by_level.get(&LogLevel::Trace).unwrap_or(&0)
801        );
802        println!(
803            "  DEBUG: {}",
804            summary.counts_by_level.get(&LogLevel::Debug).unwrap_or(&0)
805        );
806        println!(
807            "  INFO:  {}",
808            summary.counts_by_level.get(&LogLevel::Info).unwrap_or(&0)
809        );
810        println!(
811            "  WARN:  {}",
812            summary.counts_by_level.get(&LogLevel::Warn).unwrap_or(&0)
813        );
814        println!(
815            "  ERROR: {}",
816            summary.counts_by_level.get(&LogLevel::Error).unwrap_or(&0)
817        );
818        if let Some(ref err) = summary.first_error {
819            println!("First error: {err}");
820        }
821        if let Some(ref err) = summary.last_error
822            && summary.first_error.as_ref() != Some(err)
823        {
824            println!("Last error: {err}");
825        }
826        println!("{}", "=".repeat(60));
827    }
828}
829
830/// Summary of test logs
831#[derive(Debug, Clone, Serialize)]
832pub struct TestLogSummary {
833    pub test_name: String,
834    pub total_entries: usize,
835    pub duration_ms: u64,
836    pub counts_by_level: std::collections::HashMap<LogLevel, usize>,
837    pub first_error: Option<String>,
838    pub last_error: Option<String>,
839}
840
841/// Builder for creating a TestLogger with custom configuration
842pub struct TestLoggerBuilder {
843    test_name: String,
844    config: LoggerConfig,
845}
846
847impl TestLoggerBuilder {
848    /// Create a new builder for the given test name.
849    ///
850    /// By default, logs are written under the active Cargo target directory as
851    /// JSONL (one JSON object per line). This honors `CARGO_TARGET_DIR` first
852    /// so tests do not silently write artifacts to a different filesystem than
853    /// the current build.
854    pub fn new(test_name: &str) -> Self {
855        // Auto-set log directory for standardized JSONL output
856        let config = LoggerConfig {
857            log_dir: Self::auto_detect_log_dir(),
858            ..Default::default()
859        };
860        Self {
861            test_name: test_name.to_string(),
862            config,
863        }
864    }
865
866    /// Auto-detect the log directory based on Cargo's active target directory.
867    fn auto_detect_log_dir() -> Option<PathBuf> {
868        if let Some(log_dir) = std::env::var_os("CARGO_TARGET_DIR")
869            .map(PathBuf::from)
870            .and_then(Self::log_dir_from_cargo_target_dir)
871        {
872            return Some(log_dir);
873        }
874
875        // Try CARGO_MANIFEST_DIR first (set during cargo test)
876        if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
877            let manifest_path = PathBuf::from(&manifest_dir);
878            // Walk up to find workspace root (has target/ directory)
879            let workspace_root = find_workspace_root(&manifest_path)?;
880            let log_dir = workspace_root.join("target").join("test-logs");
881            if fs::create_dir_all(&log_dir).is_ok() {
882                return Some(log_dir);
883            }
884        }
885        // Fallback: try current directory
886        if let Ok(cwd) = std::env::current_dir() {
887            let log_dir = cwd.join("target").join("test-logs");
888            if log_dir.parent().map(|p| p.exists()).unwrap_or(false)
889                && fs::create_dir_all(&log_dir).is_ok()
890            {
891                return Some(log_dir);
892            }
893        }
894        None
895    }
896
897    fn log_dir_from_cargo_target_dir(target_dir: PathBuf) -> Option<PathBuf> {
898        if target_dir.as_os_str().is_empty() {
899            return None;
900        }
901
902        let target_dir = if target_dir.is_absolute() {
903            target_dir
904        } else {
905            std::env::current_dir().ok()?.join(target_dir)
906        };
907        let log_dir = target_dir.join("test-logs");
908        fs::create_dir_all(&log_dir).ok()?;
909        Some(log_dir)
910    }
911
912    /// Set the minimum log level
913    pub fn min_level(mut self, level: LogLevel) -> Self {
914        self.config.min_level = level;
915        self
916    }
917
918    /// Enable or disable real-time printing
919    pub fn print_realtime(mut self, enabled: bool) -> Self {
920        self.config.print_realtime = enabled;
921        self
922    }
923
924    /// Enable or disable ANSI colors
925    pub fn use_colors(mut self, enabled: bool) -> Self {
926        self.config.use_colors = enabled;
927        self
928    }
929
930    /// Set the maximum number of entries to keep in memory
931    pub fn max_entries(mut self, max: usize) -> Self {
932        self.config.max_entries = max;
933        self
934    }
935
936    /// Set the log directory for file persistence
937    pub fn log_dir(mut self, dir: impl Into<PathBuf>) -> Self {
938        self.config.log_dir = Some(dir.into());
939        self
940    }
941
942    /// Build the TestLogger
943    pub fn build(self) -> TestLogger {
944        TestLogger::new(&self.test_name, self.config)
945    }
946}
947
948#[cfg(test)]
949mod tests {
950    use super::*;
951
952    #[test]
953    fn test_log_levels_order() {
954        assert!(LogLevel::Trace < LogLevel::Debug);
955        assert!(LogLevel::Debug < LogLevel::Info);
956        assert!(LogLevel::Info < LogLevel::Warn);
957        assert!(LogLevel::Warn < LogLevel::Error);
958    }
959
960    #[test]
961    fn test_logger_basic() {
962        let logger = TestLoggerBuilder::new("test_basic")
963            .print_realtime(false)
964            .build();
965
966        logger.info("Test message");
967        logger.warn("Warning message");
968        logger.error("Error message");
969
970        assert_eq!(logger.entries().len(), 3);
971        assert!(logger.has_errors());
972        assert_eq!(logger.error_count(), 1);
973        assert_eq!(logger.warn_count(), 1);
974    }
975
976    #[test]
977    fn test_logger_filtering() {
978        let logger = TestLoggerBuilder::new("test_filtering")
979            .print_realtime(false)
980            .min_level(LogLevel::Info)
981            .build();
982
983        logger.trace("Trace message");
984        logger.debug("Debug message");
985        logger.info("Info message");
986
987        // Only info should be captured (trace and debug filtered out)
988        assert_eq!(logger.entries().len(), 1);
989    }
990
991    #[test]
992    fn test_logger_search() {
993        let logger = TestLoggerBuilder::new("test_search")
994            .print_realtime(false)
995            .build();
996
997        logger.info("Starting daemon");
998        logger.info("Daemon ready");
999        logger.info("Worker connected");
1000
1001        let daemon_logs = logger.search("daemon");
1002        assert_eq!(daemon_logs.len(), 2);
1003    }
1004
1005    #[test]
1006    fn test_logger_context() {
1007        let logger = TestLoggerBuilder::new("test_context")
1008            .print_realtime(false)
1009            .build();
1010
1011        logger.log_with_context(
1012            LogLevel::Info,
1013            LogSource::Harness,
1014            "Worker selected",
1015            vec![
1016                ("worker_id".to_string(), "css".to_string()),
1017                ("slots".to_string(), "4".to_string()),
1018            ],
1019        );
1020
1021        let entries = logger.entries();
1022        assert_eq!(entries.len(), 1);
1023        assert_eq!(entries[0].context.len(), 2);
1024    }
1025
1026    #[test]
1027    fn test_logger_max_entries() {
1028        let logger = TestLoggerBuilder::new("test_max_entries")
1029            .print_realtime(false)
1030            .max_entries(5)
1031            .build();
1032
1033        for i in 0..10 {
1034            logger.info(format!("Message {i}"));
1035        }
1036
1037        let entries = logger.entries();
1038        assert_eq!(entries.len(), 5);
1039        // Should keep the most recent entries
1040        assert!(entries[0].message.contains("5"));
1041        assert!(entries[4].message.contains("9"));
1042    }
1043
1044    #[test]
1045    fn test_logger_summary() {
1046        let logger = TestLoggerBuilder::new("test_summary")
1047            .print_realtime(false)
1048            .build();
1049
1050        logger.debug("Debug 1");
1051        logger.debug("Debug 2");
1052        logger.info("Info 1");
1053        logger.warn("Warn 1");
1054        logger.error("First error");
1055        logger.error("Last error");
1056
1057        let summary = logger.summary();
1058        assert_eq!(summary.test_name, "test_summary");
1059        assert_eq!(summary.total_entries, 6);
1060        assert_eq!(summary.counts_by_level.get(&LogLevel::Debug), Some(&2));
1061        assert_eq!(summary.counts_by_level.get(&LogLevel::Error), Some(&2));
1062        assert_eq!(summary.first_error, Some("First error".to_string()));
1063        assert_eq!(summary.last_error, Some("Last error".to_string()));
1064    }
1065
1066    #[test]
1067    fn test_log_entry_display() {
1068        let entry = LogEntry {
1069            timestamp: Utc::now(),
1070            elapsed_ms: 123,
1071            level: LogLevel::Info,
1072            source: LogSource::Harness,
1073            message: "Test message".to_string(),
1074            context: vec![("key".to_string(), "value".to_string())],
1075        };
1076
1077        let s = entry.to_string();
1078        assert!(s.contains("123ms"));
1079        assert!(s.contains("INFO"));
1080        assert!(s.contains("harness"));
1081        assert!(s.contains("Test message"));
1082        assert!(s.contains("key=value"));
1083    }
1084
1085    #[test]
1086    fn test_auto_detect_log_dir() {
1087        // Verify auto-detection finds a log directory
1088        let log_dir = TestLoggerBuilder::auto_detect_log_dir();
1089        eprintln!("Auto-detected log_dir: {:?}", log_dir);
1090
1091        // Should find something when running in cargo test context
1092        if std::env::var("CARGO_MANIFEST_DIR").is_ok() {
1093            assert!(
1094                log_dir.is_some(),
1095                "Should auto-detect log_dir with CARGO_MANIFEST_DIR set"
1096            );
1097            let dir = log_dir.unwrap();
1098            eprintln!("Log directory: {}", dir.display());
1099            assert!(dir.ends_with("test-logs"), "Should end with test-logs");
1100        }
1101    }
1102
1103    #[test]
1104    fn test_cargo_target_dir_log_dir_detection() {
1105        let temp_dir = tempfile::tempdir().expect("temp dir should be creatable");
1106        let log_dir = TestLoggerBuilder::log_dir_from_cargo_target_dir(temp_dir.path().into())
1107            .expect("target dir should produce log dir");
1108
1109        assert_eq!(log_dir, temp_dir.path().join("test-logs"));
1110        assert!(log_dir.exists());
1111    }
1112
1113    #[test]
1114    fn test_logger_writes_to_file() {
1115        // Create logger with explicit temp directory
1116        let temp_dir = tempfile::tempdir().expect("temp dir should be creatable");
1117        let temp_dir_path = temp_dir.path();
1118
1119        let logger = TestLoggerBuilder::new("test_file_write")
1120            .log_dir(temp_dir_path)
1121            .print_realtime(false)
1122            .build();
1123
1124        logger.info("Test file write message");
1125        logger.warn("Another message");
1126
1127        // Drop logger to flush file
1128        drop(logger);
1129
1130        // Check for log file
1131        let entries: Vec<_> = fs::read_dir(temp_dir_path)
1132            .unwrap()
1133            .filter_map(|e| e.ok())
1134            .filter(|e| {
1135                e.file_name()
1136                    .to_string_lossy()
1137                    .starts_with("test_file_write")
1138            })
1139            .collect();
1140
1141        assert!(
1142            !entries.is_empty(),
1143            "Should have created a log file in {:?}",
1144            temp_dir_path
1145        );
1146
1147        // Read and verify contents
1148        let log_path = &entries[0].path();
1149        let contents = fs::read_to_string(log_path).expect("Should read log file");
1150        assert!(
1151            contents.contains("Test file write message"),
1152            "Log should contain message"
1153        );
1154    }
1155
1156    #[test]
1157    fn test_reliability_event_schema_contract() {
1158        let temp_dir = tempfile::tempdir().expect("temp dir should be creatable");
1159        let logger = TestLoggerBuilder::new("test_reliability_schema")
1160            .log_dir(temp_dir.path())
1161            .print_realtime(false)
1162            .build();
1163
1164        let event = logger.log_reliability_event(ReliabilityEventInput {
1165            level: LogLevel::Info,
1166            phase: ReliabilityPhase::Execute,
1167            scenario_id: "scenario-path-deps".to_string(),
1168            message: "remote execution complete".to_string(),
1169            context: ReliabilityContext {
1170                worker_id: Some("worker-a".to_string()),
1171                repo_set: vec!["/data/projects/repo-a".to_string()],
1172                pressure_state: Some("disk:normal,memory:normal".to_string()),
1173                triage_actions: vec!["none".to_string()],
1174                decision_code: "REMOTE_OK".to_string(),
1175                fallback_reason: None,
1176            },
1177            artifact_paths: vec!["/tmp/a.json".to_string()],
1178        });
1179
1180        assert_eq!(event.schema_version, RELIABILITY_EVENT_SCHEMA_VERSION);
1181        assert_eq!(event.phase, ReliabilityPhase::Execute);
1182        assert_eq!(event.scenario_id, "scenario-path-deps");
1183        assert_eq!(event.context.decision_code, "REMOTE_OK");
1184
1185        let reliability_path = logger
1186            .reliability_log_path()
1187            .expect("reliability log path should exist")
1188            .to_path_buf();
1189        let reliability_contents =
1190            fs::read_to_string(&reliability_path).expect("should read reliability log");
1191        let first_line = reliability_contents
1192            .lines()
1193            .next()
1194            .expect("reliability log should contain one event");
1195        let parsed: ReliabilityPhaseEvent =
1196            serde_json::from_str(first_line).expect("reliability event should parse");
1197        assert_eq!(parsed.schema_version, RELIABILITY_EVENT_SCHEMA_VERSION);
1198        assert_eq!(parsed.phase, ReliabilityPhase::Execute);
1199        assert_eq!(parsed.context.worker_id, Some("worker-a".to_string()));
1200        assert_eq!(parsed.context.repo_set, vec!["/data/projects/repo-a"]);
1201    }
1202
1203    #[test]
1204    fn test_reliability_event_parser_compatibility() {
1205        let json = r#"{
1206            "schema_version":"1.0.0",
1207            "timestamp":"2026-02-16T00:00:00Z",
1208            "elapsed_ms":42,
1209            "level":"info",
1210            "phase":"verify",
1211            "scenario_id":"scenario-x",
1212            "message":"verify finished",
1213            "context":{
1214                "worker_id":"worker-1",
1215                "repo_set":["/data/projects/repo-x","/dp/repo-y"],
1216                "pressure_state":"disk:high",
1217                "triage_actions":["trim-cache","kill-stuck-procs"],
1218                "decision_code":"VERIFY_OK",
1219                "fallback_reason":null
1220            },
1221            "artifact_paths":["/tmp/trace.json"]
1222        }"#;
1223
1224        let event: ReliabilityPhaseEvent =
1225            serde_json::from_str(json).expect("contract payload should deserialize");
1226        assert_eq!(event.schema_version, "1.0.0");
1227        assert_eq!(event.phase, ReliabilityPhase::Verify);
1228        assert_eq!(event.context.decision_code, "VERIFY_OK");
1229        assert_eq!(event.context.triage_actions.len(), 2);
1230    }
1231
1232    #[test]
1233    fn test_reliability_artifact_capture_text_and_json() {
1234        let temp_dir = tempfile::tempdir().expect("temp dir should be creatable");
1235        let logger = TestLoggerBuilder::new("test_reliability_artifacts")
1236            .log_dir(temp_dir.path())
1237            .print_realtime(false)
1238            .build();
1239
1240        let text_path = logger
1241            .capture_artifact_text("scenario-alpha", "stdout_capture", "hello world")
1242            .expect("text artifact capture should succeed");
1243        assert!(text_path.exists());
1244        let text_contents = fs::read_to_string(&text_path).expect("read text artifact");
1245        assert_eq!(text_contents, "hello world");
1246
1247        let json_path = logger
1248            .capture_artifact_json(
1249                "scenario-alpha",
1250                "command_trace",
1251                &serde_json::json!({ "cmd": "cargo test", "exit_code": 0 }),
1252            )
1253            .expect("json artifact capture should succeed");
1254        assert!(json_path.exists());
1255        let json_contents = fs::read_to_string(&json_path).expect("read json artifact");
1256        assert!(json_contents.contains("\"cmd\""));
1257        assert!(json_contents.contains("\"cargo test\""));
1258    }
1259}