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