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        println!();
130        for line in graph.display_layered() {
131            println!("{line}");
132        }
133    }
134
135    Ok(())
136}
137
138#[derive(Serialize)]
139struct GraphReport {
140    session_id: String,
141    nodes: Vec<GraphNodeReport>,
142    edges: Vec<GraphEdgeReport>,
143}
144
145#[derive(Serialize)]
146struct GraphNodeReport {
147    issue_number: u32,
148    title: String,
149    state: String,
150    area: String,
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pr_number: Option<u32>,
153    #[serde(skip_serializing_if = "Option::is_none")]
154    run_id: Option<String>,
155}
156
157#[derive(Serialize)]
158struct GraphEdgeReport {
159    from: u32,
160    to: u32,
161}
162
163/// Serializable report for JSON output.
164#[derive(Serialize)]
165struct RunReport {
166    id: String,
167    issue_number: u32,
168    status: String,
169    cost_usd: f64,
170    started_at: String,
171    finished_at: Option<String>,
172    error_message: Option<String>,
173    agents: Vec<AgentRunReport>,
174}
175
176#[derive(Serialize)]
177struct AgentRunReport {
178    agent: String,
179    cycle: u32,
180    status: String,
181    cost_usd: f64,
182    turns: u32,
183    #[serde(skip_serializing_if = "Option::is_none")]
184    raw_output: Option<String>,
185}
186
187impl RunReport {
188    fn from_run(run: &Run, agent_runs: &[AgentRun]) -> Self {
189        Self {
190            id: run.id.clone(),
191            issue_number: run.issue_number,
192            status: run.status.to_string(),
193            cost_usd: run.cost_usd,
194            started_at: run.started_at.clone(),
195            finished_at: run.finished_at.clone(),
196            error_message: run.error_message.clone(),
197            agents: agent_runs
198                .iter()
199                .map(|ar| AgentRunReport {
200                    agent: ar.agent.clone(),
201                    cycle: ar.cycle,
202                    status: ar.status.clone(),
203                    cost_usd: ar.cost_usd,
204                    turns: ar.turns,
205                    raw_output: ar.raw_output.clone(),
206                })
207                .collect(),
208        }
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::db::RunStatus;
216
217    fn sample_run() -> Run {
218        Run {
219            id: "abcd1234".to_string(),
220            issue_number: 42,
221            status: RunStatus::Complete,
222            pr_number: Some(99),
223            branch: Some("oven/issue-42-abc".to_string()),
224            worktree_path: None,
225            cost_usd: 4.23,
226            auto_merge: false,
227            started_at: "2026-03-12T10:00:00".to_string(),
228            finished_at: Some("2026-03-12T10:08:32".to_string()),
229            error_message: None,
230            complexity: "full".to_string(),
231            issue_source: "github".to_string(),
232        }
233    }
234
235    fn sample_agent_runs() -> Vec<AgentRun> {
236        vec![
237            AgentRun {
238                id: 1,
239                run_id: "abcd1234".to_string(),
240                agent: "implementer".to_string(),
241                cycle: 1,
242                status: "complete".to_string(),
243                cost_usd: 2.10,
244                turns: 12,
245                started_at: "2026-03-12T10:00:00".to_string(),
246                finished_at: Some("2026-03-12T10:03:15".to_string()),
247                output_summary: None,
248                error_message: None,
249                raw_output: None,
250            },
251            AgentRun {
252                id: 2,
253                run_id: "abcd1234".to_string(),
254                agent: "reviewer".to_string(),
255                cycle: 1,
256                status: "complete".to_string(),
257                cost_usd: 0.85,
258                turns: 8,
259                started_at: "2026-03-12T10:03:15".to_string(),
260                finished_at: Some("2026-03-12T10:04:57".to_string()),
261                output_summary: None,
262                error_message: None,
263                raw_output: None,
264            },
265        ]
266    }
267
268    #[test]
269    fn run_report_serializes_to_json() {
270        let report = RunReport::from_run(&sample_run(), &sample_agent_runs());
271        let json = serde_json::to_string_pretty(&report).unwrap();
272        assert!(json.contains("abcd1234"));
273        assert!(json.contains("implementer"));
274        assert!(json.contains("reviewer"));
275        assert!(json.contains("4.23"));
276    }
277
278    #[test]
279    fn run_report_includes_all_agents() {
280        let report = RunReport::from_run(&sample_run(), &sample_agent_runs());
281        assert_eq!(report.agents.len(), 2);
282    }
283
284    #[test]
285    fn empty_agent_runs_produces_valid_report() {
286        let report = RunReport::from_run(&sample_run(), &[]);
287        let json = serde_json::to_string(&report).unwrap();
288        assert!(json.contains("\"agents\":[]"));
289    }
290
291    #[test]
292    fn print_run_report_captures_output() {
293        // Verify the function doesn't panic and formats correctly
294        let run = sample_run();
295        let agents = sample_agent_runs();
296        print_run_report(&run, &agents);
297    }
298
299    #[test]
300    fn print_run_report_with_error() {
301        let mut run = sample_run();
302        run.error_message = Some("something broke".to_string());
303        run.status = RunStatus::Failed;
304        print_run_report(&run, &[]);
305    }
306
307    #[test]
308    fn print_runs_table_formats_rows() {
309        let runs = vec![
310            sample_run(),
311            Run {
312                id: "efgh5678".to_string(),
313                issue_number: 99,
314                status: RunStatus::Failed,
315                pr_number: None,
316                branch: None,
317                worktree_path: None,
318                cost_usd: 12.50,
319                auto_merge: false,
320                started_at: "2026-03-13T10:00:00".to_string(),
321                finished_at: None,
322                error_message: Some("budget exceeded".to_string()),
323                complexity: "full".to_string(),
324                issue_source: "github".to_string(),
325            },
326        ];
327        print_runs_table(&runs);
328    }
329
330    #[test]
331    fn run_report_from_run_maps_all_fields() {
332        let run = Run {
333            id: "test0001".to_string(),
334            issue_number: 7,
335            status: RunStatus::Failed,
336            pr_number: Some(55),
337            branch: Some("oven/issue-7-abc".to_string()),
338            worktree_path: None,
339            cost_usd: 18.75,
340            auto_merge: true,
341            started_at: "2026-03-12T10:00:00".to_string(),
342            finished_at: Some("2026-03-12T10:30:00".to_string()),
343            error_message: Some("cost exceeded".to_string()),
344            complexity: "full".to_string(),
345            issue_source: "github".to_string(),
346        };
347        let agents = vec![AgentRun {
348            id: 1,
349            run_id: "test0001".to_string(),
350            agent: "implementer".to_string(),
351            cycle: 1,
352            status: "failed".to_string(),
353            cost_usd: 18.75,
354            turns: 50,
355            started_at: "2026-03-12T10:00:00".to_string(),
356            finished_at: Some("2026-03-12T10:30:00".to_string()),
357            output_summary: None,
358            error_message: Some("budget".to_string()),
359            raw_output: None,
360        }];
361        let report = RunReport::from_run(&run, &agents);
362        assert_eq!(report.id, "test0001");
363        assert_eq!(report.status, "failed");
364        assert_eq!(report.error_message.as_deref(), Some("cost exceeded"));
365        assert_eq!(report.agents.len(), 1);
366        assert_eq!(report.agents[0].turns, 50);
367    }
368}