use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use serde::Deserialize;
use crate::data::models::ViewData;
use crate::i18n::Locale;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum LlmReportType {
All,
Monthly,
Yearly,
}
pub struct LlmApiConfig {
pub auth_token: String,
pub base_url: String,
pub haiku_model: String,
}
#[derive(Deserialize, Default)]
struct SettingsFile {
#[serde(default)]
env: Option<HashMap<String, String>>,
}
const DEFAULT_HAIKU: &str = "deepseek-v4-flash";
pub fn load_api_config(claude_dir: &Path) -> LlmApiConfig {
let settings_path = claude_dir.join("settings.json");
let env = fs::read_to_string(&settings_path)
.ok()
.and_then(|content| serde_json::from_str::<SettingsFile>(&content).ok())
.and_then(|s| s.env)
.unwrap_or_default();
let auth_token = env
.get("ANTHROPIC_AUTH_TOKEN")
.cloned()
.unwrap_or_default();
let base_url = env
.get("ANTHROPIC_BASE_URL")
.cloned()
.unwrap_or_else(|| "https://api.anthropic.com".to_string());
let haiku_model = env
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
.cloned()
.filter(|m| !m.is_empty())
.unwrap_or_else(|| DEFAULT_HAIKU.to_string());
LlmApiConfig {
auth_token,
base_url,
haiku_model,
}
}
pub fn compute_source_mtime(claude_dir: &Path) -> u64 {
let mut max_mtime: u64 = 0;
let mut check = |path: &Path| {
if let Ok(meta) = fs::metadata(path) {
if let Ok(modified) = meta.modified() {
if let Ok(dur) = modified.duration_since(UNIX_EPOCH) {
max_mtime = max_mtime.max(dur.as_secs());
}
}
}
};
check(&claude_dir.join("stats-cache.json"));
check(&claude_dir.join("history.jsonl"));
let projects_dir = claude_dir.join("projects");
if let Ok(entries) = fs::read_dir(&projects_dir) {
for entry in entries.flatten() {
let proj_path = entry.path();
if !proj_path.is_dir() {
continue;
}
if let Ok(sessions) = fs::read_dir(&proj_path) {
for s in sessions.flatten() {
let sp = s.path();
if sp.extension().map(|e| e == "jsonl").unwrap_or(false) {
check(&sp);
}
}
}
}
}
max_mtime
}
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
const CACHE_VERSION: u32 = 2;
#[derive(SerdeSerialize, SerdeDeserialize)]
struct CachedReport {
source_mtime: u64,
generated_at: String,
content: String,
#[serde(default)]
version: u32,
}
pub fn cache_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_default()
.join(".claude-dashboard")
}
fn cache_path(key: &str) -> PathBuf {
cache_dir().join(format!("llm-{}.json", key))
}
pub fn read_cache(key: &str) -> Option<(u64, String)> {
let path = cache_path(key);
let content = fs::read_to_string(&path).ok()?;
let report: CachedReport = serde_json::from_str(&content).ok()?;
if report.version != CACHE_VERSION {
return None; }
Some((report.source_mtime, report.content))
}
pub fn write_cache(key: &str, source_mtime: u64, content: &str) -> Result<(), String> {
let dir = cache_dir();
fs::create_dir_all(&dir).map_err(|e| format!("Cannot create cache dir: {}", e))?;
let report = CachedReport {
source_mtime,
generated_at: chrono::Local::now()
.format("%Y-%m-%dT%H:%M:%S")
.to_string(),
content: content.to_string(),
version: CACHE_VERSION,
};
let json = serde_json::to_string_pretty(&report)
.map_err(|e| format!("Cannot serialize cache: {}", e))?;
fs::write(cache_path(key), json)
.map_err(|e| format!("Cannot write cache: {}", e))?;
Ok(())
}
#[allow(dead_code)]
pub fn delete_cache(key: &str) -> Result<(), String> {
let path = cache_path(key);
if path.exists() {
fs::remove_file(&path).map_err(|e| format!("Cannot delete cache: {}", e))?;
}
Ok(())
}
use crate::utils::format_tokens;
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
}
pub fn call_haiku_api(prompt: &str, config: &LlmApiConfig, thinking_mode: bool) -> Result<(String, Option<String>), String> {
let client = reqwest::blocking::Client::new();
let mut body = serde_json::json!({
"model": config.haiku_model,
"max_tokens": if thinking_mode { 8192 } else { 1024 },
"messages": [
{"role": "user", "content": prompt}
]
});
if thinking_mode {
body["thinking"] = serde_json::json!({
"type": "enabled",
"budget_tokens": 4096
});
}
let url = format!("{}/v1/messages", config.base_url.trim_end_matches('/'));
let resp = client
.post(&url)
.header("x-api-key", &config.auth_token)
.header("anthropic-version", "2023-06-01")
.header("Content-Type", "application/json")
.json(&body)
.send()
.map_err(|e| format!("API request failed: {}", e))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().unwrap_or_default();
return Err(format!("API returned {}: {}", status, text));
}
let json: serde_json::Value = resp
.json()
.map_err(|e| format!("Failed to parse API response: {}", e))?;
let blocks = json["content"].as_array();
let text = blocks
.and_then(|bs| {
let parts: Vec<String> = bs.iter()
.filter(|b| b["type"].as_str() == Some("text"))
.filter_map(|b| b["text"].as_str())
.map(|s| s.to_string())
.collect();
if parts.is_empty() { None } else { Some(parts.join("\n")) }
})
.or_else(|| {
json["choices"][0]["message"]["content"]
.as_str()
.map(|s| s.to_string())
})
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "(empty response)".to_string());
let thinking = if thinking_mode {
let anthropic_thinking = blocks.and_then(|bs| {
let parts: Vec<String> = bs.iter()
.filter(|b| b["type"].as_str() == Some("thinking"))
.filter_map(|b| b["thinking"].as_str())
.map(|s| s.to_string())
.collect();
if parts.is_empty() { None } else { Some(parts.join("\n\n")) }
});
let openai_thinking = json["choices"][0]["message"]["reasoning_content"]
.as_str()
.map(|s| s.to_string());
let alt_thinking = json["choices"][0]["message"]["reasoning"]
.as_str()
.map(|s| s.to_string());
anthropic_thinking
.or(openai_thinking)
.or(alt_thinking)
} else {
None
};
Ok((text, thinking))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load_api_config_defaults() {
let cfg = load_api_config(Path::new("/nonexistent/path"));
assert!(!cfg.haiku_model.is_empty());
}
#[test]
fn test_cache_key_formats() {
assert!(cache_path("all").to_string_lossy().contains("llm-all"));
assert!(cache_path("monthly_2026_05").to_string_lossy().contains("monthly_2026_05"));
assert!(cache_path("yearly_2026").to_string_lossy().contains("yearly_2026"));
}
#[test]
fn test_cache_write_read_delete() {
let key = "__test_cache_roundtrip";
let _ = delete_cache(key);
assert!(read_cache(key).is_none());
write_cache(key, 12345, "hello world").unwrap();
let (mtime, content) = read_cache(key).unwrap();
assert_eq!(mtime, 12345);
assert_eq!(content, "hello world");
delete_cache(key).unwrap();
assert!(read_cache(key).is_none());
}
#[test]
fn test_cache_overwrite() {
let key = "__test_cache_overwrite";
let _ = delete_cache(key);
write_cache(key, 100, "first").unwrap();
write_cache(key, 200, "second").unwrap();
let (mtime, content) = read_cache(key).unwrap();
assert_eq!(mtime, 200);
assert_eq!(content, "second");
delete_cache(key).unwrap();
}
#[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_compute_source_mtime_no_files() {
let mtime = compute_source_mtime(Path::new("/nonexistent/path"));
assert_eq!(mtime, 0);
}
#[test]
fn test_compute_source_mtime_real() {
let home = dirs::home_dir().unwrap_or_default();
let claude_dir = home.join(".claude");
if claude_dir.exists() {
let mtime = compute_source_mtime(&claude_dir);
assert!(mtime > 0, "Real claude dir should have files with mtime > 0");
}
}
#[test]
fn test_load_api_config_from_file() {
let tmp = std::env::temp_dir().join("__llm_test_settings");
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
let settings = tmp.join("settings.json");
fs::write(&settings, r#"{
"env": {
"ANTHROPIC_AUTH_TOKEN": "test-token",
"ANTHROPIC_BASE_URL": "https://test.api.com",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "test-haiku-model"
}
}"#).unwrap();
let cfg = load_api_config(&tmp);
assert_eq!(cfg.auth_token, "test-token");
assert_eq!(cfg.base_url, "https://test.api.com");
assert_eq!(cfg.haiku_model, "test-haiku-model");
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn test_call_haiku_api_error_on_bad_url() {
let config = LlmApiConfig {
auth_token: "bad-token".into(),
base_url: "https://invalid.example.com".into(),
haiku_model: "test-model".into(),
};
let result = call_haiku_api("hello", &config, false);
assert!(result.is_err());
}
fn build_mock_viewdata() -> ViewData {
use crate::data::models::{DailyActivity, ModelTokenDetail};
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_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_llm_report_type_equality() {
assert_eq!(LlmReportType::All, LlmReportType::All);
assert_eq!(LlmReportType::Monthly, LlmReportType::Monthly);
assert_eq!(LlmReportType::Yearly, LlmReportType::Yearly);
assert_ne!(LlmReportType::All, LlmReportType::Monthly);
assert_ne!(LlmReportType::Monthly, LlmReportType::Yearly);
}
#[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"));
}
#[test]
fn test_cache_version_check() {
let key = "__test_version_check";
let _ = delete_cache(key);
let dir = cache_dir();
let _ = fs::create_dir_all(&dir);
let old_report = serde_json::json!({
"source_mtime": 99999,
"generated_at": "2026-01-01T00:00:00",
"content": "old content",
"version": 0 });
let path = cache_path(key);
let _ = fs::write(&path, serde_json::to_string_pretty(&old_report).unwrap());
assert!(read_cache(key).is_none());
let _ = delete_cache(key);
}
}