1use std::path::Path;
2
3use anyhow::{Context, Result};
4use tokio::io::{AsyncBufReadExt, BufReader};
5use tokio_util::sync::CancellationToken;
6
7use super::{GlobalOpts, LookArgs};
8use crate::db::{self, RunStatus};
9
10pub async fn run(args: LookArgs, _global: &GlobalOpts) -> Result<()> {
11 let project_dir = std::env::current_dir().context("getting current directory")?;
12 let db_path = project_dir.join(".oven").join("oven.db");
13 let conn = db::open(&db_path)?;
14
15 let run = if let Some(ref run_id) = args.run_id {
16 db::runs::get_run(&conn, run_id)?.with_context(|| format!("run {run_id} not found"))?
17 } else {
18 db::runs::get_latest_run(&conn)?.context("no runs found")?
19 };
20
21 let log_dir = project_dir.join(".oven").join("logs").join(&run.id);
22 let log_file = log_dir.join("pipeline.log");
23
24 if !log_file.exists() {
25 anyhow::bail!("no log file found for run {}", run.id);
26 }
27
28 let agent_tag = args.agent.as_deref().map(|a| format!("agent={a}"));
29 let is_active = !matches!(run.status, RunStatus::Complete | RunStatus::Failed);
30
31 if is_active {
32 tail_log(&log_file, args.agent.as_deref(), agent_tag.as_deref()).await?;
33 } else {
34 dump_log(&log_file, args.agent.as_deref(), agent_tag.as_deref()).await?;
35 }
36
37 Ok(())
38}
39
40async fn dump_log(path: &Path, agent_filter: Option<&str>, agent_tag: Option<&str>) -> Result<()> {
41 let file = tokio::fs::File::open(path).await.context("reading log file")?;
42 let reader = BufReader::new(file);
43 let mut lines = reader.lines();
44
45 while let Some(line) = lines.next_line().await.context("reading log line")? {
46 if should_show_line(&line, agent_filter, agent_tag) {
47 println!("{line}");
48 }
49 }
50
51 Ok(())
52}
53
54async fn tail_log(path: &Path, agent_filter: Option<&str>, agent_tag: Option<&str>) -> Result<()> {
55 let cancel = CancellationToken::new();
56 let cancel_for_signal = cancel.clone();
57
58 tokio::spawn(async move {
59 if tokio::signal::ctrl_c().await.is_ok() {
60 cancel_for_signal.cancel();
61 }
62 });
63
64 let file = tokio::fs::File::open(path).await.context("opening log file")?;
65 let mut reader = BufReader::new(file);
66 let mut line = String::new();
67
68 loop {
69 tokio::select! {
70 () = cancel.cancelled() => break,
71 result = reader.read_line(&mut line) => {
72 match result {
73 Ok(0) => {
74 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
76 }
77 Ok(_) => {
78 let trimmed = line.trim_end();
79 if should_show_line(trimmed, agent_filter, agent_tag) {
80 println!("{trimmed}");
81 }
82 line.clear();
83 }
84 Err(e) => return Err(e).context("reading log file"),
85 }
86 }
87 }
88 }
89
90 Ok(())
91}
92
93fn should_show_line(line: &str, agent_filter: Option<&str>, agent_tag: Option<&str>) -> bool {
94 match (agent_filter, agent_tag) {
95 (Some(agent), Some(tag)) => line.contains(tag) || line.contains(agent),
96 _ => true,
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 #[test]
105 fn filter_matches_agent_field() {
106 let tag = "agent=reviewer";
107 assert!(should_show_line(
108 r#"{"agent":"reviewer","msg":"ok"}"#,
109 Some("reviewer"),
110 Some(tag)
111 ));
112 assert!(!should_show_line(
113 r#"{"agent":"implementer","msg":"ok"}"#,
114 Some("reviewer"),
115 Some(tag)
116 ));
117 }
118
119 #[test]
120 fn no_filter_shows_all() {
121 assert!(should_show_line("any line at all", None, None));
122 }
123
124 #[test]
125 fn filter_matches_substring() {
126 assert!(should_show_line(
127 "agent=reviewer cycle=1",
128 Some("reviewer"),
129 Some("agent=reviewer")
130 ));
131 }
132}