use anyhow::{Context, Result};
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::Path;
use std::sync::Arc;
use crate::models::{BillingBlockManager, ConversationMessage, MessageRole, SessionMetadata};
pub fn export_billing_blocks_to_csv(manager: &BillingBlockManager, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
let file = File::create(path)
.with_context(|| format!("Failed to create CSV file: {}", path.display()))?;
let mut writer = BufWriter::new(file);
writeln!(writer, "Date,Block (UTC),Tokens,Sessions,Cost")
.context("Failed to write CSV header")?;
let mut blocks = manager.get_all_blocks();
blocks.reverse();
for (block, usage) in blocks {
writeln!(
writer,
"\"{}\",\"{}\",{},{},\"${:.3}\"",
block.date.format("%Y-%m-%d"), block.label(), usage.total_tokens(),
usage.session_count,
usage.total_cost
)
.with_context(|| format!("Failed to write row for block {:?}", block))?;
}
writer.flush().context("Failed to flush CSV writer")?;
Ok(())
}
pub fn export_sessions_to_csv(sessions: &[Arc<SessionMetadata>], path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
let file = File::create(path)
.with_context(|| format!("Failed to create CSV file: {}", path.display()))?;
let mut writer = BufWriter::new(file);
writeln!(
writer,
"Date,Time,Project,Session ID,Messages,Tokens,Models,Duration (min)"
)
.context("Failed to write CSV header")?;
for session in sessions {
let date = session
.first_timestamp
.map(|ts| ts.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| "N/A".to_string());
let time = session
.first_timestamp
.map(|ts| ts.format("%H:%M:%S").to_string())
.unwrap_or_else(|| "N/A".to_string());
let models = session.models_used.join(";");
let duration = if let (Some(first), Some(last)) =
(&session.first_timestamp, &session.last_timestamp)
{
let diff = last.signed_duration_since(*first);
diff.num_minutes()
} else {
0
};
writeln!(
writer,
"\"{}\",\"{}\",\"{}\",\"{}\",{},{},\"{}\",{}",
date,
time,
session.project_path,
session.id,
session.message_count,
session.total_tokens,
models,
duration
)
.with_context(|| format!("Failed to write row for session {}", session.id))?;
}
writer.flush().context("Failed to flush CSV writer")?;
Ok(())
}
pub fn export_sessions_to_json(sessions: &[Arc<SessionMetadata>], path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
let sessions_ref: Vec<&SessionMetadata> = sessions.iter().map(|s| s.as_ref()).collect();
let json = serde_json::to_string_pretty(&sessions_ref)
.context("Failed to serialize sessions to JSON")?;
std::fs::write(path, json)
.with_context(|| format!("Failed to write JSON file: {}", path.display()))?;
Ok(())
}
pub fn export_conversation_to_markdown(
messages: &[ConversationMessage],
metadata: &SessionMetadata,
path: &Path,
) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
let file = File::create(path)
.with_context(|| format!("Failed to create Markdown file: {}", path.display()))?;
let mut writer = BufWriter::new(file);
writeln!(writer, "# Session: {}", metadata.id)?;
writeln!(writer, "**Project**: {}", metadata.project_path)?;
if let Some(ts) = metadata.first_timestamp {
writeln!(writer, "**Date**: {}", ts.format("%Y-%m-%d %H:%M:%S"))?;
}
writeln!(
writer,
"**Messages**: {} | **Tokens**: {}\n",
metadata.message_count, metadata.total_tokens
)?;
for msg in messages {
let role = match msg.role {
MessageRole::User => "User".to_string(),
MessageRole::Assistant => {
if let Some(ref model) = msg.model {
format!("Assistant ({})", model)
} else {
"Assistant".to_string()
}
}
MessageRole::System => "System".to_string(),
};
writeln!(writer, "## {}\n", role)?;
writeln!(writer, "{}\n", msg.content)?;
if msg.role == MessageRole::Assistant {
if let Some(ref tokens) = msg.tokens {
writeln!(
writer,
"*Tokens: {} input, {} output*\n",
tokens.input_tokens, tokens.output_tokens
)?;
}
}
}
writer.flush().context("Failed to flush Markdown writer")?;
Ok(())
}
pub fn export_conversation_to_json(
messages: &[ConversationMessage],
metadata: &SessionMetadata,
path: &Path,
) -> Result<()> {
use serde_json::json;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
let export_data = json!({
"session_id": metadata.id,
"project_path": metadata.project_path,
"metadata": {
"first_timestamp": metadata.first_timestamp,
"last_timestamp": metadata.last_timestamp,
"message_count": metadata.message_count,
"total_tokens": metadata.total_tokens,
"models_used": metadata.models_used,
"duration_seconds": metadata.duration_seconds,
"branch": metadata.branch,
},
"messages": messages,
});
let json_str =
serde_json::to_string_pretty(&export_data).context("Failed to serialize conversation")?;
std::fs::write(path, json_str)
.with_context(|| format!("Failed to write JSON file: {}", path.display()))?;
Ok(())
}
pub fn export_conversation_to_html(
messages: &[ConversationMessage],
metadata: &SessionMetadata,
path: &Path,
) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
let file = File::create(path)
.with_context(|| format!("Failed to create HTML file: {}", path.display()))?;
let mut writer = BufWriter::new(file);
writeln!(writer, "<!DOCTYPE html>")?;
writeln!(writer, "<html lang=\"en\">")?;
writeln!(writer, "<head>")?;
writeln!(writer, " <meta charset=\"UTF-8\">")?;
writeln!(
writer,
" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"
)?;
writeln!(
writer,
" <title>Session {} - ccboard</title>",
metadata.id
)?;
writeln!(writer, " <style>")?;
writeln!(
writer,
" body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 900px; margin: 40px auto; padding: 20px; background: #f5f5f5; }}"
)?;
writeln!(
writer,
" .header {{ background: white; padding: 30px; border-radius: 8px; margin-bottom: 30px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}"
)?;
writeln!(
writer,
" .header h1 {{ margin: 0 0 20px 0; color: #333; font-size: 28px; }}"
)?;
writeln!(writer, " .meta {{ color: #666; line-height: 1.8; }}")?;
writeln!(
writer,
" .message {{ background: white; padding: 20px; margin-bottom: 15px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}"
)?;
writeln!(
writer,
" .role {{ font-weight: 600; margin-bottom: 10px; padding: 8px 12px; border-radius: 4px; display: inline-block; }}"
)?;
writeln!(
writer,
" .role.user {{ background: #e3f2fd; color: #1976d2; }}"
)?;
writeln!(
writer,
" .role.assistant {{ background: #f3e5f5; color: #7b1fa2; }}"
)?;
writeln!(
writer,
" .role.system {{ background: #fff3e0; color: #e65100; }}"
)?;
writeln!(
writer,
" .content {{ white-space: pre-wrap; line-height: 1.6; color: #333; }}"
)?;
writeln!(
writer,
" .tokens {{ color: #999; font-size: 12px; margin-top: 10px; }}"
)?;
writeln!(writer, " </style>")?;
writeln!(writer, "</head>")?;
writeln!(writer, "<body>")?;
writeln!(writer, " <div class=\"header\">")?;
writeln!(
writer,
" <h1>Session {}</h1>",
html_escape(&metadata.id.to_string())
)?;
writeln!(writer, " <div class=\"meta\">")?;
writeln!(
writer,
" <strong>Project:</strong> {}<br>",
html_escape(&metadata.project_path.to_string())
)?;
if let Some(ts) = metadata.first_timestamp {
writeln!(
writer,
" <strong>Date:</strong> {}<br>",
ts.format("%Y-%m-%d %H:%M:%S")
)?;
}
writeln!(
writer,
" <strong>Messages:</strong> {} | <strong>Tokens:</strong> {}",
metadata.message_count, metadata.total_tokens
)?;
writeln!(writer, " </div>")?;
writeln!(writer, " </div>")?;
for msg in messages {
let role_class = match msg.role {
MessageRole::User => "user",
MessageRole::Assistant => "assistant",
MessageRole::System => "system",
};
let role_label = match msg.role {
MessageRole::User => "User".to_string(),
MessageRole::Assistant => {
if let Some(ref model) = msg.model {
format!("Assistant ({})", model)
} else {
"Assistant".to_string()
}
}
MessageRole::System => "System".to_string(),
};
writeln!(writer, " <div class=\"message\">")?;
writeln!(
writer,
" <div class=\"role {}\">{}</div>",
role_class,
html_escape(&role_label)
)?;
writeln!(
writer,
" <div class=\"content\">{}</div>",
html_escape(&msg.content)
)?;
if msg.role == MessageRole::Assistant {
if let Some(ref tokens) = msg.tokens {
writeln!(
writer,
" <div class=\"tokens\">Tokens: {} input, {} output</div>",
tokens.input_tokens, tokens.output_tokens
)?;
}
}
writeln!(writer, " </div>")?;
}
writeln!(writer, "</body>")?;
writeln!(writer, "</html>")?;
writer.flush().context("Failed to flush HTML writer")?;
Ok(())
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::BillingBlockManager;
use chrono::{TimeZone, Utc};
use tempfile::TempDir;
#[test]
fn test_export_empty_manager() {
let manager = BillingBlockManager::new();
let temp_dir = TempDir::new().unwrap();
let csv_path = temp_dir.path().join("test.csv");
export_billing_blocks_to_csv(&manager, &csv_path).unwrap();
let contents = std::fs::read_to_string(&csv_path).unwrap();
assert_eq!(contents, "Date,Block (UTC),Tokens,Sessions,Cost\n");
}
#[test]
fn test_export_with_data() {
let mut manager = BillingBlockManager::new();
let ts1 = Utc.with_ymd_and_hms(2026, 2, 3, 14, 30, 0).unwrap();
manager.add_usage(&ts1, 5000, 1500, 200, 100, 0.015);
let ts2 = Utc.with_ymd_and_hms(2026, 2, 3, 20, 15, 0).unwrap();
manager.add_usage(&ts2, 3000, 1000, 100, 50, 0.010);
let temp_dir = TempDir::new().unwrap();
let csv_path = temp_dir.path().join("billing.csv");
export_billing_blocks_to_csv(&manager, &csv_path).unwrap();
let contents = std::fs::read_to_string(&csv_path).unwrap();
let lines: Vec<&str> = contents.lines().collect();
assert_eq!(lines.len(), 3); assert_eq!(lines[0], "Date,Block (UTC),Tokens,Sessions,Cost");
assert!(lines[1].contains("2026-02-03"));
assert!(lines[1].contains("20:00-23:59")); assert!(lines[2].contains("10:00-14:59"));
}
#[test]
fn test_creates_parent_directory() {
let manager = BillingBlockManager::new();
let temp_dir = TempDir::new().unwrap();
let nested_path = temp_dir.path().join("exports/nested/test.csv");
export_billing_blocks_to_csv(&manager, &nested_path).unwrap();
assert!(nested_path.exists());
}
#[test]
fn test_cost_formatting() {
let mut manager = BillingBlockManager::new();
let ts = Utc.with_ymd_and_hms(2026, 2, 3, 10, 0, 0).unwrap();
manager.add_usage(&ts, 1000, 500, 50, 25, 1.23456);
let temp_dir = TempDir::new().unwrap();
let csv_path = temp_dir.path().join("cost.csv");
export_billing_blocks_to_csv(&manager, &csv_path).unwrap();
let contents = std::fs::read_to_string(&csv_path).unwrap();
assert!(contents.contains("\"$1.235\""));
}
#[test]
fn test_multiple_dates_sorted() {
let mut manager = BillingBlockManager::new();
let ts1 = Utc.with_ymd_and_hms(2026, 2, 1, 10, 0, 0).unwrap();
manager.add_usage(&ts1, 1000, 500, 0, 0, 0.5);
let ts2 = Utc.with_ymd_and_hms(2026, 2, 3, 5, 0, 0).unwrap();
manager.add_usage(&ts2, 2000, 1000, 0, 0, 1.0);
let ts3 = Utc.with_ymd_and_hms(2026, 2, 2, 15, 0, 0).unwrap();
manager.add_usage(&ts3, 1500, 750, 0, 0, 0.75);
let temp_dir = TempDir::new().unwrap();
let csv_path = temp_dir.path().join("sorted.csv");
export_billing_blocks_to_csv(&manager, &csv_path).unwrap();
let contents = std::fs::read_to_string(&csv_path).unwrap();
let lines: Vec<&str> = contents.lines().collect();
assert_eq!(lines.len(), 4); assert!(lines[1].contains("2026-02-03")); assert!(lines[2].contains("2026-02-02")); assert!(lines[3].contains("2026-02-01")); }
use crate::models::SessionMetadata;
use std::path::PathBuf;
fn create_test_session(id: &str, project: &str, messages: u64, tokens: u64) -> SessionMetadata {
let timestamp = Utc.with_ymd_and_hms(2026, 2, 3, 14, 30, 0).unwrap();
SessionMetadata {
id: id.into(),
file_path: PathBuf::from(format!("/test/{}.jsonl", id)),
project_path: project.into(),
first_timestamp: Some(timestamp),
last_timestamp: Some(timestamp + chrono::Duration::minutes(45)),
message_count: messages,
total_tokens: tokens,
input_tokens: tokens / 2,
output_tokens: tokens / 3,
cache_creation_tokens: tokens / 10,
cache_read_tokens: tokens - (tokens / 2 + tokens / 3 + tokens / 10),
models_used: vec!["sonnet".to_string(), "opus".to_string()],
file_size_bytes: 1024,
first_user_message: Some("Test message".to_string()),
has_subagents: false,
duration_seconds: Some(2700), branch: None,
tool_usage: std::collections::HashMap::new(),
}
}
#[test]
fn test_export_sessions_csv_empty() {
let sessions: Vec<Arc<SessionMetadata>> = vec![];
let temp_dir = TempDir::new().unwrap();
let csv_path = temp_dir.path().join("sessions.csv");
super::export_sessions_to_csv(&sessions, &csv_path).unwrap();
let contents = std::fs::read_to_string(&csv_path).unwrap();
assert_eq!(
contents,
"Date,Time,Project,Session ID,Messages,Tokens,Models,Duration (min)\n"
);
}
#[test]
fn test_export_sessions_csv_with_data() {
let sessions = vec![
Arc::new(create_test_session(
"abc123",
"/Users/test/project1",
25,
15000,
)),
Arc::new(create_test_session(
"def456",
"/Users/test/project2",
10,
5000,
)),
];
let temp_dir = TempDir::new().unwrap();
let csv_path = temp_dir.path().join("sessions.csv");
super::export_sessions_to_csv(&sessions, &csv_path).unwrap();
let contents = std::fs::read_to_string(&csv_path).unwrap();
let lines: Vec<&str> = contents.lines().collect();
assert_eq!(lines.len(), 3); assert_eq!(
lines[0],
"Date,Time,Project,Session ID,Messages,Tokens,Models,Duration (min)"
);
assert!(lines[1].contains("2026-02-03"));
assert!(lines[1].contains("14:30:00"));
assert!(lines[1].contains("abc123"));
assert!(lines[1].contains("25"));
assert!(lines[1].contains("15000"));
assert!(lines[1].contains("sonnet;opus"));
assert!(lines[1].contains("45")); }
#[test]
fn test_export_sessions_json_empty() {
let sessions: Vec<Arc<SessionMetadata>> = vec![];
let temp_dir = TempDir::new().unwrap();
let json_path = temp_dir.path().join("sessions.json");
super::export_sessions_to_json(&sessions, &json_path).unwrap();
let contents = std::fs::read_to_string(&json_path).unwrap();
assert_eq!(contents, "[]");
}
#[test]
fn test_export_sessions_json_with_data() {
let sessions = vec![Arc::new(create_test_session(
"abc123",
"/Users/test/project1",
25,
15000,
))];
let temp_dir = TempDir::new().unwrap();
let json_path = temp_dir.path().join("sessions.json");
super::export_sessions_to_json(&sessions, &json_path).unwrap();
let contents = std::fs::read_to_string(&json_path).unwrap();
let parsed: Vec<SessionMetadata> = serde_json::from_str(&contents).unwrap();
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].id, "abc123");
assert_eq!(parsed[0].message_count, 25);
assert_eq!(parsed[0].total_tokens, 15000);
}
#[test]
fn test_export_sessions_creates_dirs() {
let sessions = vec![Arc::new(create_test_session("test", "/test", 1, 100))];
let temp_dir = TempDir::new().unwrap();
let nested_path = temp_dir.path().join("exports/nested/sessions.csv");
super::export_sessions_to_csv(&sessions, &nested_path).unwrap();
assert!(nested_path.exists());
}
use crate::models::{ConversationMessage, MessageRole, TokenUsage};
fn create_test_messages() -> Vec<ConversationMessage> {
vec![
ConversationMessage {
role: MessageRole::User,
content: "Hello, can you help me with Rust?".to_string(),
timestamp: Some(Utc.with_ymd_and_hms(2026, 2, 12, 14, 30, 0).unwrap()),
model: None,
tokens: None,
tool_calls: Vec::new(),
tool_results: Vec::new(),
},
ConversationMessage {
role: MessageRole::Assistant,
content: "Sure! I'd be happy to help. What do you need?".to_string(),
timestamp: Some(Utc.with_ymd_and_hms(2026, 2, 12, 14, 30, 30).unwrap()),
model: Some("claude-sonnet-4-5-20250929".to_string()),
tokens: Some(TokenUsage {
input_tokens: 100,
output_tokens: 50,
cache_read_tokens: 0,
cache_write_tokens: 0,
}),
tool_calls: Vec::new(),
tool_results: Vec::new(),
},
]
}
#[test]
fn test_export_conversation_markdown() {
let messages = create_test_messages();
let metadata = create_test_session("test-conv", "/test/project", 2, 150);
let temp_dir = TempDir::new().unwrap();
let md_path = temp_dir.path().join("conversation.md");
super::export_conversation_to_markdown(&messages, &metadata, &md_path).unwrap();
let contents = std::fs::read_to_string(&md_path).unwrap();
assert!(contents.contains("# Session: test-conv"));
assert!(contents.contains("**Project**: /test/project"));
assert!(contents.contains("## User"));
assert!(contents.contains("Hello, can you help me with Rust?"));
assert!(contents.contains("## Assistant (claude-sonnet-4-5-20250929)"));
assert!(contents.contains("Sure! I'd be happy to help."));
assert!(contents.contains("*Tokens: 100 input, 50 output*"));
}
#[test]
fn test_export_conversation_json() {
let messages = create_test_messages();
let metadata = create_test_session("test-conv", "/test/project", 2, 150);
let temp_dir = TempDir::new().unwrap();
let json_path = temp_dir.path().join("conversation.json");
super::export_conversation_to_json(&messages, &metadata, &json_path).unwrap();
let contents = std::fs::read_to_string(&json_path).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
assert_eq!(parsed["session_id"], "test-conv");
assert_eq!(parsed["project_path"], "/test/project");
assert_eq!(parsed["messages"].as_array().unwrap().len(), 2);
assert_eq!(parsed["messages"][0]["role"], "user");
assert_eq!(parsed["messages"][1]["role"], "assistant");
}
#[test]
fn test_export_conversation_html() {
let messages = create_test_messages();
let metadata = create_test_session("test-conv", "/test/project", 2, 150);
let temp_dir = TempDir::new().unwrap();
let html_path = temp_dir.path().join("conversation.html");
super::export_conversation_to_html(&messages, &metadata, &html_path).unwrap();
let contents = std::fs::read_to_string(&html_path).unwrap();
assert!(contents.contains("<!DOCTYPE html>"));
assert!(contents.contains("<title>Session test-conv - ccboard</title>"));
assert!(contents.contains("class=\"role user\""));
assert!(contents.contains("class=\"role assistant\""));
assert!(contents.contains("Hello, can you help me with Rust?"));
assert!(contents.contains("Sure! I'd be happy to help."));
assert!(contents.contains("Tokens: 100 input, 50 output"));
}
#[test]
fn test_html_escape() {
let input = "<script>alert('XSS')</script>";
let escaped = super::html_escape(input);
assert_eq!(
escaped,
"<script>alert('XSS')</script>"
);
}
#[test]
fn test_export_creates_nested_dirs() {
let messages = create_test_messages();
let metadata = create_test_session("test", "/test", 2, 150);
let temp_dir = TempDir::new().unwrap();
let nested_path = temp_dir.path().join("exports/conversations/test.md");
super::export_conversation_to_markdown(&messages, &metadata, &nested_path).unwrap();
assert!(nested_path.exists());
}
}