1use 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
19fn find_workspace_root(start: &Path) -> Option<PathBuf> {
22 let mut current = start.to_path_buf();
23 if current.is_file() {
25 current = current.parent()?.to_path_buf();
26 }
27
28 let mut candidate = current.clone();
30 loop {
31 let cargo_toml = candidate.join("Cargo.toml");
32 if cargo_toml.exists() {
33 if let Ok(contents) = std::fs::read_to_string(&cargo_toml)
35 && contents.contains("[workspace]")
36 {
37 return Some(candidate);
38 }
39 let target = candidate.join("target");
41 if target.join("debug").exists() || target.join("release").exists() {
42 return Some(candidate);
43 }
44 }
45 match candidate.parent() {
47 Some(parent) if parent != candidate => candidate = parent.to_path_buf(),
48 _ => break,
49 }
50 }
51
52 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 start.parent().map(|p| p.to_path_buf())
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
69#[serde(rename_all = "lowercase")]
70pub enum LogLevel {
71 Trace,
73 Debug,
75 Info,
77 Warn,
79 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 pub fn color_code(&self) -> &'static str {
99 match self {
100 LogLevel::Trace => "\x1b[90m", LogLevel::Debug => "\x1b[36m", LogLevel::Info => "\x1b[32m", LogLevel::Warn => "\x1b[33m", LogLevel::Error => "\x1b[31m", }
106 }
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(rename_all = "snake_case")]
112pub enum LogSource {
113 Harness,
115 ProcessStdout { name: String, pid: u32 },
117 ProcessStderr { name: String, pid: u32 },
119 Daemon,
121 Worker { id: String },
123 Hook,
125 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#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct LogEntry {
146 pub timestamp: DateTime<Utc>,
148 pub elapsed_ms: u64,
150 pub level: LogLevel,
152 pub source: LogSource,
154 pub message: String,
156 #[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 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
207pub const RELIABILITY_EVENT_SCHEMA_VERSION: &str = "1.0.0";
209
210#[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#[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 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#[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#[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 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#[derive(Debug, Clone)]
303pub struct LoggerConfig {
304 pub min_level: LogLevel,
306 pub print_realtime: bool,
308 pub use_colors: bool,
310 pub max_entries: usize,
312 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#[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 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 pub fn default_for_test(test_name: &str) -> Self {
402 Self::new(test_name, LoggerConfig::default())
403 }
404
405 pub fn test_name(&self) -> &str {
407 &self.test_name
408 }
409
410 pub fn elapsed(&self) -> Duration {
412 self.start_time.elapsed()
413 }
414
415 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 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 if config.print_realtime {
444 if config.use_colors {
445 println!("{}", entry.format_colored());
446 } else {
447 println!("{entry}");
448 }
449 }
450
451 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 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 pub fn reliability_log_path(&self) -> Option<&Path> {
470 self.reliability_log_path.as_deref()
471 }
472
473 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 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 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 pub fn trace(&self, message: impl Into<String>) {
603 self.log(LogLevel::Trace, LogSource::Harness, message);
604 }
605
606 pub fn debug(&self, message: impl Into<String>) {
608 self.log(LogLevel::Debug, LogSource::Harness, message);
609 }
610
611 pub fn info(&self, message: impl Into<String>) {
613 self.log(LogLevel::Info, LogSource::Harness, message);
614 }
615
616 pub fn warn(&self, message: impl Into<String>) {
618 self.log(LogLevel::Warn, LogSource::Harness, message);
619 }
620
621 pub fn error(&self, message: impl Into<String>) {
623 self.log(LogLevel::Error, LogSource::Harness, message);
624 }
625
626 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 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 pub fn log_daemon(&self, level: LogLevel, message: impl Into<String>) {
652 self.log(level, LogSource::Daemon, message);
653 }
654
655 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 pub fn log_hook(&self, level: LogLevel, message: impl Into<String>) {
668 self.log(level, LogSource::Hook, message);
669 }
670
671 pub fn entries(&self) -> Vec<LogEntry> {
673 self.entries.lock().unwrap().iter().cloned().collect()
674 }
675
676 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 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 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 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 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 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 pub fn clear(&self) {
742 self.entries.lock().unwrap().clear();
743 }
744
745 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 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 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 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#[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
841pub struct TestLoggerBuilder {
843 test_name: String,
844 config: LoggerConfig,
845}
846
847impl TestLoggerBuilder {
848 pub fn new(test_name: &str) -> Self {
855 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 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 if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
877 let manifest_path = PathBuf::from(&manifest_dir);
878 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 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 pub fn min_level(mut self, level: LogLevel) -> Self {
914 self.config.min_level = level;
915 self
916 }
917
918 pub fn print_realtime(mut self, enabled: bool) -> Self {
920 self.config.print_realtime = enabled;
921 self
922 }
923
924 pub fn use_colors(mut self, enabled: bool) -> Self {
926 self.config.use_colors = enabled;
927 self
928 }
929
930 pub fn max_entries(mut self, max: usize) -> Self {
932 self.config.max_entries = max;
933 self
934 }
935
936 pub fn log_dir(mut self, dir: impl Into<PathBuf>) -> Self {
938 self.config.log_dir = Some(dir.into());
939 self
940 }
941
942 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 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 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 let log_dir = TestLoggerBuilder::auto_detect_log_dir();
1089 eprintln!("Auto-detected log_dir: {:?}", log_dir);
1090
1091 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 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);
1129
1130 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 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}