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