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, StatsCache,
};
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(())
}
fn fmt_num(n: u64) -> String {
if n >= 1_000_000_000 {
format!("{:.2}B", n as f64 / 1_000_000_000.0)
} else if n >= 1_000_000 {
format!("{:.2}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.2}K", n as f64 / 1_000.0)
} else {
n.to_string()
}
}
pub fn export_stats_to_csv(stats: &StatsCache, 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,
"Model,Input Tokens,Output Tokens,Cache Read,Cache Write,Total Tokens,Cost (USD)"
)
.context("Failed to write CSV header")?;
let mut models: Vec<_> = stats
.model_usage
.iter()
.filter(|(_, u)| u.total_tokens() > 0)
.collect();
models.sort_by(|a, b| b.1.total_tokens().cmp(&a.1.total_tokens()));
for (name, usage) in &models {
writeln!(
writer,
"\"{}\",{},{},{},{},{},\"{:.6}\"",
name,
usage.input_tokens,
usage.output_tokens,
usage.cache_read_input_tokens,
usage.cache_creation_input_tokens,
usage.total_tokens(),
usage.cost_usd
)
.with_context(|| format!("Failed to write row for model {}", name))?;
}
writer.flush().context("Failed to flush CSV writer")?;
Ok(())
}
pub fn export_stats_to_json(stats: &StatsCache, 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 json = serde_json::to_string_pretty(stats).context("Failed to serialize stats to JSON")?;
std::fs::write(path, json)
.with_context(|| format!("Failed to write JSON file: {}", path.display()))?;
Ok(())
}
pub fn export_stats_to_markdown(stats: &StatsCache, 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, "# Claude Code Statistics Report")?;
writeln!(writer)?;
if let Some(date) = &stats.last_computed_date {
writeln!(writer, "**Generated**: {}", date)?;
}
if let Some(first) = &stats.first_session_date {
writeln!(writer, "**First Session**: {}", first)?;
}
writeln!(writer)?;
writeln!(writer, "## Summary")?;
writeln!(writer)?;
writeln!(writer, "| Metric | Value |")?;
writeln!(writer, "|--------|-------|")?;
writeln!(
writer,
"| Total Tokens | {} |",
fmt_num(stats.total_tokens())
)?;
writeln!(
writer,
"| Input Tokens | {} |",
fmt_num(stats.total_input_tokens())
)?;
writeln!(
writer,
"| Output Tokens | {} |",
fmt_num(stats.total_output_tokens())
)?;
writeln!(
writer,
"| Cache Read Tokens | {} |",
fmt_num(stats.total_cache_read_tokens())
)?;
writeln!(
writer,
"| Cache Write Tokens | {} |",
fmt_num(stats.total_cache_write_tokens())
)?;
writeln!(writer, "| Sessions | {} |", stats.total_sessions)?;
writeln!(writer, "| Messages | {} |", stats.total_messages)?;
writeln!(
writer,
"| Cache Hit Ratio | {:.1}% |",
stats.cache_ratio() * 100.0
)?;
writeln!(writer)?;
if !stats.model_usage.is_empty() {
writeln!(writer, "## Model Breakdown")?;
writeln!(writer)?;
writeln!(
writer,
"| Model | Input | Output | Cache Read | Cache Write | Total | Cost |"
)?;
writeln!(
writer,
"|-------|-------|--------|------------|-------------|-------|------|"
)?;
let mut models: Vec<_> = stats
.model_usage
.iter()
.filter(|(_, u)| u.total_tokens() > 0)
.collect();
models.sort_by(|a, b| b.1.total_tokens().cmp(&a.1.total_tokens()));
for (name, usage) in &models {
writeln!(
writer,
"| {} | {} | {} | {} | {} | {} | ${:.4} |",
name,
fmt_num(usage.input_tokens),
fmt_num(usage.output_tokens),
fmt_num(usage.cache_read_input_tokens),
fmt_num(usage.cache_creation_input_tokens),
fmt_num(usage.total_tokens()),
usage.cost_usd
)
.with_context(|| format!("Failed to write row for model {}", name))?;
}
writeln!(writer)?;
}
let recent = stats.recent_daily(30);
if !recent.is_empty() {
writeln!(writer, "## Daily Activity (Last 30 Days)")?;
writeln!(writer)?;
writeln!(writer, "| Date | Sessions | Messages | Tool Calls |")?;
writeln!(writer, "|------|----------|----------|------------|")?;
for day in recent.iter().rev() {
writeln!(
writer,
"| {} | {} | {} | {} |",
day.date, day.session_count, day.message_count, day.tool_call_count
)?;
}
writeln!(writer)?;
}
writer.flush().context("Failed to flush Markdown writer")?;
Ok(())
}
pub fn export_sessions_to_markdown(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 Markdown file: {}", path.display()))?;
let mut writer = BufWriter::new(file);
writeln!(writer, "# Session List")?;
writeln!(writer)?;
writeln!(writer, "**Total**: {} sessions", sessions.len())?;
writeln!(writer)?;
writeln!(
writer,
"| Date | Time | Project | Session ID | Messages | Tokens | Duration |"
)?;
writeln!(
writer,
"|------|------|---------|------------|----------|--------|----------|"
)?;
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").to_string())
.unwrap_or_else(|| "N/A".to_string());
let duration = if let (Some(first), Some(last)) =
(&session.first_timestamp, &session.last_timestamp)
{
let diff = last.signed_duration_since(*first);
format!("{}min", diff.num_minutes())
} else {
"N/A".to_string()
};
let short_id = &session.id[..8.min(session.id.len())];
writeln!(
writer,
"| {} | {} | {} | `{}` | {} | {} | {} |",
date,
time,
session.project_path,
short_id,
session.message_count,
fmt_num(session.total_tokens),
duration
)
.with_context(|| format!("Failed to write row for session {}", session.id))?;
}
writer.flush().context("Failed to flush Markdown writer")?;
Ok(())
}
pub fn export_billing_blocks_to_json(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 mut blocks = manager.get_all_blocks();
blocks.reverse();
let json_array: Vec<_> = blocks
.iter()
.map(|(block, usage)| {
serde_json::json!({
"date": block.date.format("%Y-%m-%d").to_string(),
"block": block.label(),
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
"cache_creation_tokens": usage.cache_creation_tokens,
"cache_read_tokens": usage.cache_read_tokens,
"total_tokens": usage.total_tokens(),
"sessions": usage.session_count,
"cost_usd": usage.total_cost,
})
})
.collect();
let json = serde_json::to_string_pretty(&json_array)
.context("Failed to serialize billing blocks to JSON")?;
std::fs::write(path, json)
.with_context(|| format!("Failed to write JSON file: {}", path.display()))?;
Ok(())
}
pub fn export_billing_blocks_to_markdown(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 Markdown file: {}", path.display()))?;
let mut writer = BufWriter::new(file);
writeln!(writer, "# Billing Blocks Report")?;
writeln!(writer)?;
writeln!(writer, "| Date | Block (UTC) | Tokens | Sessions | Cost |")?;
writeln!(writer, "|------|-------------|--------|----------|------|")?;
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(),
fmt_num(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 Markdown writer")?;
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,
" .code-block {{ margin: 10px 0; border-radius: 6px; overflow: hidden; border: 1px solid #ddd; }}"
)?;
writeln!(
writer,
" .code-lang {{ background: #f0f0f0; color: #555; padding: 4px 12px; font-size: 11px; font-family: monospace; border-bottom: 1px solid #ddd; }}"
)?;
writeln!(
writer,
" .code-block pre {{ margin: 0 !important; padding: 12px !important; overflow-x: auto; border-radius: 0 !important; font-size: 13px; line-height: 1.5; }}"
)?;
writeln!(writer, " .text {{ white-space: pre-wrap; }}")?;
writeln!(writer, " </style>")?;
writeln!(writer, "</head>")?;
writeln!(writer, "<body>")?;
writeln!(writer, " <div class=\"header\">")?;
writeln!(
writer,
" <h1>Session {}</h1>",
html_escape(&metadata.id)
)?;
writeln!(writer, " <div class=\"meta\">")?;
writeln!(
writer,
" <strong>Project:</strong> {}<br>",
html_escape(&metadata.project_path)
)?;
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>",
render_content_as_html(&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('\'', "'")
}
use once_cell::sync::Lazy;
use regex::Regex;
use syntect::highlighting::ThemeSet;
use syntect::html::highlighted_html_for_string;
use syntect::parsing::SyntaxSet;
static SYNTAX_SET: Lazy<SyntaxSet> = Lazy::new(SyntaxSet::load_defaults_newlines);
static THEME_SET: Lazy<ThemeSet> = Lazy::new(ThemeSet::load_defaults);
static CODE_FENCE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?s)```(\w*)\n(.*?)```").unwrap());
pub fn render_content_as_html(content: &str) -> String {
let theme = &THEME_SET.themes["InspiredGitHub"];
let mut result = String::new();
let mut last_end = 0usize;
let mut found_any = false;
for cap in CODE_FENCE_RE.captures_iter(content) {
found_any = true;
let m = cap.get(0).unwrap();
let lang = cap.get(1).map(|l| l.as_str()).unwrap_or("").trim();
let code = cap.get(2).map(|c| c.as_str()).unwrap_or("");
let before = &content[last_end..m.start()];
if !before.is_empty() {
result.push_str(&format!(
"<span class=\"text\">{}</span>",
html_escape(before)
));
}
let syntax = if lang.is_empty() {
SYNTAX_SET.find_syntax_plain_text()
} else {
SYNTAX_SET
.find_syntax_by_token(lang)
.unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text())
};
let highlighted = highlighted_html_for_string(code, &SYNTAX_SET, syntax, theme)
.unwrap_or_else(|_| format!("<pre><code>{}</code></pre>", html_escape(code)));
let lang_label = if lang.is_empty() {
String::new()
} else {
format!("<div class=\"code-lang\">{}</div>", html_escape(lang))
};
result.push_str(&format!(
"<div class=\"code-block\">{}{}</div>",
lang_label, highlighted
));
last_end = m.end();
}
if !found_any {
return html_escape(content);
}
let remaining = &content[last_end..];
if !remaining.is_empty() {
result.push_str(&format!(
"<span class=\"text\">{}</span>",
html_escape(remaining)
));
}
result
}
#[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(),
tool_token_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());
}
#[test]
fn test_render_plain_text_no_code_block() {
let text = "Hello, this is plain text.";
let result = super::render_content_as_html(text);
assert_eq!(result, "Hello, this is plain text.");
}
#[test]
fn test_render_html_escaping_in_plain_text() {
let text = "<script>alert('XSS')</script>";
let result = super::render_content_as_html(text);
assert!(result.contains("<script>"));
assert!(!result.contains("<script>"));
}
#[test]
fn test_render_fenced_code_block_with_lang() {
let text = "Here is some code:\n```rust\nfn main() {}\n```\nDone.";
let result = super::render_content_as_html(text);
assert!(result.contains("code-block"));
assert!(result.contains("code-lang"));
assert!(result.contains("rust"));
assert!(result.contains("main"));
assert!(result.contains("Here is some code:"));
assert!(result.contains("Done."));
}
#[test]
fn test_render_fenced_code_block_no_lang() {
let text = "```\nsome plain code\n```";
let result = super::render_content_as_html(text);
assert!(result.contains("code-block"));
assert!(result.contains("some plain code"));
assert!(!result.contains("code-lang"));
}
#[test]
fn test_render_multiple_code_blocks() {
let text = "First:\n```bash\necho hello\n```\nSecond:\n```python\nprint('world')\n```";
let result = super::render_content_as_html(text);
let count = result.matches("code-block").count();
assert_eq!(count, 2, "Expected 2 code blocks, got {count}");
assert!(result.contains("echo"));
assert!(result.contains("hello"));
assert!(result.contains("print"));
assert!(result.contains("First:"));
assert!(result.contains("Second:"));
}
#[test]
fn test_render_html_export_with_code_block() {
let mut messages = create_test_messages();
messages[1].content =
"Here's an example:\n```rust\nlet x = 42;\nprintln!(\"{}\", x);\n```\nEnjoy!"
.to_string();
let metadata = create_test_session("test-html", "/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("code-block"));
assert!(contents.contains("code-lang"));
assert!(contents.contains("rust"));
assert!(contents.contains("<pre"));
}
}