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