Skip to main content

scud/commands/swarm/
transcript.rs

1//! Transcript extraction from Claude Code conversation logs
2//!
3//! Claude Code stores conversation logs in ~/.claude/projects/<project>/*.jsonl
4//! Each line is a message with type (user/assistant), timestamp, and content.
5//! This module extracts and formats these into readable transcripts.
6
7use std::collections::HashMap;
8use std::fs::{self, File};
9use std::io::{BufRead, BufReader};
10use std::path::{Path, PathBuf};
11
12use anyhow::Result;
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15
16/// A single message in the conversation
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct TranscriptMessage {
19    pub timestamp: DateTime<Utc>,
20    pub uuid: String,
21    pub parent_uuid: Option<String>,
22    pub session_id: String,
23    pub role: String, // "user" or "assistant"
24    pub content: MessageContent,
25}
26
27/// Content of a message (can be text or tool use)
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(untagged)]
30pub enum MessageContent {
31    Text(String),
32    Structured(StructuredContent),
33}
34
35/// Structured content from API response
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct StructuredContent {
38    pub role: Option<String>,
39    pub content: Option<serde_json::Value>,
40    pub model: Option<String>,
41    pub usage: Option<Usage>,
42    #[serde(rename = "stop_reason")]
43    pub stop_reason: Option<String>,
44}
45
46/// Token usage stats
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct Usage {
49    pub input_tokens: Option<u64>,
50    pub output_tokens: Option<u64>,
51}
52
53/// A tool call extracted from the transcript
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct ToolCall {
56    pub id: String,
57    pub name: String,
58    pub input: serde_json::Value,
59    pub timestamp: DateTime<Utc>,
60}
61
62/// A tool result extracted from the transcript
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct ToolResult {
65    pub tool_use_id: String,
66    pub content: String,
67    pub is_error: bool,
68    pub timestamp: DateTime<Utc>,
69}
70
71/// Full transcript of a session
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct Transcript {
74    pub session_id: String,
75    pub project_path: String,
76    pub started_at: Option<DateTime<Utc>>,
77    pub ended_at: Option<DateTime<Utc>>,
78    pub messages: Vec<TranscriptMessage>,
79    pub tool_calls: Vec<ToolCall>,
80    pub tool_results: Vec<ToolResult>,
81    pub total_input_tokens: u64,
82    pub total_output_tokens: u64,
83}
84
85impl Transcript {
86    /// Create an empty transcript
87    pub fn new(session_id: &str, project_path: &str) -> Self {
88        Self {
89            session_id: session_id.to_string(),
90            project_path: project_path.to_string(),
91            started_at: None,
92            ended_at: None,
93            messages: Vec::new(),
94            tool_calls: Vec::new(),
95            tool_results: Vec::new(),
96            total_input_tokens: 0,
97            total_output_tokens: 0,
98        }
99    }
100
101    /// Format as readable text
102    pub fn to_text(&self) -> String {
103        use std::fmt::Write;
104        let mut s = String::new();
105
106        writeln!(s, "# Transcript: {}", self.session_id).unwrap();
107        writeln!(s).unwrap();
108
109        if let Some(start) = self.started_at {
110            writeln!(s, "Started: {}", start.format("%Y-%m-%d %H:%M:%S")).unwrap();
111        }
112        if let Some(end) = self.ended_at {
113            writeln!(s, "Ended: {}", end.format("%Y-%m-%d %H:%M:%S")).unwrap();
114        }
115
116        writeln!(
117            s,
118            "Tokens: {} in / {} out",
119            self.total_input_tokens, self.total_output_tokens
120        )
121        .unwrap();
122        writeln!(s, "Tool calls: {}", self.tool_calls.len()).unwrap();
123        writeln!(s).unwrap();
124        writeln!(s, "---").unwrap();
125        writeln!(s).unwrap();
126
127        for msg in &self.messages {
128            let role_prefix = match msg.role.as_str() {
129                "user" => "## User",
130                "assistant" => "## Assistant",
131                _ => "## Unknown",
132            };
133
134            writeln!(s, "{} ({})", role_prefix, msg.timestamp.format("%H:%M:%S")).unwrap();
135            writeln!(s).unwrap();
136
137            match &msg.content {
138                MessageContent::Text(text) => {
139                    writeln!(s, "{}", text).unwrap();
140                }
141                MessageContent::Structured(structured) => {
142                    if let Some(content) = &structured.content {
143                        format_content(&mut s, content);
144                    }
145                }
146            }
147
148            writeln!(s).unwrap();
149        }
150
151        s
152    }
153}
154
155/// Format content array (text blocks and tool uses)
156fn format_content(s: &mut String, content: &serde_json::Value) {
157    use std::fmt::Write;
158
159    match content {
160        serde_json::Value::Array(arr) => {
161            for item in arr {
162                if let Some(obj) = item.as_object() {
163                    if let Some(type_val) = obj.get("type") {
164                        match type_val.as_str() {
165                            Some("text") => {
166                                if let Some(text) = obj.get("text").and_then(|t| t.as_str()) {
167                                    writeln!(s, "{}", text).unwrap();
168                                }
169                            }
170                            Some("tool_use") => {
171                                let name = obj
172                                    .get("name")
173                                    .and_then(|n| n.as_str())
174                                    .unwrap_or("unknown");
175                                writeln!(s, "**Tool: {}**", name).unwrap();
176                                if let Some(input) = obj.get("input") {
177                                    // Compact JSON for tool input
178                                    if let Ok(json) = serde_json::to_string(input) {
179                                        let truncated = if json.len() > 200 {
180                                            format!("{}...", &json[..200])
181                                        } else {
182                                            json
183                                        };
184                                        writeln!(s, "```json\n{}\n```", truncated).unwrap();
185                                    }
186                                }
187                            }
188                            Some("tool_result") => {
189                                let tool_id = obj
190                                    .get("tool_use_id")
191                                    .and_then(|id| id.as_str())
192                                    .unwrap_or("unknown");
193                                writeln!(s, "**Tool Result** ({})", tool_id).unwrap();
194                                if let Some(content) = obj.get("content") {
195                                    let text = match content {
196                                        serde_json::Value::String(s) => s.clone(),
197                                        _ => serde_json::to_string(content).unwrap_or_default(),
198                                    };
199                                    let truncated = if text.len() > 500 {
200                                        format!("{}...", &text[..500])
201                                    } else {
202                                        text
203                                    };
204                                    writeln!(s, "```\n{}\n```", truncated).unwrap();
205                                }
206                            }
207                            _ => {}
208                        }
209                    }
210                }
211            }
212        }
213        serde_json::Value::String(text) => {
214            writeln!(s, "{}", text).unwrap();
215        }
216        _ => {}
217    }
218}
219
220/// Find Claude Code project directory
221pub fn find_claude_project_dir(working_dir: &Path) -> Option<PathBuf> {
222    let home = dirs::home_dir()?;
223    let claude_dir = home.join(".claude").join("projects");
224
225    if !claude_dir.exists() {
226        return None;
227    }
228
229    // Claude Code uses a mangled path name for the project directory
230    // e.g., /home/user/scud -> -home-user-scud
231    let project_name = working_dir
232        .to_string_lossy()
233        .replace('/', "-")
234        .trim_start_matches('-')
235        .to_string();
236
237    // Try exact match first
238    let exact_path = claude_dir.join(format!("-{}", project_name));
239    if exact_path.exists() {
240        return Some(exact_path);
241    }
242
243    // Try without leading dash
244    let no_dash_path = claude_dir.join(&project_name);
245    if no_dash_path.exists() {
246        return Some(no_dash_path);
247    }
248
249    // Search for matching directories
250    if let Ok(entries) = fs::read_dir(&claude_dir) {
251        for entry in entries.flatten() {
252            let name = entry.file_name().to_string_lossy().to_string();
253            // Check if the directory name contains our working dir path
254            let normalized_name = name.replace('-', "/");
255            if normalized_name.contains(&working_dir.to_string_lossy().to_string()) {
256                return Some(entry.path());
257            }
258        }
259    }
260
261    None
262}
263
264/// List available session files in a project directory
265pub fn list_session_files(project_dir: &Path) -> Result<Vec<PathBuf>> {
266    let mut files = Vec::new();
267
268    if !project_dir.exists() {
269        return Ok(files);
270    }
271
272    for entry in fs::read_dir(project_dir)? {
273        let entry = entry?;
274        let path = entry.path();
275        if path.extension().map(|e| e == "jsonl").unwrap_or(false) {
276            files.push(path);
277        }
278    }
279
280    // Sort by modification time (most recent last)
281    files.sort_by_key(|p| fs::metadata(p).and_then(|m| m.modified()).ok());
282
283    Ok(files)
284}
285
286/// Parse a JSONL file into a transcript
287pub fn parse_transcript(path: &Path) -> Result<Transcript> {
288    let file = File::open(path)?;
289    let reader = BufReader::new(file);
290
291    let session_id = path
292        .file_stem()
293        .and_then(|s| s.to_str())
294        .unwrap_or("unknown")
295        .to_string();
296
297    let project_path = path
298        .parent()
299        .and_then(|p| p.file_name())
300        .and_then(|s| s.to_str())
301        .unwrap_or("unknown")
302        .to_string();
303
304    let mut transcript = Transcript::new(&session_id, &project_path);
305
306    for line in reader.lines() {
307        let line = line?;
308        if line.trim().is_empty() {
309            continue;
310        }
311
312        if let Ok(entry) = serde_json::from_str::<serde_json::Value>(&line) {
313            // Extract timestamp
314            let timestamp = entry
315                .get("timestamp")
316                .and_then(|t| t.as_str())
317                .and_then(|t| DateTime::parse_from_rfc3339(t).ok())
318                .map(|dt| dt.with_timezone(&Utc))
319                .unwrap_or_else(Utc::now);
320
321            // Update time range
322            if transcript.started_at.is_none() || Some(timestamp) < transcript.started_at {
323                transcript.started_at = Some(timestamp);
324            }
325            if transcript.ended_at.is_none() || Some(timestamp) > transcript.ended_at {
326                transcript.ended_at = Some(timestamp);
327            }
328
329            // Extract message type
330            let msg_type = entry
331                .get("type")
332                .and_then(|t| t.as_str())
333                .unwrap_or("unknown");
334
335            let uuid = entry
336                .get("uuid")
337                .and_then(|u| u.as_str())
338                .unwrap_or("")
339                .to_string();
340
341            let parent_uuid = entry
342                .get("parentUuid")
343                .and_then(|u| u.as_str())
344                .map(String::from);
345
346            let session_id = entry
347                .get("sessionId")
348                .and_then(|s| s.as_str())
349                .unwrap_or("")
350                .to_string();
351
352            if let Some(message) = entry.get("message") {
353                // Extract usage stats from assistant messages
354                if msg_type == "assistant" {
355                    if let Some(usage) = message.get("usage") {
356                        if let Some(input) = usage.get("input_tokens").and_then(|t| t.as_u64()) {
357                            transcript.total_input_tokens += input;
358                        }
359                        if let Some(output) = usage.get("output_tokens").and_then(|t| t.as_u64()) {
360                            transcript.total_output_tokens += output;
361                        }
362                    }
363
364                    // Extract tool calls
365                    if let Some(content) = message.get("content").and_then(|c| c.as_array()) {
366                        for item in content {
367                            if item.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
368                                let tool_call = ToolCall {
369                                    id: item
370                                        .get("id")
371                                        .and_then(|id| id.as_str())
372                                        .unwrap_or("")
373                                        .to_string(),
374                                    name: item
375                                        .get("name")
376                                        .and_then(|n| n.as_str())
377                                        .unwrap_or("")
378                                        .to_string(),
379                                    input: item.get("input").cloned().unwrap_or_default(),
380                                    timestamp,
381                                };
382                                transcript.tool_calls.push(tool_call);
383                            }
384                        }
385                    }
386                }
387
388                // Extract tool results from user messages
389                if msg_type == "user" {
390                    if let Some(content) = message.get("content").and_then(|c| c.as_array()) {
391                        for item in content {
392                            if item.get("type").and_then(|t| t.as_str()) == Some("tool_result") {
393                                let tool_result = ToolResult {
394                                    tool_use_id: item
395                                        .get("tool_use_id")
396                                        .and_then(|id| id.as_str())
397                                        .unwrap_or("")
398                                        .to_string(),
399                                    content: item
400                                        .get("content")
401                                        .map(|c| match c {
402                                            serde_json::Value::String(s) => s.clone(),
403                                            _ => serde_json::to_string(c).unwrap_or_default(),
404                                        })
405                                        .unwrap_or_default(),
406                                    is_error: item
407                                        .get("is_error")
408                                        .and_then(|e| e.as_bool())
409                                        .unwrap_or(false),
410                                    timestamp,
411                                };
412                                transcript.tool_results.push(tool_result);
413                            }
414                        }
415                    }
416                }
417
418                // Create message content
419                let content = if let Some(role) = message.get("role").and_then(|r| r.as_str()) {
420                    // This is an API-style message
421                    MessageContent::Structured(StructuredContent {
422                        role: Some(role.to_string()),
423                        content: message.get("content").cloned(),
424                        model: message
425                            .get("model")
426                            .and_then(|m| m.as_str())
427                            .map(String::from),
428                        usage: message
429                            .get("usage")
430                            .and_then(|u| serde_json::from_value(u.clone()).ok()),
431                        stop_reason: message
432                            .get("stop_reason")
433                            .and_then(|s| s.as_str())
434                            .map(String::from),
435                    })
436                } else if let Some(text) = message.get("content").and_then(|c| c.as_str()) {
437                    // Simple text content
438                    MessageContent::Text(text.to_string())
439                } else {
440                    continue;
441                };
442
443                transcript.messages.push(TranscriptMessage {
444                    timestamp,
445                    uuid,
446                    parent_uuid,
447                    session_id,
448                    role: msg_type.to_string(),
449                    content,
450                });
451            }
452        }
453    }
454
455    Ok(transcript)
456}
457
458/// Find transcript files that overlap with a time range
459pub fn find_transcripts_in_range(
460    project_dir: &Path,
461    start: DateTime<Utc>,
462    end: DateTime<Utc>,
463) -> Result<Vec<PathBuf>> {
464    let all_files = list_session_files(project_dir)?;
465    let mut matching = Vec::new();
466
467    for file in all_files {
468        // Quick check: parse first and last lines for timestamps
469        if let Ok(transcript) = parse_transcript(&file) {
470            if let (Some(t_start), Some(t_end)) = (transcript.started_at, transcript.ended_at) {
471                // Check for overlap
472                if t_start <= end && t_end >= start {
473                    matching.push(file);
474                }
475            }
476        }
477    }
478
479    Ok(matching)
480}
481
482/// Print the full transcript with all messages and tool calls
483pub fn print_full_transcript(transcript: &Transcript) {
484    use colored::Colorize;
485
486    println!();
487    println!("{}", "Full Transcript".blue().bold());
488    println!("{}", "═".repeat(80).blue());
489    println!(
490        "Session: {} | Tokens: {} in / {} out",
491        transcript.session_id.cyan(),
492        transcript.total_input_tokens.to_string().dimmed(),
493        transcript.total_output_tokens.to_string().dimmed()
494    );
495    println!("{}", "═".repeat(80).blue());
496    println!();
497
498    for msg in &transcript.messages {
499        let role_display = match msg.role.as_str() {
500            "user" => "USER".green().bold(),
501            "assistant" => "ASSISTANT".blue().bold(),
502            _ => msg.role.yellow().bold(),
503        };
504
505        println!(
506            "[{}] {} ─────────────────────────────────",
507            msg.timestamp.format("%H:%M:%S"),
508            role_display
509        );
510        println!();
511
512        match &msg.content {
513            MessageContent::Text(text) => {
514                // Wrap long lines for readability
515                for line in text.lines() {
516                    println!("  {}", line);
517                }
518            }
519            MessageContent::Structured(structured) => {
520                if let Some(content) = &structured.content {
521                    print_structured_content(content, 2);
522                }
523            }
524        }
525
526        println!();
527    }
528}
529
530/// Print structured content with indentation
531fn print_structured_content(content: &serde_json::Value, indent: usize) {
532    use colored::Colorize;
533    let pad = " ".repeat(indent);
534
535    match content {
536        serde_json::Value::Array(arr) => {
537            for item in arr {
538                if let Some(obj) = item.as_object() {
539                    if let Some(type_val) = obj.get("type") {
540                        match type_val.as_str() {
541                            Some("text") => {
542                                if let Some(text) = obj.get("text").and_then(|t| t.as_str()) {
543                                    for line in text.lines() {
544                                        println!("{}{}", pad, line);
545                                    }
546                                }
547                            }
548                            Some("tool_use") => {
549                                let name = obj
550                                    .get("name")
551                                    .and_then(|n| n.as_str())
552                                    .unwrap_or("unknown");
553                                let id = obj.get("id").and_then(|id| id.as_str()).unwrap_or("");
554                                println!();
555                                println!(
556                                    "{}{}{}",
557                                    pad,
558                                    "▶ TOOL CALL: ".yellow().bold(),
559                                    name.cyan().bold()
560                                );
561                                println!("{}  ID: {}", pad, id.dimmed());
562                                if let Some(input) = obj.get("input") {
563                                    println!("{}  Input:", pad);
564                                    if let Ok(json) = serde_json::to_string_pretty(input) {
565                                        // Limit output size
566                                        let lines: Vec<&str> = json.lines().collect();
567                                        let max_lines = 20;
568                                        for (i, line) in lines.iter().take(max_lines).enumerate() {
569                                            println!("{}    {}", pad, line.dimmed());
570                                            if i == max_lines - 1 && lines.len() > max_lines {
571                                                println!(
572                                                    "{}    {} more lines...",
573                                                    pad,
574                                                    (lines.len() - max_lines).to_string().yellow()
575                                                );
576                                            }
577                                        }
578                                    }
579                                }
580                                println!();
581                            }
582                            Some("tool_result") => {
583                                let tool_id = obj
584                                    .get("tool_use_id")
585                                    .and_then(|id| id.as_str())
586                                    .unwrap_or("unknown");
587                                let is_error = obj
588                                    .get("is_error")
589                                    .and_then(|e| e.as_bool())
590                                    .unwrap_or(false);
591                                println!();
592                                let header = if is_error {
593                                    "◀ TOOL ERROR".red().bold()
594                                } else {
595                                    "◀ TOOL RESULT".green().bold()
596                                };
597                                println!("{}{} ({})", pad, header, tool_id.dimmed());
598                                if let Some(content) = obj.get("content") {
599                                    let text = match content {
600                                        serde_json::Value::String(s) => s.clone(),
601                                        _ => serde_json::to_string_pretty(content)
602                                            .unwrap_or_default(),
603                                    };
604                                    // Limit output size
605                                    let lines: Vec<&str> = text.lines().collect();
606                                    let max_lines = 30;
607                                    for (i, line) in lines.iter().take(max_lines).enumerate() {
608                                        let display = if line.len() > 120 {
609                                            format!("{}...", &line[..120])
610                                        } else {
611                                            line.to_string()
612                                        };
613                                        println!("{}  {}", pad, display.dimmed());
614                                        if i == max_lines - 1 && lines.len() > max_lines {
615                                            println!(
616                                                "{}  {} more lines...",
617                                                pad,
618                                                (lines.len() - max_lines).to_string().yellow()
619                                            );
620                                        }
621                                    }
622                                }
623                                println!();
624                            }
625                            _ => {}
626                        }
627                    }
628                }
629            }
630        }
631        serde_json::Value::String(text) => {
632            for line in text.lines() {
633                println!("{}{}", pad, line);
634            }
635        }
636        _ => {}
637    }
638}
639
640/// Print a transcript summary
641pub fn print_transcript_summary(transcript: &Transcript) {
642    use colored::Colorize;
643
644    println!();
645    println!("{}", "Transcript".blue().bold());
646    println!("{}", "═".repeat(60).blue());
647
648    println!("  {} {}", "Session:".dimmed(), transcript.session_id.cyan());
649
650    if let (Some(start), Some(end)) = (transcript.started_at, transcript.ended_at) {
651        let duration = end.signed_duration_since(start);
652        println!(
653            "  {} {}s",
654            "Duration:".dimmed(),
655            duration.num_seconds().to_string().cyan()
656        );
657    }
658
659    println!(
660        "  {} {} in / {} out",
661        "Tokens:".dimmed(),
662        transcript.total_input_tokens.to_string().cyan(),
663        transcript.total_output_tokens.to_string().cyan()
664    );
665
666    println!(
667        "  {} {}",
668        "Messages:".dimmed(),
669        transcript.messages.len().to_string().cyan()
670    );
671
672    println!(
673        "  {} {}",
674        "Tool calls:".dimmed(),
675        transcript.tool_calls.len().to_string().cyan()
676    );
677
678    // Show tool usage breakdown
679    if !transcript.tool_calls.is_empty() {
680        let mut tool_counts: HashMap<&str, usize> = HashMap::new();
681        for call in &transcript.tool_calls {
682            *tool_counts.entry(&call.name).or_insert(0) += 1;
683        }
684
685        println!();
686        println!("{}", "Tool Usage".yellow().bold());
687        println!("{}", "─".repeat(40).yellow());
688
689        let mut sorted: Vec<_> = tool_counts.into_iter().collect();
690        sorted.sort_by(|a, b| b.1.cmp(&a.1));
691
692        for (tool, count) in sorted.iter().take(10) {
693            println!("  {:30} {}", tool.dimmed(), count.to_string().cyan());
694        }
695    }
696
697    println!();
698}
699
700/// List available transcripts for a project
701pub fn list_transcripts(project_root: &Path) -> Result<()> {
702    use colored::Colorize;
703
704    let project_dir = find_claude_project_dir(project_root)
705        .ok_or_else(|| anyhow::anyhow!("No Claude Code project found for this directory"))?;
706
707    let files = list_session_files(&project_dir)?;
708
709    if files.is_empty() {
710        println!("{}", "No transcripts found.".yellow());
711        return Ok(());
712    }
713
714    println!();
715    println!("{}", "Available Transcripts".blue().bold());
716    println!("{}", "═".repeat(60).blue());
717
718    for (i, file) in files.iter().rev().enumerate().take(20) {
719        let session_id = file
720            .file_stem()
721            .and_then(|s| s.to_str())
722            .unwrap_or("unknown");
723
724        // Get file size and mod time
725        if let Ok(metadata) = fs::metadata(file) {
726            let size_kb = metadata.len() / 1024;
727            let modified = metadata.modified().ok().and_then(|t| {
728                chrono::DateTime::<Utc>::from(t)
729                    .format("%Y-%m-%d %H:%M")
730                    .to_string()
731                    .into()
732            });
733
734            println!(
735                "  {} {} ({} KB) - {}",
736                format!("[{}]", i + 1).dimmed(),
737                session_id.cyan(),
738                size_kb.to_string().dimmed(),
739                modified.unwrap_or_else(|| "unknown".to_string()).dimmed()
740            );
741        } else {
742            println!(
743                "  {} {}",
744                format!("[{}]", i + 1).dimmed(),
745                session_id.cyan()
746            );
747        }
748    }
749
750    if files.len() > 20 {
751        println!("  ... and {} more", (files.len() - 20).to_string().dimmed());
752    }
753
754    println!();
755    println!(
756        "Use {} to view a transcript",
757        "scud transcript --session <id>".cyan()
758    );
759    println!();
760
761    Ok(())
762}
763
764/// View a specific transcript or the latest one
765pub fn view_transcript(project_root: &Path, session: Option<&str>, full: bool) -> Result<()> {
766    use colored::Colorize;
767
768    let project_dir = find_claude_project_dir(project_root)
769        .ok_or_else(|| anyhow::anyhow!("No Claude Code project found for this directory"))?;
770
771    let files = list_session_files(&project_dir)?;
772
773    if files.is_empty() {
774        anyhow::bail!("No transcripts found");
775    }
776
777    // Find the requested session or use the latest
778    let transcript_path = if let Some(session_id) = session {
779        files
780            .iter()
781            .find(|f| {
782                f.file_stem()
783                    .and_then(|s| s.to_str())
784                    .map(|s| s == session_id || s.contains(session_id))
785                    .unwrap_or(false)
786            })
787            .ok_or_else(|| anyhow::anyhow!("Session '{}' not found", session_id))?
788            .clone()
789    } else {
790        // Use the latest (last in sorted list)
791        files
792            .last()
793            .cloned()
794            .ok_or_else(|| anyhow::anyhow!("No transcripts found"))?
795    };
796
797    println!(
798        "{}",
799        format!("Loading transcript: {}", transcript_path.display()).dimmed()
800    );
801
802    let transcript = parse_transcript(&transcript_path)?;
803
804    if full {
805        print_full_transcript(&transcript);
806    } else {
807        print_transcript_summary(&transcript);
808    }
809
810    Ok(())
811}
812
813/// Export transcript as JSON
814pub fn export_transcript_json(project_root: &Path, session: Option<&str>) -> Result<String> {
815    let project_dir = find_claude_project_dir(project_root)
816        .ok_or_else(|| anyhow::anyhow!("No Claude Code project found for this directory"))?;
817
818    let files = list_session_files(&project_dir)?;
819
820    if files.is_empty() {
821        anyhow::bail!("No transcripts found");
822    }
823
824    let transcript_path = if let Some(session_id) = session {
825        files
826            .iter()
827            .find(|f| {
828                f.file_stem()
829                    .and_then(|s| s.to_str())
830                    .map(|s| s == session_id || s.contains(session_id))
831                    .unwrap_or(false)
832            })
833            .ok_or_else(|| anyhow::anyhow!("Session '{}' not found", session_id))?
834            .clone()
835    } else {
836        files
837            .last()
838            .cloned()
839            .ok_or_else(|| anyhow::anyhow!("No transcripts found"))?
840    };
841
842    let transcript = parse_transcript(&transcript_path)?;
843    serde_json::to_string_pretty(&transcript).map_err(|e| anyhow::anyhow!("JSON error: {}", e))
844}
845
846#[cfg(test)]
847mod tests {
848    use super::*;
849
850    #[test]
851    fn test_transcript_new() {
852        let transcript = Transcript::new("session-1", "project-1");
853        assert_eq!(transcript.session_id, "session-1");
854        assert!(transcript.messages.is_empty());
855    }
856
857    #[test]
858    fn test_format_content_text() {
859        let content = serde_json::json!([
860            {"type": "text", "text": "Hello world"}
861        ]);
862
863        let mut s = String::new();
864        format_content(&mut s, &content);
865        assert!(s.contains("Hello world"));
866    }
867
868    #[test]
869    fn test_format_content_tool_use() {
870        let content = serde_json::json!([
871            {
872                "type": "tool_use",
873                "name": "Read",
874                "input": {"file_path": "/test/file.txt"}
875            }
876        ]);
877
878        let mut s = String::new();
879        format_content(&mut s, &content);
880        assert!(s.contains("Tool: Read"));
881        assert!(s.contains("file_path"));
882    }
883}