use crate::claude::{self, AgentContent, ContentBlock, LogEntry, UserContent, UserMessage};
use crate::tool_format;
use chrono::Local;
use std::fs::{self, File};
#[cfg(target_os = "linux")]
use std::io::Write as _;
use std::io::{BufRead, BufReader};
use std::path::Path;
#[cfg(target_os = "linux")]
use std::process::{Command, Stdio};
#[derive(Clone, Copy, Debug)]
pub enum ExportFormat {
Ledger,
Plain,
Markdown,
Jsonl,
}
impl ExportFormat {
pub fn from_index(index: usize) -> Option<Self> {
match index {
0 => Some(ExportFormat::Ledger),
1 => Some(ExportFormat::Plain),
2 => Some(ExportFormat::Markdown),
3 => Some(ExportFormat::Jsonl),
_ => None,
}
}
fn extension(&self) -> &'static str {
match self {
ExportFormat::Ledger | ExportFormat::Plain => "txt",
ExportFormat::Markdown => "md",
ExportFormat::Jsonl => "jsonl",
}
}
}
pub struct ExportResult {
pub message: String,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct ExportOptions {
pub show_tools: bool,
pub show_thinking: bool,
}
pub fn export_to_file(
source_path: &Path,
format: ExportFormat,
options: ExportOptions,
) -> ExportResult {
let timestamp = Local::now().format("%Y-%m-%d-%H%M%S");
let ext = format.extension();
let filename = format!("conversation-{}.{}", timestamp, ext);
let content = match generate_content(source_path, format, options) {
Ok(c) => c,
Err(e) => {
return ExportResult {
message: format!("Failed to read: {}", e),
};
}
};
match fs::write(&filename, &content) {
Ok(_) => ExportResult {
message: format!("Exported to {}", filename),
},
Err(e) => ExportResult {
message: format!("Failed to write: {}", e),
},
}
}
pub fn copy_to_system_clipboard(text: &str) -> Result<(), String> {
#[cfg(target_os = "linux")]
{
let candidates = linux_clipboard_candidates();
for (cmd, args) in &candidates {
match copy_via_command(cmd, args, text) {
Ok(Ok(())) => return Ok(()),
Ok(Err(_)) => continue, Err(()) => continue, }
}
}
match arboard::Clipboard::new() {
Ok(mut clipboard) => clipboard
.set_text(text)
.map_err(|e| format!("Clipboard error: {}", e)),
Err(e) => Err(format!("Clipboard unavailable: {}", e)),
}
}
#[cfg(target_os = "linux")]
fn linux_clipboard_candidates() -> Vec<(&'static str, &'static [&'static str])> {
let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some();
let x11 = std::env::var_os("DISPLAY").is_some();
let mut candidates = Vec::new();
if wayland {
candidates.push(("wl-copy", ["--type", "text/plain;charset=utf-8"].as_slice()));
}
if x11 {
candidates.push(("xclip", ["-selection", "clipboard"].as_slice()));
candidates.push(("xsel", ["--clipboard", "--input"].as_slice()));
}
candidates
}
#[cfg(target_os = "linux")]
fn copy_via_command(cmd: &str, args: &[&str], text: &str) -> Result<Result<(), String>, ()> {
let mut child = Command::new(cmd)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|_| ())?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(text.as_bytes());
}
match child.wait() {
Ok(status) if status.success() => Ok(Ok(())),
Ok(status) => Ok(Err(format!("{} exited with {}", cmd, status))),
Err(e) => Ok(Err(format!("{} error: {}", cmd, e))),
}
}
pub fn export_to_clipboard(
source_path: &Path,
format: ExportFormat,
options: ExportOptions,
) -> ExportResult {
let content = match generate_content(source_path, format, options) {
Ok(c) => c,
Err(e) => {
return ExportResult {
message: format!("Failed to read: {}", e),
};
}
};
match copy_to_system_clipboard(&content) {
Ok(()) => ExportResult {
message: "Copied to clipboard".to_string(),
},
Err(e) => ExportResult { message: e },
}
}
pub fn extract_message_text(
source_path: &Path,
entry_index: usize,
options: ExportOptions,
) -> Result<String, String> {
let file = File::open(source_path).map_err(|e| format!("Failed to read: {}", e))?;
let reader = BufReader::new(file);
let mut current_index: usize = 0;
for line in reader.lines() {
let line = line.map_err(|e| format!("Failed to read: {}", e))?;
if line.trim().is_empty() {
continue;
}
let Ok(entry) = serde_json::from_str::<LogEntry>(&line) else {
continue;
};
if current_index == entry_index {
return Ok(format_entry_for_clipboard(&entry, options));
}
current_index += 1;
}
Err("Message not found".to_string())
}
fn format_entry_for_clipboard(entry: &LogEntry, options: ExportOptions) -> String {
let mut output = String::new();
match entry {
LogEntry::User {
message,
parent_tool_use_id,
..
} => {
if let Some(text) = extract_user_text(message) {
output.push_str(&text);
}
if options.show_tools
&& let UserContent::Blocks(blocks) = &message.content
{
for block in blocks {
if let ContentBlock::ToolResult { content, .. } = block {
let content_str = format_tool_result_for_export(content.as_ref());
if !output.is_empty() {
output.push_str("\n\n");
}
output.push_str(&content_str);
}
}
}
let _ = parent_tool_use_id;
}
LogEntry::Assistant {
message,
parent_tool_use_id,
..
} => {
for block in &message.content {
match block {
ContentBlock::Text { text } => {
if !output.is_empty() {
output.push_str("\n\n");
}
output.push_str(text);
}
ContentBlock::ToolUse { name, input, .. } if options.show_tools => {
if !output.is_empty() {
output.push_str("\n\n");
}
let formatted = format_tool_call_for_export(name, input);
output.push_str(&formatted);
}
ContentBlock::Thinking { thinking, .. } if options.show_thinking => {
if !output.is_empty() {
output.push_str("\n\n");
}
output.push_str(thinking);
}
_ => {}
}
}
let _ = parent_tool_use_id;
}
LogEntry::Progress { data, .. } => {
if let Some(agent_progress) = claude::parse_agent_progress(data) {
let AgentContent::Blocks(blocks) = &agent_progress.message.message.content;
for block in blocks {
match block {
ContentBlock::Text { text } => {
if !output.is_empty() {
output.push_str("\n\n");
}
output.push_str(text);
}
ContentBlock::ToolUse { name, input, .. } if options.show_tools => {
if !output.is_empty() {
output.push_str("\n\n");
}
output.push_str(&format_tool_call_for_export(name, input));
}
ContentBlock::ToolResult { content, .. } if options.show_tools => {
if !output.is_empty() {
output.push_str("\n\n");
}
output.push_str(&format_tool_result_for_export(content.as_ref()));
}
_ => {}
}
}
}
}
_ => {}
}
output
}
fn generate_content(
source_path: &Path,
format: ExportFormat,
options: ExportOptions,
) -> std::io::Result<String> {
match format {
ExportFormat::Jsonl => fs::read_to_string(source_path),
ExportFormat::Plain => generate_plain(source_path, options),
ExportFormat::Markdown => generate_markdown(source_path, options),
ExportFormat::Ledger => generate_ledger(source_path, options),
}
}
fn generate_plain(path: &Path, options: ExportOptions) -> std::io::Result<String> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut output = String::new();
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
if let Ok(entry) = serde_json::from_str::<LogEntry>(&line) {
match entry {
LogEntry::User {
message,
parent_tool_use_id,
..
} => {
if parent_tool_use_id.is_some() && !options.show_thinking {
continue;
}
let prefix = subagent_prefix(&parent_tool_use_id);
if let Some(text) = extract_user_text(&message) {
output.push_str(&format!("{}You: {}\n\n", prefix, text));
}
if options.show_tools
&& let UserContent::Blocks(blocks) = &message.content
{
for block in blocks {
if let ContentBlock::ToolResult { content, .. } = block {
let content_str = format_tool_result_for_export(content.as_ref());
output.push_str(&format!(
"{}Tool Result: {}\n\n",
prefix, content_str
));
}
}
}
}
LogEntry::Assistant {
message,
parent_tool_use_id,
..
} => {
if parent_tool_use_id.is_some() && !options.show_thinking {
continue;
}
let prefix = subagent_prefix(&parent_tool_use_id);
for block in &message.content {
match block {
ContentBlock::Text { text } => {
output.push_str(&format!("{}Claude: {}\n\n", prefix, text));
}
ContentBlock::ToolUse { name, input, .. } if options.show_tools => {
let formatted = format_tool_call_for_export(name, input);
output.push_str(&format!("{}Tool: {}\n\n", prefix, formatted));
}
ContentBlock::Thinking { thinking, .. } if options.show_thinking => {
output.push_str(&format!("{}Thinking: {}\n\n", prefix, thinking));
}
_ => {}
}
}
}
_ => {}
}
}
}
Ok(output)
}
fn generate_markdown(path: &Path, options: ExportOptions) -> std::io::Result<String> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut output = String::new();
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
if let Ok(entry) = serde_json::from_str::<LogEntry>(&line) {
match entry {
LogEntry::User {
message,
parent_tool_use_id,
..
} => {
if parent_tool_use_id.is_some() && !options.show_thinking {
continue;
}
let prefix = subagent_prefix(&parent_tool_use_id);
if let Some(text) = extract_user_text(&message) {
output.push_str(&format!("## {}You\n\n{}\n\n", prefix, text));
}
if options.show_tools
&& let UserContent::Blocks(blocks) = &message.content
{
for block in blocks {
if let ContentBlock::ToolResult { content, .. } = block {
let content_str = format_tool_result_for_export(content.as_ref());
let fenced = markdown_code_fence(&content_str);
output.push_str(&format!(
"### {}Tool Result\n\n{}\n\n",
prefix, fenced
));
}
}
}
}
LogEntry::Assistant {
message,
parent_tool_use_id,
..
} => {
if parent_tool_use_id.is_some() && !options.show_thinking {
continue;
}
let prefix = subagent_prefix(&parent_tool_use_id);
for block in &message.content {
match block {
ContentBlock::Text { text } => {
output.push_str(&format!("## {}Claude\n\n{}\n\n", prefix, text));
}
ContentBlock::ToolUse { name, input, .. } if options.show_tools => {
let formatted = format_tool_call_for_export(name, input);
let fenced = markdown_code_fence(&formatted);
output.push_str(&format!(
"### {}Tool: {}\n\n{}\n\n",
prefix, name, fenced
));
}
ContentBlock::Thinking { thinking, .. } if options.show_thinking => {
output.push_str(&format!(
"### {}Thinking\n\n{}\n\n",
prefix, thinking
));
}
_ => {}
}
}
}
_ => {}
}
}
}
Ok(output)
}
const LEDGER_WIDTH: usize = 90;
fn generate_ledger(path: &Path, options: ExportOptions) -> std::io::Result<String> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut output = String::new();
const NAME_WIDTH: usize = 9;
let content_width = LEDGER_WIDTH - NAME_WIDTH - 3;
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
if let Ok(entry) = serde_json::from_str::<LogEntry>(&line) {
match entry {
LogEntry::User {
message,
parent_tool_use_id,
..
} => {
if parent_tool_use_id.is_some() && !options.show_thinking {
continue;
}
let speaker = match &parent_tool_use_id {
Some(id) => format!("↳{}", claude::short_parent_id(id)),
None => "You".to_string(),
};
if let Some(text) = extract_user_text(&message) {
let wrapped = wrap_plain_text(&text, content_width);
append_ledger_block(&mut output, &speaker, &wrapped, NAME_WIDTH);
output.push('\n');
}
if options.show_tools
&& let UserContent::Blocks(blocks) = &message.content
{
for block in blocks {
if let ContentBlock::ToolResult { content, .. } = block {
let content_str = format_tool_result_for_export(content.as_ref());
if content_str.trim().is_empty() {
continue;
}
let wrapped = wrap_plain_text(&content_str, content_width);
append_ledger_block(&mut output, "↳ Result", &wrapped, NAME_WIDTH);
output.push('\n');
}
}
}
}
LogEntry::Assistant {
message,
parent_tool_use_id,
..
} => {
if parent_tool_use_id.is_some() && !options.show_thinking {
continue;
}
let speaker = match &parent_tool_use_id {
Some(id) => format!("↳{}", claude::short_parent_id(id)),
None => "Claude".to_string(),
};
for block in &message.content {
match block {
ContentBlock::Text { text } => {
let rendered =
crate::markdown::render_markdown_plain(text, content_width);
let rendered = rendered.trim_end();
append_ledger_block(&mut output, &speaker, rendered, NAME_WIDTH);
output.push('\n');
}
ContentBlock::ToolUse { name, input, .. } if options.show_tools => {
let formatted =
format_tool_call_for_ledger(name, input, content_width);
let tool_label = if parent_tool_use_id.is_some() {
&speaker
} else {
"Tool"
};
append_ledger_block(
&mut output,
tool_label,
&formatted,
NAME_WIDTH,
);
output.push('\n');
}
ContentBlock::Thinking { thinking, .. }
if options.show_thinking && !thinking.is_empty() =>
{
let rendered =
crate::markdown::render_markdown_plain(thinking, content_width);
let rendered = rendered.trim_end();
append_ledger_block(&mut output, "Thinking", rendered, NAME_WIDTH);
output.push('\n');
}
_ => {}
}
}
}
_ => {}
}
}
}
Ok(output)
}
fn append_ledger_block(output: &mut String, speaker: &str, text: &str, name_width: usize) {
for (i, line) in text.lines().enumerate() {
if i == 0 {
output.push_str(&format!(
"{:>width$} │ {}\n",
speaker,
line,
width = name_width
));
} else {
output.push_str(&format!("{:>width$} │ {}\n", "", line, width = name_width));
}
}
}
fn subagent_prefix(parent_tool_use_id: &Option<String>) -> String {
match parent_tool_use_id {
Some(id) => format!("[↳{}] ", claude::short_parent_id(id)),
None => String::new(),
}
}
fn extract_user_text(message: &UserMessage) -> Option<String> {
match &message.content {
UserContent::String(s) => process_command_text(s),
UserContent::Blocks(blocks) => {
for block in blocks {
if let ContentBlock::Text { text } = block
&& let Some(processed) = process_command_text(text)
{
return Some(processed);
}
}
None
}
}
}
fn process_command_text(text: &str) -> Option<String> {
let trimmed = text.trim();
if trimmed.starts_with("<local-command-stdout>") && trimmed.ends_with("</local-command-stdout>")
{
let inner = &trimmed
["<local-command-stdout>".len()..trimmed.len() - "</local-command-stdout>".len()];
if inner.trim().is_empty() {
return None;
}
return Some(inner.trim().to_string());
}
if let Some(start) = trimmed.find("<command-name>")
&& let Some(end) = trimmed.find("</command-name>")
{
let content_start = start + "<command-name>".len();
if content_start < end {
let command_name = &trimmed[content_start..end];
if let Some(args_start) = trimmed.find("<command-args>")
&& let Some(args_end) = trimmed.find("</command-args>")
{
let args_content_start = args_start + "<command-args>".len();
if args_content_start < args_end {
let args = trimmed[args_content_start..args_end].trim();
if !args.is_empty() {
return Some(format!("{} {}", command_name, args));
}
}
}
return Some(command_name.to_string());
}
}
Some(text.to_string())
}
fn markdown_code_fence(content: &str) -> String {
let max_backticks = content
.split(|c| c != '`')
.map(|s| s.len())
.max()
.unwrap_or(0);
let fence_len = std::cmp::max(3, max_backticks + 1);
let fence: String = std::iter::repeat_n('`', fence_len).collect();
format!("{}\n{}\n{}", fence, content, fence)
}
const EXPORT_WIDTH: usize = usize::MAX;
fn format_tool_call_for_export(name: &str, input: &serde_json::Value) -> String {
let formatted = tool_format::format_tool_call(name, input, EXPORT_WIDTH);
match formatted.body {
Some(body) => format!("{}\n{}", formatted.header, body),
None => formatted.header,
}
}
fn format_tool_call_for_ledger(name: &str, input: &serde_json::Value, max_width: usize) -> String {
let formatted = tool_format::format_tool_call(name, input, max_width);
let text = match formatted.body {
Some(body) => format!("{}\n{}", formatted.header, body),
None => formatted.header,
};
wrap_plain_text(&text, max_width)
}
fn wrap_plain_text(text: &str, max_width: usize) -> String {
let mut result = String::new();
for (i, line) in text.lines().enumerate() {
if i > 0 {
result.push('\n');
}
if line.is_empty() {
continue;
}
let wrapped: Vec<_> = textwrap::wrap(line, max_width)
.into_iter()
.map(|cow| cow.into_owned())
.collect();
for (j, w) in wrapped.iter().enumerate() {
if j > 0 {
result.push('\n');
}
result.push_str(w);
}
}
result
}
fn format_tool_result_for_export(content: Option<&serde_json::Value>) -> String {
match content {
Some(serde_json::Value::String(s)) => s.clone(),
Some(serde_json::Value::Array(arr)) => {
let texts: Vec<&str> = arr
.iter()
.filter_map(|item| item.get("text").and_then(|t| t.as_str()))
.collect();
if !texts.is_empty() {
texts.join("\n\n")
} else {
serde_json::to_string_pretty(&arr).unwrap_or_else(|_| "<error>".to_string())
}
}
Some(value) => {
serde_json::to_string_pretty(value).unwrap_or_else(|_| "<error>".to_string())
}
None => "<no content>".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_wrap_plain_text_preserves_short_lines() {
let result = wrap_plain_text("short line", 80);
assert_eq!(result, "short line");
}
#[test]
fn test_wrap_plain_text_wraps_long_line() {
let long = "word ".repeat(20); let result = wrap_plain_text(long.trim(), 40);
for line in result.lines() {
assert!(line.len() <= 40, "Line exceeds max_width: {:?}", line);
}
assert_eq!(result.matches("word").count(), 20);
}
#[test]
fn test_wrap_plain_text_preserves_existing_newlines() {
let text = "line one\nline two\nline three";
let result = wrap_plain_text(text, 80);
assert_eq!(result.lines().count(), 3);
}
#[test]
fn test_wrap_plain_text_preserves_empty_lines() {
let text = "line one\n\nline three";
let result = wrap_plain_text(text, 80);
assert_eq!(result, "line one\n\nline three");
}
#[test]
fn test_append_ledger_block_format() {
let mut output = String::new();
append_ledger_block(&mut output, "Claude", "Hello\nWorld", 9);
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines.len(), 2);
assert!(lines[0].starts_with(" Claude │ Hello"));
assert!(lines[1].starts_with(" │ World"));
}
#[test]
fn test_ledger_line_width() {
let name_width = 9;
let content_width = LEDGER_WIDTH - name_width - 3;
let long_text = "word ".repeat(20);
let wrapped = wrap_plain_text(long_text.trim(), content_width);
let mut output = String::new();
append_ledger_block(&mut output, "Claude", &wrapped, name_width);
for line in output.lines() {
let width = line.chars().count();
assert!(
width <= LEDGER_WIDTH,
"Ledger line exceeds {} chars (got {}): {:?}",
LEDGER_WIDTH,
width,
line
);
}
}
#[test]
fn test_ledger_markdown_rendering() {
let content_width = LEDGER_WIDTH - 9 - 3;
let rendered =
crate::markdown::render_markdown_plain("This has **bold** and `code`", content_width);
assert!(
!rendered.contains("**"),
"Should strip bold markers: {:?}",
rendered
);
assert!(
rendered.contains("`code`"),
"Should keep inline code backticks: {:?}",
rendered
);
assert!(
!rendered.contains("\x1b"),
"Should not contain ANSI codes: {:?}",
rendered
);
}
#[test]
fn test_generate_ledger_wraps_and_renders() {
let long_text = "This is a **really long** sentence that should definitely wrap because it contains many words and exceeds the content width of the ledger format which is 68 characters.";
let entry = serde_json::json!({
"type": "assistant",
"message": {
"id": "test",
"type": "message",
"role": "assistant",
"content": [{"type": "text", "text": long_text}],
"model": "test",
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": {"input_tokens": 0, "output_tokens": 0}
},
"timestamp": "2024-01-01T00:00:00Z"
});
let tmpdir = std::env::temp_dir();
let tmppath = tmpdir.join("claude-history-test-ledger.jsonl");
std::fs::write(&tmppath, format!("{}\n", entry)).unwrap();
let result = generate_ledger(
&tmppath,
ExportOptions {
show_tools: false,
show_thinking: false,
},
)
.unwrap();
std::fs::remove_file(&tmppath).ok();
eprintln!("Ledger output:\n{}", result);
for line in result.lines() {
if line.is_empty() {
continue;
}
let width = line.chars().count();
assert!(
width <= LEDGER_WIDTH,
"Ledger line exceeds {} chars (got {}): {:?}",
LEDGER_WIDTH,
width,
line
);
}
assert!(result.contains("Claude"), "Should have speaker name");
assert!(!result.contains("\x1b"), "Should not contain ANSI codes");
assert!(
!result.contains("**"),
"Should not contain raw bold markers"
);
let content_lines: Vec<&str> = result.lines().filter(|l| !l.is_empty()).collect();
assert!(
content_lines.len() > 1,
"Long text should wrap to multiple lines, got: {:?}",
content_lines
);
}
}