use crate::data::models::ViewData;
use crate::i18n::Locale;
use crate::utils::format_tokens;
use super::LlmReportType;
fn format_duration(secs: u64) -> String {
if secs == 0 {
return "0s".into();
}
if secs < 60 {
return format!("{}s", secs);
}
if secs < 3600 {
return format!("{}m {}s", secs / 60, secs % 60);
}
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
}
pub fn build_prompt(
report_type: LlmReportType,
data: &ViewData,
year: i32,
month: u32,
locale: Locale,
) -> String {
use std::fmt::Write;
let zh = locale == Locale::Zh;
let period = match report_type {
LlmReportType::All => "All-Time".to_string(),
LlmReportType::Monthly => format!("{}-{:02}", year, month),
LlmReportType::Yearly => year.to_string(),
};
let mut p = String::new();
if zh {
writeln!(p, "你是一位风趣的数据故事家 \u{1f3ad}。请根据以下 Claude Code 使用统计数据,写一份生动有趣、充满洞察的中文 Markdown 总结报告。").unwrap();
writeln!(p, "风格参考:像 B 站/网易云音乐的「年度总结」一样,用灵动、有温度的语言,让用户感受到自己的编码旅程。").unwrap();
} else {
writeln!(p, "You are a witty data storyteller \u{1f3ad}. Based on the following Claude Code usage statistics, write a vivid, insightful, and fun English Markdown summary report.").unwrap();
writeln!(p, "Style reference: Think of it like a \"year-in-review\" experience \u{2014} use lively, warm language that makes the user feel their coding journey is meaningful.").unwrap();
}
writeln!(p).unwrap();
writeln!(p, "## {} ({})", if zh { "使用数据概览" } else { "Usage Data Overview" }, period).unwrap();
writeln!(p, "- {}: {}", if zh { "总会话数" } else { "Total Sessions" }, data.total_sessions).unwrap();
writeln!(p, "- {}: {}", if zh { "总消息数" } else { "Total Messages" }, data.total_messages).unwrap();
writeln!(p, "- {}: {}", if zh { "总 Token 消耗" } else { "Total Tokens" }, format_tokens(data.total_tokens)).unwrap();
writeln!(p, "- {}: {}", if zh { "项目数量" } else { "Projects" }, data.project_tokens.len()).unwrap();
writeln!(p, "- {}: {}", if zh { "使用模型数" } else { "Models Used" }, data.model_tokens.len()).unwrap();
writeln!(p).unwrap();
if !data.model_token_detail.is_empty() {
writeln!(p, "## {}", if zh { "模型 Token 分布" } else { "Model Token Breakdown" }).unwrap();
if zh {
writeln!(p, "| 模型 | 总计 | Input | Output | Cache Read | Cache Create | 缓存命中率 |").unwrap();
writeln!(p, "|------|------|-------|--------|------------|--------------|-----------|").unwrap();
} else {
writeln!(p, "| Model | Total | Input | Output | Cache Read | Cache Create | Cache Hit Rate |").unwrap();
writeln!(p, "|-------|-------|-------|--------|------------|--------------|----------------|").unwrap();
}
let mut models: Vec<(String, &crate::data::models::ModelTokenDetail)> =
data.model_token_detail.iter().map(|(k, v)| (k.clone(), v)).collect();
models.sort_by(|a, b| b.1.total_tokens.cmp(&a.1.total_tokens));
for (name, d) in &models {
writeln!(p, "| {} | {} | {} | {} | {} | {} | {:.1}% |",
name,
format_tokens(d.total_tokens),
format_tokens(d.input_tokens),
format_tokens(d.output_tokens),
format_tokens(d.cache_read_input_tokens),
format_tokens(d.cache_creation_input_tokens),
d.cache_hit_rate() * 100.0,
).unwrap();
}
writeln!(p).unwrap();
}
if !data.project_tokens.is_empty() {
writeln!(p, "## {}", if zh { "项目 Token 分布" } else { "Project Token Breakdown" }).unwrap();
if zh {
writeln!(p, "| 项目 | Tokens | 占比 |").unwrap();
writeln!(p, "|------|--------|------|").unwrap();
} else {
writeln!(p, "| Project | Tokens | Share |").unwrap();
writeln!(p, "|---------|--------|-------|").unwrap();
}
let mut projects: Vec<(String, u64)> = data.project_tokens.iter()
.map(|(k, v)| (k.clone(), *v)).collect();
projects.sort_by(|a, b| b.1.cmp(&a.1));
let proj_total: u64 = projects.iter().map(|(_, v)| *v).sum();
for (name, tokens) in projects.iter().take(15) {
let pct = if proj_total > 0 {
format!("{:.1}%", (*tokens as f64 / proj_total as f64) * 100.0)
} else {
"0%".into()
};
let display = if name.is_empty() { "[unknown]" } else { name.as_str() };
writeln!(p, "| {} | {} | {} |", display, format_tokens(*tokens), pct).unwrap();
}
if projects.len() > 15 {
writeln!(p, "| ... ({} more) | | |", projects.len() - 15).unwrap();
}
writeln!(p).unwrap();
}
let total_tools = data.total_web_search_requests + data.total_web_fetch_requests + data.total_tool_calls;
if total_tools > 0 {
writeln!(p, "## {}", if zh { "工具使用统计" } else { "Tool Usage Statistics" }).unwrap();
writeln!(p, "- {}: {}", if zh { "总工具调用" } else { "Total Tool Calls" }, total_tools).unwrap();
writeln!(p, "- {}: {}", if zh { "Web 搜索" } else { "Web Search" }, data.total_web_search_requests).unwrap();
writeln!(p, "- {}: {}", if zh { "Web 获取" } else { "Web Fetch" }, data.total_web_fetch_requests).unwrap();
writeln!(p, "- {}: {}", if zh { "其他工具调用" } else { "Other Tool Calls" }, data.total_tool_calls).unwrap();
if !data.tool_calls.is_empty() {
let mut tools: Vec<(String, u64)> = data.tool_calls.iter()
.map(|(k, v)| (k.clone(), *v)).collect();
tools.sort_by(|a, b| b.1.cmp(&a.1));
let top: Vec<String> = tools.iter().take(10)
.map(|(n, c)| format!("{} ({})", n, c)).collect();
writeln!(p, "- {}: {}", if zh { "Top 工具" } else { "Top Tools" }, top.join(", ")).unwrap();
}
writeln!(p).unwrap();
}
writeln!(p, "## {}", if zh { "会话模式" } else { "Session Patterns" }).unwrap();
writeln!(p, "- {}: {}", if zh { "最长会话" } else { "Longest Session" }, format_duration(data.longest_session_secs)).unwrap();
writeln!(p, "- {}: {}", if zh { "平均会话时长" } else { "Avg Session Duration" }, format_duration(data.avg_session_secs)).unwrap();
writeln!(p, "- {}: {}", if zh { "高峰时段" } else { "Peak Period" }, data.peak_period).unwrap();
writeln!(p).unwrap();
if !data.daily_activity.is_empty() {
let total_msgs: u64 = data.daily_activity.iter().map(|d| d.message_count).sum();
let avg_daily = total_msgs as f64 / data.daily_activity.len() as f64;
let max_day = data.daily_activity.iter()
.max_by_key(|d| d.message_count + d.tool_call_count);
writeln!(p, "## {}", if zh { "每日活动" } else { "Daily Activity" }).unwrap();
writeln!(p, "- {}: {}", if zh { "活跃天数" } else { "Active Days" }, data.daily_activity.len()).unwrap();
writeln!(p, "- {}: {:.1}", if zh { "日均消息数" } else { "Avg Daily Messages" }, avg_daily).unwrap();
if let Some(peak) = max_day {
if zh {
writeln!(p, "- 最活跃日: {} (消息: {}, 工具调用: {})",
peak.date, peak.message_count, peak.tool_call_count).unwrap();
} else {
writeln!(p, "- Most Active Day: {} (messages: {}, tool calls: {})",
peak.date, peak.message_count, peak.tool_call_count).unwrap();
}
}
writeln!(p).unwrap();
}
let total_input: u64 = data.model_token_detail.values().map(|d| d.input_tokens).sum();
let total_cache_read: u64 = data.model_token_detail.values().map(|d| d.cache_read_input_tokens).sum();
let overall_cache_rate = if total_input + total_cache_read > 0 {
format!("{:.1}%", (total_cache_read as f64 / (total_input + total_cache_read) as f64) * 100.0)
} else {
"N/A".into()
};
writeln!(p, "## {}", if zh { "关键指标" } else { "Key Metrics" }).unwrap();
writeln!(p, "- {}: {}", if zh { "整体缓存命中率" } else { "Overall Cache Hit Rate" }, overall_cache_rate).unwrap();
if data.total_sessions > 0 {
writeln!(p, "- {}: {:.1}", if zh { "平均每会话消息数" } else { "Avg Messages per Session" }, data.total_messages as f64 / data.total_sessions as f64).unwrap();
}
writeln!(p).unwrap();
if zh {
writeln!(p, "请用以下风格生成报告(使用 Markdown 格式,善用 emoji 让内容更生动):").unwrap();
writeln!(p).unwrap();
writeln!(p, "## \u{1f3c6} 高光时刻").unwrap();
writeln!(p, "[用最燃的一句话开篇,像电影预告片一样概括这段编码旅程的精髓,引用最亮眼的数据]").unwrap();
writeln!(p).unwrap();
writeln!(p, "## \u{1f4ca} 数据会说话").unwrap();
writeln!(p, "[用有趣、有画面感的方式解读关键数据,让数字活起来。]").unwrap();
writeln!(p, "[比如:「你发送了 X 条消息,相当于写了一篇 Y 万字的小说」,「你的 Token 消耗能买 Z 杯咖啡 \u{2615}」]").unwrap();
writeln!(p).unwrap();
writeln!(p, "## \u{1f52e} 模型偏好").unwrap();
writeln!(p, "[像分析音乐口味一样分析模型使用,找到用户的「本命模型」和隐藏偏好]").unwrap();
writeln!(p).unwrap();
writeln!(p, "## \u{1f31f} 项目故事").unwrap();
writeln!(p, "[讲述项目之间的故事:哪个是「真爱」项目,哪个被冷落了,有没有「一夜爆肝」的传奇]").unwrap();
writeln!(p).unwrap();
writeln!(p, "## \u{26a1} 效率秘籍").unwrap();
writeln!(p, "[像游戏教练一样给出实用建议:哪些操作可以优化,哪些好习惯值得继续保持]").unwrap();
writeln!(p).unwrap();
writeln!(p, "## \u{1f4cb} 数据一览").unwrap();
writeln!(p, "[一个精致的 Markdown 表格,收录 8-10 个最值得炫耀的 KPI]").unwrap();
writeln!(p).unwrap();
writeln!(p, "要求:").unwrap();
writeln!(p, "- 全程用中文撰写,600 字以内").unwrap();
writeln!(p, "- 大量引用具体数据,让结论有说服力").unwrap();
writeln!(p, "- 多用 emoji 点缀,让报告像朋友圈一样好看").unwrap();
writeln!(p, "- 语气轻松幽默,像朋友聊天一样自然").unwrap();
writeln!(p, "- 直接输出报告正文,不要任何前言、自我介绍或\u{201c}好的\u{201d}之类的开头").unwrap();
} else {
writeln!(p, "Please generate the report in the following style (use Markdown format, with emojis to make it engaging):").unwrap();
writeln!(p).unwrap();
writeln!(p, "## \u{1f3c6} Highlight of the Journey").unwrap();
writeln!(p, "[Open with the most epic one-liner \u{2014} like a movie trailer that captures the essence of this coding journey, citing the most impressive data point]").unwrap();
writeln!(p).unwrap();
writeln!(p, "## \u{1f4ca} Let the Data Speak").unwrap();
writeln!(p, "[Interpret key data in a fun, vivid way \u{2014} bring the numbers to life.]").unwrap();
writeln!(p, "[E.g.: \"You sent X messages \u{2014} that's like writing a Y-page novel\", \"Your token usage could buy Z cups of coffee \u{2615}\"]").unwrap();
writeln!(p).unwrap();
writeln!(p, "## \u{1f52e} Model Preferences").unwrap();
writeln!(p, "[Analyze model usage like music taste \u{2014} discover the user's \"signature model\" and hidden preferences]").unwrap();
writeln!(p).unwrap();
writeln!(p, "## \u{1f31f} Project Stories").unwrap();
writeln!(p, "[Tell the story between projects: which one is the \"true love\", which got neglected, any legendary all-night coding sessions?]").unwrap();
writeln!(p).unwrap();
writeln!(p, "## \u{26a1} Efficiency Tips").unwrap();
writeln!(p, "[Like a gaming coach, give practical advice: what can be optimized, which good habits should be maintained]").unwrap();
writeln!(p).unwrap();
writeln!(p, "## \u{1f4cb} Data at a Glance").unwrap();
writeln!(p, "[A polished Markdown table showcasing the 8-10 most impressive KPIs]").unwrap();
writeln!(p).unwrap();
writeln!(p, "Requirements:").unwrap();
writeln!(p, "- Write entirely in English, under 500 words").unwrap();
writeln!(p, "- Cite specific data liberally to make conclusions persuasive").unwrap();
writeln!(p, "- Use plenty of emojis to make the report visually appealing").unwrap();
writeln!(p, "- Keep the tone light and humorous, like chatting with a friend").unwrap();
writeln!(p, "- Output the report body directly \u{2014} no preamble, self-introduction, or filler phrases").unwrap();
}
p
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::models::{DailyActivity, ModelTokenDetail};
use std::collections::HashMap;
fn build_mock_viewdata() -> ViewData {
let mut model_token_detail = HashMap::new();
let mut detail = ModelTokenDetail::default();
detail.total_tokens = 100_000;
detail.input_tokens = 60_000;
detail.output_tokens = 30_000;
detail.cache_read_input_tokens = 8_000;
detail.cache_creation_input_tokens = 2_000;
detail.web_search_requests = 5;
detail.web_fetch_requests = 3;
detail.tool_call_count = 50;
model_token_detail.insert("claude-opus".into(), detail.clone());
let mut detail2 = ModelTokenDetail::default();
detail2.total_tokens = 50_000;
detail2.input_tokens = 30_000;
detail2.output_tokens = 15_000;
detail2.cache_read_input_tokens = 4_000;
detail2.cache_creation_input_tokens = 1_000;
model_token_detail.insert("claude-sonnet".into(), detail2);
let mut model_tokens = HashMap::new();
model_tokens.insert("claude-opus".into(), 100_000u64);
model_tokens.insert("claude-sonnet".into(), 50_000u64);
let mut project_tokens = HashMap::new();
project_tokens.insert("test-project".into(), 80_000u64);
project_tokens.insert("another-project".into(), 70_000u64);
let mut tool_calls = HashMap::new();
tool_calls.insert("Bash".into(), 30u64);
tool_calls.insert("Read".into(), 20u64);
tool_calls.insert("mcp__fetch__fetch".into(), 5u64);
let daily_activity = vec![
DailyActivity { date: "2026-05-01".into(), message_count: 25, session_count: 3, tool_call_count: 10 },
DailyActivity { date: "2026-05-02".into(), message_count: 40, session_count: 5, tool_call_count: 15 },
];
ViewData {
total_sessions: 12,
total_messages: 150,
total_tokens: 150_000,
model_tokens,
model_token_detail,
project_tokens,
daily_activity,
total_web_search_requests: 10,
total_web_fetch_requests: 7,
total_tool_calls: 55,
tool_calls,
longest_session_secs: 3600,
avg_session_secs: 900,
peak_period: "Afternoon (55%)".into(),
}
}
#[test]
fn test_build_prompt_all_zh() {
let data = build_mock_viewdata();
let prompt = build_prompt(LlmReportType::All, &data, 2026, 5, Locale::Zh);
assert!(prompt.contains("All-Time"));
assert!(prompt.contains("\u{9ad8}\u{5149}\u{65f6}\u{523b}")); assert!(prompt.contains("test-project"));
assert!(prompt.contains("claude-opus"));
}
#[test]
fn test_build_prompt_all_en() {
let data = build_mock_viewdata();
let prompt = build_prompt(LlmReportType::All, &data, 2026, 5, Locale::En);
assert!(prompt.contains("All-Time"));
assert!(prompt.contains("Highlight of the Journey"));
assert!(prompt.contains("test-project"));
assert!(prompt.contains("claude-opus"));
assert!(prompt.contains("Usage Data Overview"));
}
#[test]
fn test_build_prompt_monthly() {
let data = build_mock_viewdata();
let prompt = build_prompt(LlmReportType::Monthly, &data, 2026, 5, Locale::En);
assert!(prompt.contains("2026-05"));
}
#[test]
fn test_build_prompt_yearly() {
let data = build_mock_viewdata();
let prompt = build_prompt(LlmReportType::Yearly, &data, 2026, 1, Locale::En);
assert!(prompt.contains("2026"));
}
#[test]
fn test_build_prompt_empty_data() {
let data = ViewData::default();
let prompt = build_prompt(LlmReportType::All, &data, 2026, 1, Locale::En);
assert!(prompt.contains("All-Time"));
}
#[test]
fn test_format_duration_zero() {
assert_eq!(format_duration(0), "0s");
}
#[test]
fn test_format_duration_seconds() {
assert_eq!(format_duration(30), "30s");
assert_eq!(format_duration(59), "59s");
}
#[test]
fn test_format_duration_minutes() {
assert_eq!(format_duration(60), "1m 0s");
assert_eq!(format_duration(90), "1m 30s");
assert_eq!(format_duration(125), "2m 5s");
}
#[test]
fn test_format_duration_hours() {
assert_eq!(format_duration(3600), "1h 0m");
assert_eq!(format_duration(3660), "1h 1m");
assert_eq!(format_duration(7200), "2h 0m");
assert_eq!(format_duration(7325), "2h 2m");
}
#[test]
fn test_format_duration_boundary() {
assert_eq!(format_duration(59), "59s"); assert_eq!(format_duration(60), "1m 0s"); assert_eq!(format_duration(3599), "59m 59s"); assert_eq!(format_duration(3600), "1h 0m"); }
#[test]
fn test_build_prompt_contains_tool_usage() {
let data = build_mock_viewdata();
let prompt = build_prompt(LlmReportType::All, &data, 2026, 5, Locale::En);
assert!(prompt.contains("Tool Usage Statistics"));
assert!(prompt.contains("Web Search"));
assert!(prompt.contains("Web Fetch"));
assert!(prompt.contains("Bash"));
assert!(prompt.contains("Read"));
}
#[test]
fn test_build_prompt_contains_session_patterns() {
let data = build_mock_viewdata();
let prompt = build_prompt(LlmReportType::All, &data, 2026, 5, Locale::En);
assert!(prompt.contains("Session Patterns"));
assert!(prompt.contains("Longest Session"));
assert!(prompt.contains("1h 0m")); assert!(prompt.contains("15m 0s")); assert!(prompt.contains("Afternoon (55%)"));
}
#[test]
fn test_build_prompt_contains_daily_activity() {
let data = build_mock_viewdata();
let prompt = build_prompt(LlmReportType::All, &data, 2026, 5, Locale::En);
assert!(prompt.contains("Daily Activity"));
assert!(prompt.contains("Active Days"));
assert!(prompt.contains("2")); }
#[test]
fn test_build_prompt_contains_key_metrics() {
let data = build_mock_viewdata();
let prompt = build_prompt(LlmReportType::All, &data, 2026, 5, Locale::En);
assert!(prompt.contains("Key Metrics"));
assert!(prompt.contains("Overall Cache Hit Rate"));
assert!(prompt.contains("Avg Messages per Session"));
}
#[test]
fn test_build_prompt_contains_project_breakdown() {
let data = build_mock_viewdata();
let prompt = build_prompt(LlmReportType::All, &data, 2026, 5, Locale::En);
assert!(prompt.contains("Project Token Breakdown"));
assert!(prompt.contains("test-project"));
assert!(prompt.contains("another-project"));
}
#[test]
fn test_build_prompt_zh_contains_chinese_sections() {
let data = build_mock_viewdata();
let prompt = build_prompt(LlmReportType::All, &data, 2026, 5, Locale::Zh);
assert!(prompt.contains("使用数据概览"));
assert!(prompt.contains("模型 Token 分布"));
assert!(prompt.contains("项目 Token 分布"));
assert!(prompt.contains("工具使用统计"));
assert!(prompt.contains("会话模式"));
assert!(prompt.contains("关键指标"));
}
#[test]
fn test_build_prompt_model_table() {
let data = build_mock_viewdata();
let prompt = build_prompt(LlmReportType::All, &data, 2026, 5, Locale::En);
assert!(prompt.contains("Model Token Breakdown"));
assert!(prompt.contains("claude-opus"));
assert!(prompt.contains("claude-sonnet"));
}
#[test]
fn test_build_prompt_no_tool_usage_section_when_zero() {
let mut data = ViewData::default();
data.total_web_search_requests = 0;
data.total_web_fetch_requests = 0;
data.total_tool_calls = 0;
let prompt = build_prompt(LlmReportType::All, &data, 2026, 1, Locale::En);
assert!(!prompt.contains("Tool Usage Statistics"));
}
#[test]
fn test_build_prompt_no_project_section_when_empty() {
let mut data = ViewData::default();
data.project_tokens.clear();
let prompt = build_prompt(LlmReportType::All, &data, 2026, 1, Locale::En);
assert!(!prompt.contains("Project Token Breakdown"));
}
#[test]
fn test_build_prompt_no_model_section_when_empty() {
let mut data = ViewData::default();
data.model_token_detail.clear();
let prompt = build_prompt(LlmReportType::All, &data, 2026, 1, Locale::En);
assert!(!prompt.contains("Model Token Breakdown"));
}
#[test]
fn test_build_prompt_no_daily_activity_section_when_empty() {
let mut data = ViewData::default();
data.daily_activity.clear();
let prompt = build_prompt(LlmReportType::All, &data, 2026, 1, Locale::En);
assert!(!prompt.contains("Daily Activity"));
}
}