use anyhow::Result;
use serde_json::{Value, json};
use super::db::{BreakdownDb, CostRow, SessionRow};
use crate::ui::{self, Tone};
pub fn breakdown_json() -> Option<Value> {
let db = BreakdownDb::open().ok()?;
breakdown_value(&db.sessions().ok()?, &db.all_sources().ok()?)
}
fn breakdown_value(sessions: &[SessionRow], sources: &[CostRow]) -> Option<Value> {
if sessions.is_empty() {
return None;
}
Some(json!({
"sessions": sessions.iter().map(session_json).collect::<Vec<_>>(),
"sources": sources.iter().map(source_json).collect::<Vec<_>>(),
}))
}
fn session_json(s: &SessionRow) -> Value {
json!({
"session_id": s.session_id,
"agent": s.agent,
"project": s.project,
"name": s.session_name,
"turns": s.turns,
"tokens": s.tokens,
"cache_hit_pct": (s.cache_hit * 100.0).round(),
"bill_usd": s.bill_usd(),
})
}
fn source_json(c: &CostRow) -> Value {
json!({
"group": c.group_label,
"label": c.label,
"mcp_server": c.mcp_server,
"tool": c.tool_name,
"usd": c.usd,
"read_usd": c.read_usd,
"write_usd": c.write_usd,
"new_usd": c.new_usd,
})
}
pub fn sessions_csv() -> Option<String> {
let db = BreakdownDb::open().ok()?;
let sessions = db.sessions().ok()?;
if sessions.is_empty() {
return None;
}
Some(sessions_csv_rows(&sessions))
}
fn sessions_csv_rows(sessions: &[SessionRow]) -> String {
let mut o = String::from("agent,project,session,turns,tokens,cache_hit_pct,bill_usd\n");
for s in sessions {
let name = s.session_name.as_deref().unwrap_or(&s.session_id);
o.push_str(&format!(
"{},{},{},{},{},{:.0},{:.4}\n",
csv_field(&s.agent),
csv_field(s.project.as_deref().unwrap_or("")),
csv_field(name),
s.turns,
s.tokens,
s.cache_hit * 100.0,
s.bill_usd(),
));
}
o
}
fn csv_field(s: &str) -> String {
if s.contains([',', '"', '\n', '\r']) {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
pub fn top_sources_report(color: bool, max: usize) -> Result<String> {
let db = BreakdownDb::open()?;
Ok(top_sources_block(color, &db.all_sources()?, max))
}
fn top_sources_block(color: bool, sources: &[CostRow], max: usize) -> String {
if sources.is_empty() {
return String::new();
}
let mut out = format!(
"\n{}\n",
ui::paint(color, Tone::Bold, "TOP SOURCES BY COST")
);
for c in sources.iter().take(max) {
let label = match (&c.mcp_server, &c.tool_name) {
(Some(srv), Some(tool)) => format!("{} · {srv} · {tool}", c.label),
(Some(srv), None) => format!("{} · {srv}", c.label),
(None, Some(tool)) => format!("{} · {tool}", c.label),
(None, None) => c.label.clone(),
};
out.push_str(&format!(
" {:<40} {}\n",
label,
ui::paint(color, Tone::Accent, &format!("${:.2}", c.usd))
));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn session(name: Option<&str>, project: Option<&str>) -> SessionRow {
SessionRow {
session_id: "abc123def456".to_string(),
agent: "claude-code".to_string(),
project: project.map(str::to_string),
session_name: name.map(str::to_string),
turns: 4,
tokens: 12_345,
cache_hit: 0.5,
bill_micros: 1_230_000,
input_before: 1000,
input_after: 600,
last_ts: "2026-06-19T00:00:00+00:00".to_string(),
}
}
fn source(label: &str, mcp: Option<&str>, tool: Option<&str>, usd: f64) -> CostRow {
CostRow {
group_label: "Static".to_string(),
label: label.to_string(),
mcp_server: mcp.map(str::to_string),
tool_name: tool.map(str::to_string),
usd,
read_usd: usd,
write_usd: 0.0,
new_usd: 0.0,
}
}
#[test]
fn breakdown_value_is_none_without_sessions() {
assert!(breakdown_value(&[], &[]).is_none());
}
#[test]
fn breakdown_value_shapes_sessions_and_sources() {
let v = breakdown_value(
&[session(Some("my work"), Some("/proj"))],
&[source(
"MCP tools",
Some("github"),
Some("create_issue"),
0.10,
)],
)
.unwrap();
assert_eq!(v["sessions"][0]["agent"], "claude-code");
assert_eq!(v["sessions"][0]["turns"], 4);
assert_eq!(v["sessions"][0]["cache_hit_pct"], 50.0);
assert_eq!(v["sources"][0]["mcp_server"], "github");
assert_eq!(v["sources"][0]["tool"], "create_issue");
}
#[test]
fn csv_quotes_fields_with_separators() {
let csv = sessions_csv_rows(&[session(Some("a\"b"), Some("/x,y"))]);
let line = csv.lines().nth(1).unwrap();
assert!(line.contains("\"/x,y\""));
assert!(line.contains("\"a\"\"b\""));
assert!(line.ends_with("1.2300"));
}
#[test]
fn csv_falls_back_to_session_id_when_unnamed() {
let csv = sessions_csv_rows(&[session(None, None)]);
assert!(csv.lines().nth(1).unwrap().contains("abc123def456"));
}
#[test]
fn top_sources_block_labels_and_truncates() {
let sources = vec![
source("MCP tools", Some("github"), Some("create_issue"), 0.50),
source("System prompt", None, None, 0.30),
source("user text", None, None, 0.10),
];
let block = top_sources_block(false, &sources, 2);
assert!(block.contains("TOP SOURCES BY COST"));
assert!(block.contains("MCP tools · github · create_issue"));
assert!(block.contains("$0.50"));
assert!(!block.contains("user text"));
}
#[test]
fn top_sources_block_empty_when_no_sources() {
assert!(top_sources_block(false, &[], 5).is_empty());
}
}