1use 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
18fn find_workspace_root(start: &Path) -> Option<PathBuf> {
21 let mut current = start.to_path_buf();
22 if current.is_file() {
24 current = current.parent()?.to_path_buf();
25 }
26
27 let mut candidate = current.clone();
29 loop {
30 let cargo_toml = candidate.join("Cargo.toml");
31 if cargo_toml.exists() {
32 if let Ok(contents) = std::fs::read_to_string(&cargo_toml)
34 && contents.contains("[workspace]")
35 {
36 return Some(candidate);
37 }
38 let target = candidate.join("target");
40 if target.join("debug").exists() || target.join("release").exists() {
41 return Some(candidate);
42 }
43 }
44 match candidate.parent() {
46 Some(parent) if parent != candidate => candidate = parent.to_path_buf(),
47 _ => break,
48 }
49 }
50
51 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 start.parent().map(|p| p.to_path_buf())
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
68#[serde(rename_all = "lowercase")]
69pub enum LogLevel {
70 Trace,
72 Debug,
74 Info,
76 Warn,
78 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 pub fn color_code(&self) -> &'static str {
98 match self {
99 LogLevel::Trace => "\x1b[90m", LogLevel::Debug => "\x1b[36m", LogLevel::Info => "\x1b[32m", LogLevel::Warn => "\x1b[33m", LogLevel::Error => "\x1b[31m", }
105 }
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110#[serde(rename_all = "snake_case")]
111pub enum LogSource {
112 Harness,
114 ProcessStdout { name: String, pid: u32 },
116 ProcessStderr { name: String, pid: u32 },
118 Daemon,
120 Worker { id: String },
122 Hook,
124 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#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct LogEntry {
145 pub timestamp: DateTime<Utc>,
147 pub elapsed_ms: u64,
149 pub level: LogLevel,
151 pub source: LogSource,
153 pub message: String,
155 #[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 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
206pub const RELIABILITY_EVENT_SCHEMA_VERSION: &str = "1.0.0";
208
209#[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#[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 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#[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#[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 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#[derive(Debug, Clone)]
302pub struct LoggerConfig {
303 pub min_level: LogLevel,
305 pub print_realtime: bool,
307 pub use_colors: bool,
309 pub max_entries: usize,
311 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#[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 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 pub fn default_for_test(test_name: &str) -> Self {
401 Self::new(test_name, LoggerConfig::default())
402 }
403
404 pub fn test_name(&self) -> &str {
406 &self.test_name
407 }
408
409 pub fn elapsed(&self) -> Duration {
411 self.start_time.elapsed()
412 }
413
414 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 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 if config.print_realtime {
443 if config.use_colors {
444 println!("{}", entry.format_colored());
445 } else {
446 println!("{entry}");
447 }
448 }
449
450 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 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 pub fn reliability_log_path(&self) -> Option<&Path> {
469 self.reliability_log_path.as_deref()
470 }
471
472 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 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 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 pub fn trace(&self, message: impl Into<String>) {
602 self.log(LogLevel::Trace, LogSource::Harness, message);
603 }
604
605 pub fn debug(&self, message: impl Into<String>) {
607 self.log(LogLevel::Debug, LogSource::Harness, message);
608 }
609
610 pub fn info(&self, message: impl Into<String>) {
612 self.log(LogLevel::Info, LogSource::Harness, message);
613 }
614
615 pub fn warn(&self, message: impl Into<String>) {
617 self.log(LogLevel::Warn, LogSource::Harness, message);
618 }
619
620 pub fn error(&self, message: impl Into<String>) {
622 self.log(LogLevel::Error, LogSource::Harness, message);
623 }
624
625 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 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 pub fn log_daemon(&self, level: LogLevel, message: impl Into<String>) {
651 self.log(level, LogSource::Daemon, message);
652 }
653
654 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 pub fn log_hook(&self, level: LogLevel, message: impl Into<String>) {
667 self.log(level, LogSource::Hook, message);
668 }
669
670 pub fn entries(&self) -> Vec<LogEntry> {
672 self.entries.lock().unwrap().iter().cloned().collect()
673 }
674
675 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 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 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 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 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 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 pub fn clear(&self) {
741 self.entries.lock().unwrap().clear();
742 }
743
744 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 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 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 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#[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
840pub struct TestLoggerBuilder {
842 test_name: String,
843 config: LoggerConfig,
844}
845
846impl TestLoggerBuilder {
847 pub fn new(test_name: &str) -> Self {
853 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 fn auto_detect_log_dir() -> Option<PathBuf> {
867 if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
869 let manifest_path = PathBuf::from(&manifest_dir);
870 let workspace_root = find_workspace_root(&manifest_path)?;
872 let log_dir = workspace_root.join("target").join("test-logs");
873 let _ = fs::create_dir_all(&log_dir);
875 return Some(log_dir);
876 }
877 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 pub fn min_level(mut self, level: LogLevel) -> Self {
890 self.config.min_level = level;
891 self
892 }
893
894 pub fn print_realtime(mut self, enabled: bool) -> Self {
896 self.config.print_realtime = enabled;
897 self
898 }
899
900 pub fn use_colors(mut self, enabled: bool) -> Self {
902 self.config.use_colors = enabled;
903 self
904 }
905
906 pub fn max_entries(mut self, max: usize) -> Self {
908 self.config.max_entries = max;
909 self
910 }
911
912 pub fn log_dir(mut self, dir: impl Into<PathBuf>) -> Self {
914 self.config.log_dir = Some(dir.into());
915 self
916 }
917
918 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 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 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 let log_dir = TestLoggerBuilder::auto_detect_log_dir();
1065 eprintln!("Auto-detected log_dir: {:?}", log_dir);
1066
1067 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 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);
1095
1096 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 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}