1use crate::cli::cost::format_cost;
2use crate::cli::init_store;
3use crate::cli::ndjson;
4use crate::cli::task_ref;
5use crate::store::Task;
6use crate::store::{TaskStatus, TaskStore};
7use anyhow::Context;
8use serde::Serialize;
9use std::collections::BTreeMap;
10use std::collections::BTreeSet;
11use std::collections::HashSet;
12use std::fs;
13use std::str::FromStr;
14use std::sync::Arc;
15
16pub async fn export(
17 task_id: &str,
18 attempt: Option<u32>,
19 format: ExportFormat,
20) -> anyhow::Result<()> {
21 let store = Arc::new(init_store().await?);
22
23 let resolved = task_ref::resolve(&store, task_id).await?;
24 let internal_id = resolved
25 .store_id
26 .ok_or_else(|| anyhow::anyhow!("task not found in store: {task_id}"))?;
27 let repo = resolved.repo.clone();
28 let task = store.get(internal_id).await.context("task not found")?;
29
30 let attempt = if let Some(n) = attempt {
31 n
32 } else {
33 let runs = store.get_runs(internal_id).await?;
34 if runs.is_empty() {
35 anyhow::bail!("no attempts found for task");
36 }
37 let max_attempt = runs.iter().map(|r| r.attempt).max().unwrap_or(1);
38 u32::try_from(max_attempt).unwrap_or(1)
39 };
40
41 let output_path = task_ref::state_dir(&resolved)?
42 .join("attempts")
43 .join(attempt.to_string())
44 .join("output.json");
45
46 if !output_path.exists() {
47 anyhow::bail!(
48 "attempt {attempt} not found (no output file at {})",
49 output_path.display()
50 );
51 }
52
53 let content = fs::read_to_string(&output_path)?;
54
55 match format {
56 ExportFormat::Markdown => export_markdown(&task, attempt, &content, &store, &repo).await,
57 ExportFormat::Json => export_json(&task, attempt, &content),
58 ExportFormat::Raw => {
59 println!("{content}");
60 Ok(())
61 }
62 }
63}
64
65struct ParsedOutput {
67 files_changed: Vec<String>,
69 files_read: Vec<String>,
71 tool_counts: BTreeMap<String, usize>,
73 git_commands: Vec<String>,
75 key_messages: Vec<String>,
77}
78
79impl ParsedOutput {
80 fn is_empty(&self) -> bool {
81 self.files_changed.is_empty()
82 && self.files_read.is_empty()
83 && self.tool_counts.is_empty()
84 && self.git_commands.is_empty()
85 && self.key_messages.is_empty()
86 }
87}
88
89fn parse_output(content: &str) -> ParsedOutput {
96 let mut files_changed: BTreeSet<String> = BTreeSet::new();
97 let mut files_read: BTreeSet<String> = BTreeSet::new();
98 let mut tool_counts: BTreeMap<String, usize> = BTreeMap::default();
99 let mut git_commands: Vec<String> = Vec::new();
100 let mut key_messages: Vec<String> = Vec::new();
101
102 for line in content.lines() {
103 let trimmed = line.trim();
104 if trimmed.is_empty() {
105 continue;
106 }
107 let v: serde_json::Value = match serde_json::from_str(trimmed) {
108 Ok(v) => v,
109 Err(_) => continue,
110 };
111
112 let event_type = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
113
114 match event_type {
115 "assistant" => {
117 if let Some(text) = extract_claude_text(&v) {
118 push_key_message(&mut key_messages, &text);
119 }
120 }
121
122 "tool_use" if v.get("tool").is_some() => {
123 if let Some(tool) = v.get("tool") {
125 let name = tool.get("name").and_then(|n| n.as_str()).unwrap_or("?");
126 *tool_counts.entry(name.to_string()).or_default() += 1;
127 let input = tool.get("input");
128 record_file_or_command(
129 name,
130 input,
131 &mut files_changed,
132 &mut files_read,
133 &mut git_commands,
134 );
135 }
136 }
137
138 "text" => {
140 let text = v
141 .get("part")
142 .and_then(|p| p.get("text"))
143 .and_then(|t| t.as_str())
144 .or_else(|| v.get("text").and_then(|t| t.as_str()))
145 .unwrap_or("");
146 push_key_message(&mut key_messages, text);
147 }
148
149 "tool_use" if v.get("part").is_some() => {
150 if let Some(part) = v.get("part") {
152 let name = part.get("tool").and_then(|t| t.as_str()).unwrap_or("?");
153 *tool_counts.entry(name.to_string()).or_default() += 1;
154 let input = part.get("state").and_then(|s| s.get("input"));
155 record_file_or_command(
156 name,
157 input,
158 &mut files_changed,
159 &mut files_read,
160 &mut git_commands,
161 );
162 }
163 }
164
165 "item.completed" => {
167 if let Some(item) = v.get("item") {
168 let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
169 match item_type {
170 "agent_message" => {
171 let text = item.get("text").and_then(|t| t.as_str()).unwrap_or("");
172 push_key_message(&mut key_messages, text);
173 }
174 "command_execution" => {
175 let cmd = item.get("command").and_then(|c| c.as_str()).unwrap_or("");
176 *tool_counts.entry("bash".to_string()).or_default() += 1;
177 let first_line = cmd.lines().next().unwrap_or("").trim();
178 if (first_line.starts_with("git ") || first_line == "git")
179 && git_commands.len() < 20
180 {
181 git_commands.push(ndjson::truncate(first_line, 120));
182 }
183 }
184 _ => {}
185 }
186 }
187 }
188
189 _ => {}
190 }
191 }
192
193 ParsedOutput {
194 files_changed: files_changed
195 .into_iter()
196 .chain(ndjson::extract_files_changed(content))
197 .collect::<BTreeSet<_>>()
198 .into_iter()
199 .collect(),
200 files_read: files_read.into_iter().collect(),
201 tool_counts,
202 git_commands,
203 key_messages,
204 }
205}
206
207fn push_key_message(key_messages: &mut Vec<String>, text: &str) {
209 if text.len() > 40 && key_messages.len() < 10 {
210 key_messages.push(first_sentence(text, 160));
211 }
212}
213
214fn record_file_or_command(
216 name: &str,
217 input: Option<&serde_json::Value>,
218 files_changed: &mut BTreeSet<String>,
219 files_read: &mut BTreeSet<String>,
220 git_commands: &mut Vec<String>,
221) {
222 let Some(input) = input else { return };
223 match name {
224 "Write" | "write" | "NotebookEdit" | "Edit" | "edit" | "MultiEdit" => {
225 if let Some(p) = str_field(input, "file_path") {
226 files_changed.insert(p);
227 }
228 }
229 "Read" | "read" => {
230 if let Some(p) = str_field(input, "file_path") {
231 files_read.insert(p);
232 }
233 }
234 "Bash" | "bash" => {
235 if let Some(cmd) = str_field(input, "command") {
236 let first_line = cmd.lines().next().unwrap_or("").trim();
237 if (first_line.starts_with("git ") || first_line == "git")
238 && git_commands.len() < 20
239 {
240 git_commands.push(ndjson::truncate(first_line, 120));
241 }
242 }
243 }
244 _ => {}
245 }
246}
247
248async fn export_markdown(
249 task: &Task,
250 attempt: u32,
251 content: &str,
252 store: &Arc<TaskStore>,
253 _repo: &str,
254) -> anyhow::Result<()> {
255 let runs = store.get_runs(task.id).await?;
256 let attempt_i32 = i32::try_from(attempt).ok();
257 let attempt_run = runs.iter().find(|r| Some(r.attempt) == attempt_i32);
258
259 let agent = task.agent.as_deref().unwrap_or("unknown");
260 let model = task.model.as_deref().unwrap_or("-");
261
262 let duration = attempt_run.map_or(0.0, |r| r.duration_secs);
263 let duration_str = format_duration(duration);
264
265 let input_tokens = task.input_tokens;
266 let output_tokens = task.output_tokens;
267
268 let cost = task.total_cost_usd;
269
270 println!(
271 "# Session: {} (Attempt {})",
272 task_id_display(task.id, task.external_id.as_ref()),
273 attempt
274 );
275 println!();
276 println!("**Agent**: {agent} ({model})");
277 println!("**Duration**: {duration_str}");
278 println!(
279 "**Tokens**: {} in / {} out",
280 format_num(input_tokens),
281 format_num(output_tokens)
282 );
283 if input_tokens > 0 || output_tokens > 0 || cost > 0.0 {
284 println!("**Cost**: {}", format_cost(cost));
285 }
286 println!("---");
287
288 if content.trim().is_empty() {
289 println!("(no output captured)");
290 return Ok(());
291 }
292
293 let parsed = parse_output(content);
295 if !parsed.is_empty() {
296 println!();
297 println!("## Summary");
298
299 if !parsed.files_changed.is_empty() {
300 println!();
301 println!("### Files Changed");
302 for f in &parsed.files_changed {
303 println!("- `{f}`");
304 }
305 }
306
307 if !parsed.tool_counts.is_empty() {
308 println!();
309 println!("### Tool Usage");
310 let mut counts: Vec<(&String, &usize)> = parsed.tool_counts.iter().collect();
312 counts.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
313 for (tool, count) in &counts {
314 println!("- **{tool}**: {count}");
315 }
316 }
317
318 if !parsed.git_commands.is_empty() {
319 println!();
320 println!("### Git Context");
321 let mut seen = HashSet::new();
323 for cmd in &parsed.git_commands {
324 if seen.insert(cmd.as_str()) {
325 println!("- `{cmd}`");
326 }
327 }
328 }
329
330 if !parsed.key_messages.is_empty() {
331 println!();
332 println!("### Key Decisions");
333 for msg in &parsed.key_messages {
334 println!("- {msg}");
335 }
336 }
337 }
338
339 println!();
341 println!("## Output");
342 println!();
343
344 let mut formatter = ndjson::Formatter::new(false);
345 for line in content.lines() {
346 ndjson::print(&formatter.format(line));
347 }
348
349 let summary = &task.summary;
351 if !summary.is_empty() {
352 println!();
353 println!("---");
354 println!();
355 println!("## Result");
356 println!();
357
358 let status = if task.status == TaskStatus::Done {
359 "success"
360 } else if task.status == TaskStatus::Blocked {
361 "blocked"
362 } else {
363 "incomplete"
364 };
365
366 println!("**Status**: {status}");
367 println!("**Summary**: {summary}");
368 }
369
370 let error = &task.last_error;
371 if !error.is_empty() {
372 println!();
373 println!("**Error**: {error}");
374 }
375
376 Ok(())
377}
378
379fn export_json(task: &Task, attempt: u32, content: &str) -> anyhow::Result<()> {
380 #[derive(Serialize)]
381 struct SessionExport {
382 task_id: i64,
383 external_id: Option<String>,
384 attempt: u32,
385 agent: Option<String>,
386 model: Option<String>,
387 input_tokens: i64,
388 output_tokens: i64,
389 total_cost_usd: f64,
390 summary: String,
391 last_error: String,
392 status: String,
393 files_changed: Vec<String>,
394 files_read: Vec<String>,
395 tool_counts: BTreeMap<String, usize>,
396 git_commands: Vec<String>,
397 key_messages: Vec<String>,
398 output: Vec<serde_json::Value>,
399 }
400
401 let output_lines: Vec<serde_json::Value> = content
402 .lines()
403 .filter_map(|line| {
404 let trimmed = line.trim();
405 if trimmed.is_empty() {
406 return None;
407 }
408 serde_json::from_str(trimmed).ok()
409 })
410 .collect();
411
412 let parsed = parse_output(content);
413
414 let export = SessionExport {
415 task_id: task.id,
416 external_id: task.external_id.clone(),
417 attempt,
418 agent: task.agent.clone(),
419 model: task.model.clone(),
420 input_tokens: task.input_tokens,
421 output_tokens: task.output_tokens,
422 total_cost_usd: task.total_cost_usd,
423 summary: task.summary.clone(),
424 last_error: task.last_error.clone(),
425 status: task.status.as_str().to_string(),
426 files_changed: parsed.files_changed,
427 files_read: parsed.files_read,
428 tool_counts: parsed.tool_counts,
429 git_commands: parsed.git_commands,
430 key_messages: parsed.key_messages,
431 output: output_lines,
432 };
433
434 println!("{}", serde_json::to_string_pretty(&export)?);
435
436 Ok(())
437}
438
439fn task_id_display(internal_id: i64, external_id: Option<&String>) -> String {
440 match external_id {
441 Some(ext) => format!("{ext}:{internal_id}"),
442 None => format!("internal:{internal_id}"),
443 }
444}
445
446fn format_num(n: i64) -> String {
447 let s = n.to_string();
448 let mut result = String::new();
449 for (i, c) in s.chars().rev().enumerate() {
450 if i > 0 && i % 3 == 0 {
451 result.push(',');
452 }
453 result.push(c);
454 }
455 result.chars().rev().collect()
456}
457
458fn format_duration(secs: f64) -> String {
459 if secs < 60.0 {
460 format!("{secs:.0}s")
461 } else if secs < 3600.0 {
462 let mins = secs / 60.0;
463 if mins < 10.0 {
464 format!("{mins:.1}m")
465 } else {
466 format!("{mins:.0}m")
467 }
468 } else {
469 let hours = secs / 3600.0;
470 format!("{hours:.1}h")
471 }
472}
473
474fn extract_claude_text(v: &serde_json::Value) -> Option<String> {
476 let content = v.get("message")?.get("content")?.as_array()?;
477 let text: String = content
478 .iter()
479 .filter_map(|item| {
480 if item.get("type").and_then(|t| t.as_str()) == Some("text") {
481 item.get("text")
482 .and_then(|t| t.as_str())
483 .map(str::to_string)
484 } else {
485 None
486 }
487 })
488 .collect::<String>();
489 if text.is_empty() { None } else { Some(text) }
490}
491
492fn str_field(v: &serde_json::Value, key: &str) -> Option<String> {
494 v.get(key).and_then(|s| s.as_str()).map(str::to_string)
495}
496
497fn first_sentence(s: &str, max: usize) -> String {
499 let sentence_end = s
501 .char_indices()
502 .find(|(_, c)| matches!(c, '.' | '!' | '?'))
503 .map_or(s.len(), |(i, _)| i + 1);
504 let candidate = &s[..sentence_end.min(s.len())];
505 ndjson::truncate(candidate.trim(), max)
506}
507
508#[derive(Debug, Clone, Copy, Default)]
509pub enum ExportFormat {
510 #[default]
511 Markdown,
512 Json,
513 Raw,
514}
515
516impl FromStr for ExportFormat {
517 type Err = String;
518
519 fn from_str(s: &str) -> Result<Self, Self::Err> {
520 match s.to_lowercase().as_str() {
521 "markdown" | "md" => Ok(Self::Markdown),
522 "json" | "js" => Ok(Self::Json),
523 "raw" | "ndjson" => Ok(Self::Raw),
524 _ => Err(format!("unknown format: {s}")),
525 }
526 }
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532
533 #[test]
534 fn parse_claude_write_edit() {
535 let content = r#"
536{"type":"tool_use","tool":{"name":"Write","input":{"file_path":"src/lib.rs","content":"..."}}}
537{"type":"tool_use","tool":{"name":"Edit","input":{"file_path":"src/main.rs","old_string":"foo","new_string":"bar"}}}
538{"type":"tool_use","tool":{"name":"Read","input":{"file_path":"Cargo.toml"}}}
539"#;
540 let p = parse_output(content);
541 assert_eq!(p.files_changed, vec!["src/lib.rs", "src/main.rs"]);
542 assert_eq!(p.files_read, vec!["Cargo.toml"]);
543 assert_eq!(p.tool_counts["Write"], 1);
544 assert_eq!(p.tool_counts["Edit"], 1);
545 assert_eq!(p.tool_counts["Read"], 1);
546 }
547
548 #[test]
549 fn parse_claude_git_bash() {
550 let content = r#"
551{"type":"tool_use","tool":{"name":"Bash","input":{"command":"git status"}}}
552{"type":"tool_use","tool":{"name":"Bash","input":{"command":"cargo build"}}}
553{"type":"tool_use","tool":{"name":"Bash","input":{"command":"git commit -m \"fix\""}}}
554"#;
555 let p = parse_output(content);
556 assert_eq!(p.git_commands.len(), 2);
557 assert!(p.git_commands.contains(&"git status".to_string()));
558 assert!(
559 p.git_commands
560 .contains(&"git commit -m \"fix\"".to_string())
561 );
562 assert_eq!(p.tool_counts["Bash"], 3);
563 }
564
565 #[test]
566 fn parse_opencode_tool_use() {
567 let content = r#"
568{"type":"tool_use","part":{"tool":"write","state":{"input":{"file_path":"README.md"}}}}
569{"type":"tool_use","part":{"tool":"bash","state":{"input":{"command":"git diff"}}}}
570"#;
571 let p = parse_output(content);
572 assert_eq!(p.files_changed, vec!["README.md"]);
573 assert_eq!(p.git_commands, vec!["git diff"]);
574 }
575
576 #[test]
577 fn parse_codex_command_execution() {
578 let content = r#"
579{"type":"item.completed","item":{"type":"command_execution","command":"git log --oneline -5"}}
580{"type":"item.completed","item":{"type":"command_execution","command":"cargo test"}}
581"#;
582 let p = parse_output(content);
583 assert_eq!(p.git_commands, vec!["git log --oneline -5"]);
584 assert_eq!(p.tool_counts["bash"], 2);
585 }
586
587 #[test]
588 fn parse_key_messages_from_assistant() {
589 let content = r#"
590{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"This is a short msg."}]}}
591{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I have analyzed the codebase and found that the main issue is in the authentication module."}]}}
592"#;
593 let p = parse_output(content);
594 assert_eq!(p.key_messages.len(), 1);
596 assert!(p.key_messages[0].contains("authentication"));
597 }
598
599 #[test]
600 fn parse_key_messages_from_opencode_and_codex() {
601 let long = "x".repeat(41);
602 let content = format!(
603 r#"{{"type":"text","part":{{"text":"{long}"}}}}
604{{"type":"text","text":"{long}"}}
605{{"type":"text","part":{{"text":"short"}}}}
606{{"type":"item.completed","item":{{"type":"agent_message","text":"{long}"}}}}
607{{"type":"item.completed","item":{{"type":"agent_message","text":"short"}}}}
608"#
609 );
610 let p = parse_output(&content);
611 assert_eq!(p.key_messages.len(), 3);
612 }
613
614 #[test]
615 fn parse_key_messages_capped_at_ten() {
616 let line = format!(r#"{{"type":"text","text":"{}"}}"#, "y".repeat(50));
617 let content = vec![line; 15].join("\n");
618 let p = parse_output(&content);
619 assert_eq!(p.key_messages.len(), 10);
620 }
621
622 #[test]
623 fn parse_codex_git_commands_capped_at_twenty() {
624 let line = r#"{"type":"item.completed","item":{"type":"command_execution","command":"git status"}}"#;
625 let content = vec![line; 25].join("\n");
626 let p = parse_output(&content);
627 assert_eq!(p.git_commands.len(), 20);
628 assert_eq!(p.tool_counts["bash"], 25);
629 }
630
631 #[test]
632 fn parse_empty_content() {
633 let p = parse_output("");
634 assert!(p.is_empty());
635 }
636
637 #[test]
638 fn first_sentence_with_period() {
639 let s = "I will fix the bug. Then run tests.";
640 assert_eq!(first_sentence(s, 200), "I will fix the bug.");
641 }
642
643 #[test]
644 fn first_sentence_truncated() {
645 let long = "abcdefghijklmnopqrstuvwxyz".repeat(10);
646 let result = first_sentence(&long, 20);
647 assert!(result.ends_with('…'));
648 }
649
650 #[test]
651 fn first_sentence_cuts_on_char_count_not_bytes() {
652 assert_eq!(first_sentence("ééééé", 3), "ééé…");
653 assert_eq!(first_sentence("ééé", 3), "ééé");
654 }
655
656 #[test]
657 fn first_sentence_without_terminator_keeps_whole_text() {
658 assert_eq!(first_sentence(" no terminator ", 50), "no terminator");
659 assert_eq!(first_sentence("", 5), "");
660 }
661
662 #[test]
663 fn format_num_thousands() {
664 assert_eq!(format_num(1_234_567), "1,234,567");
665 assert_eq!(format_num(999), "999");
666 }
667
668 #[test]
669 fn format_duration_variants() {
670 assert_eq!(format_duration(45.0), "45s");
671 assert_eq!(format_duration(90.0), "1.5m");
672 assert_eq!(format_duration(600.0), "10m");
673 assert_eq!(format_duration(7200.0), "2.0h");
674 }
675}