Skip to main content

axon/
output.rs

1//! Execution output formats — structured report for programmatic integration.
2//!
3//! Provides `ExecutionReport` — a serde-serializable struct that captures
4//! the full result of an AXON execution: units, steps, results, token usage,
5//! timing from HookManager, anchor results, and conversation turns.
6//!
7//! Output formats:
8//!   text (default) — human-readable colored terminal output
9//!   json           — structured JSON to stdout for CI/CD, tooling, dashboards
10
11use serde::Serialize;
12
13use crate::hooks::HookManager;
14use crate::plan_export::SchemaHeader;
15
16// ── Output format enum ─────────────────────────────────────────────────────
17
18/// Output format for execution results.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum OutputFormat {
21    Text,
22    Json,
23}
24
25impl OutputFormat {
26    /// Parse from CLI string. Returns None for invalid values.
27    pub fn from_str(s: &str) -> Option<Self> {
28        match s {
29            "text" => Some(OutputFormat::Text),
30            "json" => Some(OutputFormat::Json),
31            _ => None,
32        }
33    }
34
35    pub fn is_json(&self) -> bool {
36        *self == OutputFormat::Json
37    }
38}
39
40// ── Report structures ──────────────────────────────────────────────────────
41
42/// A single step result within a unit report.
43#[derive(Debug, Clone, Serialize)]
44pub struct StepReport {
45    pub name: String,
46    pub step_type: String,
47    pub result: String,
48    pub duration_ms: u64,
49    pub input_tokens: u64,
50    pub output_tokens: u64,
51    pub anchor_breaches: u32,
52    pub chain_activations: u32,
53    pub was_retried: bool,
54}
55
56/// A single execution unit report.
57#[derive(Debug, Clone, Serialize)]
58pub struct UnitReport {
59    pub flow_name: String,
60    pub persona_name: String,
61    pub steps: Vec<StepReport>,
62    pub duration_ms: u64,
63    pub total_input_tokens: u64,
64    pub total_output_tokens: u64,
65    pub total_anchor_breaches: u32,
66    pub total_chain_activations: u32,
67}
68
69/// Top-level execution report — the full structured output.
70#[derive(Debug, Clone, Serialize)]
71pub struct ExecutionReport {
72    pub _schema: SchemaHeader,
73    pub axon_version: String,
74    pub source_file: String,
75    pub backend: String,
76    pub mode: String,
77    pub success: bool,
78    pub units: Vec<UnitReport>,
79    pub summary: ExecutionSummary,
80}
81
82/// Aggregate summary across all units.
83#[derive(Debug, Clone, Serialize)]
84pub struct ExecutionSummary {
85    pub total_units: usize,
86    pub total_steps: usize,
87    pub total_duration_ms: u64,
88    pub avg_step_duration_ms: u64,
89    pub total_input_tokens: u64,
90    pub total_output_tokens: u64,
91    pub total_tokens: u64,
92    pub retried_steps: usize,
93}
94
95// ── Report builder ─────────────────────────────────────────────────────────
96
97/// Accumulates step results during execution, then builds the final report.
98pub struct ReportBuilder {
99    source_file: String,
100    backend: String,
101    mode: String,
102    unit_reports: Vec<UnitReport>,
103    // In-flight unit tracking
104    current_unit_steps: Vec<StepReport>,
105    current_flow_name: String,
106    current_persona_name: String,
107}
108
109impl ReportBuilder {
110    pub fn new(source_file: &str, backend: &str, mode: &str) -> Self {
111        ReportBuilder {
112            source_file: source_file.to_string(),
113            backend: backend.to_string(),
114            mode: mode.to_string(),
115            unit_reports: Vec::new(),
116            current_unit_steps: Vec::new(),
117            current_flow_name: String::new(),
118            current_persona_name: String::new(),
119        }
120    }
121
122    /// Signal the start of a unit.
123    pub fn begin_unit(&mut self, flow_name: &str, persona_name: &str) {
124        self.current_flow_name = flow_name.to_string();
125        self.current_persona_name = persona_name.to_string();
126        self.current_unit_steps.clear();
127    }
128
129    /// Record a step result.
130    pub fn record_step(&mut self, step: StepReport) {
131        self.current_unit_steps.push(step);
132    }
133
134    /// Finalize the current unit using metrics from HookManager.
135    pub fn end_unit(&mut self, hooks: &HookManager) {
136        let unit_metrics = hooks.unit_metrics();
137        let um = unit_metrics.last();
138
139        self.unit_reports.push(UnitReport {
140            flow_name: self.current_flow_name.clone(),
141            persona_name: self.current_persona_name.clone(),
142            steps: std::mem::take(&mut self.current_unit_steps),
143            duration_ms: um.map(|u| u.duration_ms).unwrap_or(0),
144            total_input_tokens: um.map(|u| u.total_input_tokens).unwrap_or(0),
145            total_output_tokens: um.map(|u| u.total_output_tokens).unwrap_or(0),
146            total_anchor_breaches: um.map(|u| u.total_anchor_breaches).unwrap_or(0),
147            total_chain_activations: um.map(|u| u.total_chain_activations).unwrap_or(0),
148        });
149    }
150
151    /// Build the final report.
152    pub fn build(self, success: bool, hooks: &HookManager) -> ExecutionReport {
153        ExecutionReport {
154            _schema: SchemaHeader::new("axon.report"),
155            axon_version: crate::runner::AXON_VERSION.to_string(),
156            source_file: self.source_file,
157            backend: self.backend,
158            mode: self.mode,
159            success,
160            units: self.unit_reports,
161            summary: ExecutionSummary {
162                total_units: hooks.unit_metrics().len(),
163                total_steps: hooks.total_steps(),
164                total_duration_ms: hooks.total_duration_ms(),
165                avg_step_duration_ms: hooks.avg_step_duration_ms(),
166                total_input_tokens: hooks.total_input_tokens(),
167                total_output_tokens: hooks.total_output_tokens(),
168                total_tokens: hooks.total_input_tokens() + hooks.total_output_tokens(),
169                retried_steps: hooks.retried_steps(),
170            },
171        }
172    }
173
174    /// Serialize the report to JSON string.
175    pub fn to_json(report: &ExecutionReport) -> String {
176        serde_json::to_string_pretty(report).unwrap_or_else(|e| {
177            format!("{{\"error\": \"serialization failed: {e}\"}}")
178        })
179    }
180}
181
182// ── Tests ──────────────────────────────────────────────────────────────────
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use crate::hooks::HookManager;
188
189    #[test]
190    fn output_format_parsing() {
191        assert_eq!(OutputFormat::from_str("text"), Some(OutputFormat::Text));
192        assert_eq!(OutputFormat::from_str("json"), Some(OutputFormat::Json));
193        assert_eq!(OutputFormat::from_str("xml"), None);
194        assert_eq!(OutputFormat::from_str(""), None);
195    }
196
197    #[test]
198    fn output_format_is_json() {
199        assert!(!OutputFormat::Text.is_json());
200        assert!(OutputFormat::Json.is_json());
201    }
202
203    #[test]
204    fn report_builder_empty() {
205        let hooks = HookManager::new();
206        let rb = ReportBuilder::new("test.axon", "anthropic", "stub");
207        let report = rb.build(true, &hooks);
208
209        assert_eq!(report.source_file, "test.axon");
210        assert_eq!(report.backend, "anthropic");
211        assert_eq!(report.mode, "stub");
212        assert!(report.success);
213        assert!(report.units.is_empty());
214        assert_eq!(report.summary.total_units, 0);
215        assert_eq!(report.summary.total_steps, 0);
216    }
217
218    #[test]
219    fn report_builder_with_steps() {
220        let mut hooks = HookManager::new();
221        let mut rb = ReportBuilder::new("demo.axon", "openai", "real");
222
223        hooks.on_unit_start("Analyze", "Expert");
224        rb.begin_unit("Analyze", "Expert");
225
226        hooks.on_step_start("Gather", "step");
227        hooks.on_step_end(100, 50, 0, 0, false);
228        rb.record_step(StepReport {
229            name: "Gather".into(),
230            step_type: "step".into(),
231            result: "gathered data".into(),
232            duration_ms: 0,
233            input_tokens: 100,
234            output_tokens: 50,
235            anchor_breaches: 0,
236            chain_activations: 0,
237            was_retried: false,
238        });
239
240        hooks.on_step_start("Summarize", "step");
241        hooks.on_step_end(200, 100, 1, 0, true);
242        rb.record_step(StepReport {
243            name: "Summarize".into(),
244            step_type: "step".into(),
245            result: "summary text".into(),
246            duration_ms: 0,
247            input_tokens: 200,
248            output_tokens: 100,
249            anchor_breaches: 1,
250            chain_activations: 0,
251            was_retried: true,
252        });
253
254        hooks.on_unit_end();
255        rb.end_unit(&hooks);
256
257        let report = rb.build(true, &hooks);
258        assert_eq!(report.units.len(), 1);
259        assert_eq!(report.units[0].flow_name, "Analyze");
260        assert_eq!(report.units[0].steps.len(), 2);
261        assert_eq!(report.units[0].steps[0].name, "Gather");
262        assert_eq!(report.units[0].steps[1].name, "Summarize");
263        assert!(report.units[0].steps[1].was_retried);
264        assert_eq!(report.summary.total_steps, 2);
265        assert_eq!(report.summary.total_input_tokens, 300);
266        assert_eq!(report.summary.total_output_tokens, 150);
267        assert_eq!(report.summary.total_tokens, 450);
268        assert_eq!(report.summary.retried_steps, 1);
269    }
270
271    #[test]
272    fn report_serializes_to_json() {
273        let hooks = HookManager::new();
274        let rb = ReportBuilder::new("test.axon", "anthropic", "stub");
275        let report = rb.build(true, &hooks);
276        let json = ReportBuilder::to_json(&report);
277
278        assert!(json.contains("\"axon_version\""));
279        assert!(json.contains("\"source_file\""));
280        assert!(json.contains("\"test.axon\""));
281        assert!(json.contains("\"summary\""));
282        assert!(json.contains("\"total_steps\""));
283    }
284
285    #[test]
286    fn report_multiple_units() {
287        let mut hooks = HookManager::new();
288        let mut rb = ReportBuilder::new("multi.axon", "gemini", "real");
289
290        // Unit 1
291        hooks.on_unit_start("Flow1", "P1");
292        rb.begin_unit("Flow1", "P1");
293        hooks.on_step_start("S1", "step");
294        hooks.on_step_end(10, 5, 0, 0, false);
295        rb.record_step(StepReport {
296            name: "S1".into(),
297            step_type: "step".into(),
298            result: "r1".into(),
299            duration_ms: 0,
300            input_tokens: 10,
301            output_tokens: 5,
302            anchor_breaches: 0,
303            chain_activations: 0,
304            was_retried: false,
305        });
306        hooks.on_unit_end();
307        rb.end_unit(&hooks);
308
309        // Unit 2
310        hooks.on_unit_start("Flow2", "P2");
311        rb.begin_unit("Flow2", "P2");
312        hooks.on_step_start("S2", "step");
313        hooks.on_step_end(20, 10, 0, 0, false);
314        rb.record_step(StepReport {
315            name: "S2".into(),
316            step_type: "step".into(),
317            result: "r2".into(),
318            duration_ms: 0,
319            input_tokens: 20,
320            output_tokens: 10,
321            anchor_breaches: 0,
322            chain_activations: 0,
323            was_retried: false,
324        });
325        hooks.on_unit_end();
326        rb.end_unit(&hooks);
327
328        let report = rb.build(true, &hooks);
329        assert_eq!(report.units.len(), 2);
330        assert_eq!(report.summary.total_units, 2);
331        assert_eq!(report.summary.total_tokens, 45);
332    }
333
334    #[test]
335    fn report_json_round_trip() {
336        let mut hooks = HookManager::new();
337        let mut rb = ReportBuilder::new("rt.axon", "anthropic", "stub");
338
339        hooks.on_unit_start("F", "P");
340        rb.begin_unit("F", "P");
341        hooks.on_step_start("S", "step");
342        hooks.on_step_end(42, 21, 0, 0, false);
343        rb.record_step(StepReport {
344            name: "S".into(),
345            step_type: "step".into(),
346            result: "hello world".into(),
347            duration_ms: 0,
348            input_tokens: 42,
349            output_tokens: 21,
350            anchor_breaches: 0,
351            chain_activations: 0,
352            was_retried: false,
353        });
354        hooks.on_unit_end();
355        rb.end_unit(&hooks);
356
357        let report = rb.build(true, &hooks);
358        let json = ReportBuilder::to_json(&report);
359
360        // Parse back and verify key fields
361        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
362        assert_eq!(parsed["source_file"], "rt.axon");
363        assert_eq!(parsed["success"], true);
364        assert_eq!(parsed["units"][0]["flow_name"], "F");
365        assert_eq!(parsed["units"][0]["steps"][0]["result"], "hello world");
366        assert_eq!(parsed["summary"]["total_tokens"], 63);
367    }
368}