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