Skip to main content

oven_cli/cli/
report.rs

1use std::fmt::Write as _;
2
3use anyhow::{Context, Result};
4use serde::Serialize;
5
6use super::{GlobalOpts, ReportArgs};
7use crate::db::{self, AgentRun, Run};
8
9#[allow(clippy::unused_async)]
10pub async fn run(args: ReportArgs, _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    if args.all {
16        let runs = db::runs::get_all_runs(&conn)?;
17        if runs.is_empty() {
18            println!("no runs found");
19            return Ok(());
20        }
21        if args.json {
22            let reports = runs
23                .iter()
24                .map(|r| -> Result<_> {
25                    let agents = db::agent_runs::get_agent_runs_for_run(&conn, &r.id)
26                        .context("fetching agent runs")?;
27                    Ok(RunReport::from_run(r, &agents))
28                })
29                .collect::<Result<Vec<_>>>()?;
30            println!("{}", serde_json::to_string_pretty(&reports)?);
31        } else {
32            print_runs_table(&runs);
33        }
34        return Ok(());
35    }
36
37    let run = if let Some(ref run_id) = args.run_id {
38        db::runs::get_run(&conn, run_id)?.with_context(|| format!("run {run_id} not found"))?
39    } else {
40        db::runs::get_latest_run(&conn)?.context("no runs found")?
41    };
42
43    let agent_runs = db::agent_runs::get_agent_runs_for_run(&conn, &run.id)?;
44
45    if args.json {
46        let report = RunReport::from_run(&run, &agent_runs);
47        println!("{}", serde_json::to_string_pretty(&report)?);
48    } else {
49        print_run_report(&run, &agent_runs);
50    }
51
52    Ok(())
53}
54
55fn print_runs_table(runs: &[Run]) {
56    println!("{:<10} {:<8} {:<12} {:>8}", "Run", "Issue", "Status", "Cost");
57    println!("{}", "-".repeat(42));
58    for run in runs {
59        println!("{:<10} #{:<7} {:<12} ${:.2}", run.id, run.issue_number, run.status, run.cost_usd);
60    }
61}
62
63fn print_run_report(run: &Run, agent_runs: &[AgentRun]) {
64    println!("Run {} - Issue #{}", run.id, run.issue_number);
65    println!("Status: {}", run.status);
66
67    if let Some(start) = run.started_at.get(..19) {
68        println!("Started: {start}");
69    }
70    if let Some(ref end) = run.finished_at {
71        println!("Finished: {}", end.get(..19).unwrap_or(end));
72    }
73
74    println!("Total cost: ${:.2}", run.cost_usd);
75
76    if let Some(ref err) = run.error_message {
77        println!("Error: {err}");
78    }
79
80    if !agent_runs.is_empty() {
81        println!();
82        println!("Agents:");
83        for ar in agent_runs {
84            let mut line = format!("  {:<14} ${:.2}  {:>3} turns", ar.agent, ar.cost_usd, ar.turns);
85            let _ = write!(line, "  {}", ar.status);
86            println!("{line}");
87        }
88    }
89}
90
91/// Serializable report for JSON output.
92#[derive(Serialize)]
93struct RunReport {
94    id: String,
95    issue_number: u32,
96    status: String,
97    cost_usd: f64,
98    started_at: String,
99    finished_at: Option<String>,
100    error_message: Option<String>,
101    agents: Vec<AgentRunReport>,
102}
103
104#[derive(Serialize)]
105struct AgentRunReport {
106    agent: String,
107    cycle: u32,
108    status: String,
109    cost_usd: f64,
110    turns: u32,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    raw_output: Option<String>,
113}
114
115impl RunReport {
116    fn from_run(run: &Run, agent_runs: &[AgentRun]) -> Self {
117        Self {
118            id: run.id.clone(),
119            issue_number: run.issue_number,
120            status: run.status.to_string(),
121            cost_usd: run.cost_usd,
122            started_at: run.started_at.clone(),
123            finished_at: run.finished_at.clone(),
124            error_message: run.error_message.clone(),
125            agents: agent_runs
126                .iter()
127                .map(|ar| AgentRunReport {
128                    agent: ar.agent.clone(),
129                    cycle: ar.cycle,
130                    status: ar.status.clone(),
131                    cost_usd: ar.cost_usd,
132                    turns: ar.turns,
133                    raw_output: ar.raw_output.clone(),
134                })
135                .collect(),
136        }
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::db::RunStatus;
144
145    fn sample_run() -> Run {
146        Run {
147            id: "abcd1234".to_string(),
148            issue_number: 42,
149            status: RunStatus::Complete,
150            pr_number: Some(99),
151            branch: Some("oven/issue-42-abc".to_string()),
152            worktree_path: None,
153            cost_usd: 4.23,
154            auto_merge: false,
155            started_at: "2026-03-12T10:00:00".to_string(),
156            finished_at: Some("2026-03-12T10:08:32".to_string()),
157            error_message: None,
158            complexity: "full".to_string(),
159            issue_source: "github".to_string(),
160        }
161    }
162
163    fn sample_agent_runs() -> Vec<AgentRun> {
164        vec![
165            AgentRun {
166                id: 1,
167                run_id: "abcd1234".to_string(),
168                agent: "implementer".to_string(),
169                cycle: 1,
170                status: "complete".to_string(),
171                cost_usd: 2.10,
172                turns: 12,
173                started_at: "2026-03-12T10:00:00".to_string(),
174                finished_at: Some("2026-03-12T10:03:15".to_string()),
175                output_summary: None,
176                error_message: None,
177                raw_output: None,
178            },
179            AgentRun {
180                id: 2,
181                run_id: "abcd1234".to_string(),
182                agent: "reviewer".to_string(),
183                cycle: 1,
184                status: "complete".to_string(),
185                cost_usd: 0.85,
186                turns: 8,
187                started_at: "2026-03-12T10:03:15".to_string(),
188                finished_at: Some("2026-03-12T10:04:57".to_string()),
189                output_summary: None,
190                error_message: None,
191                raw_output: None,
192            },
193        ]
194    }
195
196    #[test]
197    fn run_report_serializes_to_json() {
198        let report = RunReport::from_run(&sample_run(), &sample_agent_runs());
199        let json = serde_json::to_string_pretty(&report).unwrap();
200        assert!(json.contains("abcd1234"));
201        assert!(json.contains("implementer"));
202        assert!(json.contains("reviewer"));
203        assert!(json.contains("4.23"));
204    }
205
206    #[test]
207    fn run_report_includes_all_agents() {
208        let report = RunReport::from_run(&sample_run(), &sample_agent_runs());
209        assert_eq!(report.agents.len(), 2);
210    }
211
212    #[test]
213    fn empty_agent_runs_produces_valid_report() {
214        let report = RunReport::from_run(&sample_run(), &[]);
215        let json = serde_json::to_string(&report).unwrap();
216        assert!(json.contains("\"agents\":[]"));
217    }
218
219    #[test]
220    fn print_run_report_captures_output() {
221        // Verify the function doesn't panic and formats correctly
222        let run = sample_run();
223        let agents = sample_agent_runs();
224        print_run_report(&run, &agents);
225    }
226
227    #[test]
228    fn print_run_report_with_error() {
229        let mut run = sample_run();
230        run.error_message = Some("something broke".to_string());
231        run.status = RunStatus::Failed;
232        print_run_report(&run, &[]);
233    }
234
235    #[test]
236    fn print_runs_table_formats_rows() {
237        let runs = vec![
238            sample_run(),
239            Run {
240                id: "efgh5678".to_string(),
241                issue_number: 99,
242                status: RunStatus::Failed,
243                pr_number: None,
244                branch: None,
245                worktree_path: None,
246                cost_usd: 12.50,
247                auto_merge: false,
248                started_at: "2026-03-13T10:00:00".to_string(),
249                finished_at: None,
250                error_message: Some("budget exceeded".to_string()),
251                complexity: "full".to_string(),
252                issue_source: "github".to_string(),
253            },
254        ];
255        print_runs_table(&runs);
256    }
257
258    #[test]
259    fn run_report_from_run_maps_all_fields() {
260        let run = Run {
261            id: "test0001".to_string(),
262            issue_number: 7,
263            status: RunStatus::Failed,
264            pr_number: Some(55),
265            branch: Some("oven/issue-7-abc".to_string()),
266            worktree_path: None,
267            cost_usd: 18.75,
268            auto_merge: true,
269            started_at: "2026-03-12T10:00:00".to_string(),
270            finished_at: Some("2026-03-12T10:30:00".to_string()),
271            error_message: Some("cost exceeded".to_string()),
272            complexity: "full".to_string(),
273            issue_source: "github".to_string(),
274        };
275        let agents = vec![AgentRun {
276            id: 1,
277            run_id: "test0001".to_string(),
278            agent: "implementer".to_string(),
279            cycle: 1,
280            status: "failed".to_string(),
281            cost_usd: 18.75,
282            turns: 50,
283            started_at: "2026-03-12T10:00:00".to_string(),
284            finished_at: Some("2026-03-12T10:30:00".to_string()),
285            output_summary: None,
286            error_message: Some("budget".to_string()),
287            raw_output: None,
288        }];
289        let report = RunReport::from_run(&run, &agents);
290        assert_eq!(report.id, "test0001");
291        assert_eq!(report.status, "failed");
292        assert_eq!(report.error_message.as_deref(), Some("cost exceeded"));
293        assert_eq!(report.agents.len(), 1);
294        assert_eq!(report.agents[0].turns, 50);
295    }
296}