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.get("model").and_then(|m| m.as_str()).map(String::from),
425                        usage: message.get("usage").and_then(|u| serde_json::from_value(u.clone()).ok()),
426                        stop_reason: message
427                            .get("stop_reason")
428                            .and_then(|s| s.as_str())
429                            .map(String::from),
430                    })
431                } else if let Some(text) = message.get("content").and_then(|c| c.as_str()) {
432                    // Simple text content
433                    MessageContent::Text(text.to_string())
434                } else {
435                    continue;
436                };
437
438                transcript.messages.push(TranscriptMessage {
439                    timestamp,
440                    uuid,
441                    parent_uuid,
442                    session_id,
443                    role: msg_type.to_string(),
444                    content,
445                });
446            }
447        }
448    }
449
450    Ok(transcript)
451}
452
453/// Find transcript files that overlap with a time range
454pub fn find_transcripts_in_range(
455    project_dir: &Path,
456    start: DateTime<Utc>,
457    end: DateTime<Utc>,
458) -> Result<Vec<PathBuf>> {
459    let all_files = list_session_files(project_dir)?;
460    let mut matching = Vec::new();
461
462    for file in all_files {
463        // Quick check: parse first and last lines for timestamps
464        if let Ok(transcript) = parse_transcript(&file) {
465            if let (Some(t_start), Some(t_end)) = (transcript.started_at, transcript.ended_at) {
466                // Check for overlap
467                if t_start <= end && t_end >= start {
468                    matching.push(file);
469                }
470            }
471        }
472    }
473
474    Ok(matching)
475}
476
477/// Print the full transcript with all messages and tool calls
478pub fn print_full_transcript(transcript: &Transcript) {
479    use colored::Colorize;
480
481    println!();
482    println!("{}", "Full Transcript".blue().bold());
483    println!("{}", "═".repeat(80).blue());
484    println!(
485        "Session: {} | Tokens: {} in / {} out",
486        transcript.session_id.cyan(),
487        transcript.total_input_tokens.to_string().dimmed(),
488        transcript.total_output_tokens.to_string().dimmed()
489    );
490    println!("{}", "═".repeat(80).blue());
491    println!();
492
493    for msg in &transcript.messages {
494        let role_display = match msg.role.as_str() {
495            "user" => "USER".green().bold(),
496            "assistant" => "ASSISTANT".blue().bold(),
497            _ => msg.role.yellow().bold(),
498        };
499
500        println!(
501            "[{}] {} ─────────────────────────────────",
502            msg.timestamp.format("%H:%M:%S"),
503            role_display
504        );
505        println!();
506
507        match &msg.content {
508            MessageContent::Text(text) => {
509                // Wrap long lines for readability
510                for line in text.lines() {
511                    println!("  {}", line);
512                }
513            }
514            MessageContent::Structured(structured) => {
515                if let Some(content) = &structured.content {
516                    print_structured_content(content, 2);
517                }
518            }
519        }
520
521        println!();
522    }
523}
524
525/// Print structured content with indentation
526fn print_structured_content(content: &serde_json::Value, indent: usize) {
527    use colored::Colorize;
528    let pad = " ".repeat(indent);
529
530    match content {
531        serde_json::Value::Array(arr) => {
532            for item in arr {
533                if let Some(obj) = item.as_object() {
534                    if let Some(type_val) = obj.get("type") {
535                        match type_val.as_str() {
536                            Some("text") => {
537                                if let Some(text) = obj.get("text").and_then(|t| t.as_str()) {
538                                    for line in text.lines() {
539                                        println!("{}{}", pad, line);
540                                    }
541                                }
542                            }
543                            Some("tool_use") => {
544                                let name = obj
545                                    .get("name")
546                                    .and_then(|n| n.as_str())
547                                    .unwrap_or("unknown");
548                                let id = obj
549                                    .get("id")
550                                    .and_then(|id| id.as_str())
551                                    .unwrap_or("");
552                                println!();
553                                println!(
554                                    "{}{}{}",
555                                    pad,
556                                    "▶ TOOL CALL: ".yellow().bold(),
557                                    name.cyan().bold()
558                                );
559                                println!("{}  ID: {}", pad, id.dimmed());
560                                if let Some(input) = obj.get("input") {
561                                    println!("{}  Input:", pad);
562                                    if let Ok(json) = serde_json::to_string_pretty(input) {
563                                        // Limit output size
564                                        let lines: Vec<&str> = json.lines().collect();
565                                        let max_lines = 20;
566                                        for (i, line) in lines.iter().take(max_lines).enumerate() {
567                                            println!("{}    {}", pad, line.dimmed());
568                                            if i == max_lines - 1 && lines.len() > max_lines {
569                                                println!(
570                                                    "{}    {} more lines...",
571                                                    pad,
572                                                    (lines.len() - max_lines).to_string().yellow()
573                                                );
574                                            }
575                                        }
576                                    }
577                                }
578                                println!();
579                            }
580                            Some("tool_result") => {
581                                let tool_id = obj
582                                    .get("tool_use_id")
583                                    .and_then(|id| id.as_str())
584                                    .unwrap_or("unknown");
585                                let is_error = obj
586                                    .get("is_error")
587                                    .and_then(|e| e.as_bool())
588                                    .unwrap_or(false);
589                                println!();
590                                let header = if is_error {
591                                    "◀ TOOL ERROR".red().bold()
592                                } else {
593                                    "◀ TOOL RESULT".green().bold()
594                                };
595                                println!("{}{} ({})", pad, header, tool_id.dimmed());
596                                if let Some(content) = obj.get("content") {
597                                    let text = match content {
598                                        serde_json::Value::String(s) => s.clone(),
599                                        _ => serde_json::to_string_pretty(content)
600                                            .unwrap_or_default(),
601                                    };
602                                    // Limit output size
603                                    let lines: Vec<&str> = text.lines().collect();
604                                    let max_lines = 30;
605                                    for (i, line) in lines.iter().take(max_lines).enumerate() {
606                                        let display = if line.len() > 120 {
607                                            format!("{}...", &line[..120])
608                                        } else {
609                                            line.to_string()
610                                        };
611                                        println!("{}  {}", pad, display.dimmed());
612                                        if i == max_lines - 1 && lines.len() > max_lines {
613                                            println!(
614                                                "{}  {} more lines...",
615                                                pad,
616                                                (lines.len() - max_lines).to_string().yellow()
617                                            );
618                                        }
619                                    }
620                                }
621                                println!();
622                            }
623                            _ => {}
624                        }
625                    }
626                }
627            }
628        }
629        serde_json::Value::String(text) => {
630            for line in text.lines() {
631                println!("{}{}", pad, line);
632            }
633        }
634        _ => {}
635    }
636}
637
638/// Print a transcript summary
639pub fn print_transcript_summary(transcript: &Transcript) {
640    use colored::Colorize;
641
642    println!();
643    println!("{}", "Transcript".blue().bold());
644    println!("{}", "═".repeat(60).blue());
645
646    println!(
647        "  {} {}",
648        "Session:".dimmed(),
649        transcript.session_id.cyan()
650    );
651
652    if let (Some(start), Some(end)) = (transcript.started_at, transcript.ended_at) {
653        let duration = end.signed_duration_since(start);
654        println!(
655            "  {} {}s",
656            "Duration:".dimmed(),
657            duration.num_seconds().to_string().cyan()
658        );
659    }
660
661    println!(
662        "  {} {} in / {} out",
663        "Tokens:".dimmed(),
664        transcript.total_input_tokens.to_string().cyan(),
665        transcript.total_output_tokens.to_string().cyan()
666    );
667
668    println!(
669        "  {} {}",
670        "Messages:".dimmed(),
671        transcript.messages.len().to_string().cyan()
672    );
673
674    println!(
675        "  {} {}",
676        "Tool calls:".dimmed(),
677        transcript.tool_calls.len().to_string().cyan()
678    );
679
680    // Show tool usage breakdown
681    if !transcript.tool_calls.is_empty() {
682        let mut tool_counts: HashMap<&str, usize> = HashMap::new();
683        for call in &transcript.tool_calls {
684            *tool_counts.entry(&call.name).or_insert(0) += 1;
685        }
686
687        println!();
688        println!("{}", "Tool Usage".yellow().bold());
689        println!("{}", "─".repeat(40).yellow());
690
691        let mut sorted: Vec<_> = tool_counts.into_iter().collect();
692        sorted.sort_by(|a, b| b.1.cmp(&a.1));
693
694        for (tool, count) in sorted.iter().take(10) {
695            println!("  {:30} {}", tool.dimmed(), count.to_string().cyan());
696        }
697    }
698
699    println!();
700}
701
702/// List available transcripts for a project
703pub fn list_transcripts(project_root: &Path) -> Result<()> {
704    use colored::Colorize;
705
706    let project_dir = find_claude_project_dir(project_root)
707        .ok_or_else(|| anyhow::anyhow!("No Claude Code project found for this directory"))?;
708
709    let files = list_session_files(&project_dir)?;
710
711    if files.is_empty() {
712        println!("{}", "No transcripts found.".yellow());
713        return Ok(());
714    }
715
716    println!();
717    println!("{}", "Available Transcripts".blue().bold());
718    println!("{}", "═".repeat(60).blue());
719
720    for (i, file) in files.iter().rev().enumerate().take(20) {
721        let session_id = file
722            .file_stem()
723            .and_then(|s| s.to_str())
724            .unwrap_or("unknown");
725
726        // Get file size and mod time
727        if let Ok(metadata) = fs::metadata(file) {
728            let size_kb = metadata.len() / 1024;
729            let modified = metadata
730                .modified()
731                .ok()
732                .and_then(|t| chrono::DateTime::<Utc>::from(t).format("%Y-%m-%d %H:%M").to_string().into());
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!("  {} {}", format!("[{}]", i + 1).dimmed(), session_id.cyan());
743        }
744    }
745
746    if files.len() > 20 {
747        println!("  ... and {} more", (files.len() - 20).to_string().dimmed());
748    }
749
750    println!();
751    println!(
752        "Use {} to view a transcript",
753        "scud transcript --session <id>".cyan()
754    );
755    println!();
756
757    Ok(())
758}
759
760/// View a specific transcript or the latest one
761pub fn view_transcript(project_root: &Path, session: Option<&str>, full: bool) -> Result<()> {
762    use colored::Colorize;
763
764    let project_dir = find_claude_project_dir(project_root)
765        .ok_or_else(|| anyhow::anyhow!("No Claude Code project found for this directory"))?;
766
767    let files = list_session_files(&project_dir)?;
768
769    if files.is_empty() {
770        anyhow::bail!("No transcripts found");
771    }
772
773    // Find the requested session or use the latest
774    let transcript_path = if let Some(session_id) = session {
775        files
776            .iter()
777            .find(|f| {
778                f.file_stem()
779                    .and_then(|s| s.to_str())
780                    .map(|s| s == session_id || s.contains(session_id))
781                    .unwrap_or(false)
782            })
783            .ok_or_else(|| anyhow::anyhow!("Session '{}' not found", session_id))?
784            .clone()
785    } else {
786        // Use the latest (last in sorted list)
787        files.last().cloned().ok_or_else(|| anyhow::anyhow!("No transcripts found"))?
788    };
789
790    println!(
791        "{}",
792        format!("Loading transcript: {}", transcript_path.display()).dimmed()
793    );
794
795    let transcript = parse_transcript(&transcript_path)?;
796
797    if full {
798        print_full_transcript(&transcript);
799    } else {
800        print_transcript_summary(&transcript);
801    }
802
803    Ok(())
804}
805
806/// Export transcript as JSON
807pub fn export_transcript_json(project_root: &Path, session: Option<&str>) -> Result<String> {
808    let project_dir = find_claude_project_dir(project_root)
809        .ok_or_else(|| anyhow::anyhow!("No Claude Code project found for this directory"))?;
810
811    let files = list_session_files(&project_dir)?;
812
813    if files.is_empty() {
814        anyhow::bail!("No transcripts found");
815    }
816
817    let transcript_path = if let Some(session_id) = session {
818        files
819            .iter()
820            .find(|f| {
821                f.file_stem()
822                    .and_then(|s| s.to_str())
823                    .map(|s| s == session_id || s.contains(session_id))
824                    .unwrap_or(false)
825            })
826            .ok_or_else(|| anyhow::anyhow!("Session '{}' not found", session_id))?
827            .clone()
828    } else {
829        files.last().cloned().ok_or_else(|| anyhow::anyhow!("No transcripts found"))?
830    };
831
832    let transcript = parse_transcript(&transcript_path)?;
833    serde_json::to_string_pretty(&transcript).map_err(|e| anyhow::anyhow!("JSON error: {}", e))
834}
835
836#[cfg(test)]
837mod tests {
838    use super::*;
839
840    #[test]
841    fn test_transcript_new() {
842        let transcript = Transcript::new("session-1", "project-1");
843        assert_eq!(transcript.session_id, "session-1");
844        assert!(transcript.messages.is_empty());
845    }
846
847    #[test]
848    fn test_format_content_text() {
849        let content = serde_json::json!([
850            {"type": "text", "text": "Hello world"}
851        ]);
852
853        let mut s = String::new();
854        format_content(&mut s, &content);
855        assert!(s.contains("Hello world"));
856    }
857
858    #[test]
859    fn test_format_content_tool_use() {
860        let content = serde_json::json!([
861            {
862                "type": "tool_use",
863                "name": "Read",
864                "input": {"file_path": "/test/file.txt"}
865            }
866        ]);
867
868        let mut s = String::new();
869        format_content(&mut s, &content);
870        assert!(s.contains("Tool: Read"));
871        assert!(s.contains("file_path"));
872    }
873}