use chrono::{Datelike, TimeZone};
use clap::{Parser, Subcommand};
use serde::Deserialize;
use std::io::Read;
use std::process::Command;
mod config;
mod db;
use config::{BLUE, GRAY, GREEN, RED, RESET, YELLOW};
#[derive(Parser)]
#[command(name = "paddington", about = "Status line renderer for Claude Code")]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
Stats {
#[arg(long)]
month: Option<String>,
#[arg(long)]
compare: Option<String>,
},
Check,
History {
#[arg(long, short = 'n', default_value = "20")]
limit: u32,
#[arg(long, short)]
project: Option<String>,
},
}
#[derive(Deserialize, Default)]
pub struct Input {
pub session_id: Option<String>,
pub model: Option<Model>,
pub cwd: Option<String>,
pub workspace: Option<Workspace>,
pub worktree: Option<Worktree>,
pub pr: Option<PullRequest>,
pub session_name: Option<String>,
pub context_window: Option<ContextWindow>,
pub cost: Option<Cost>,
pub provider: Option<String>,
pub reasoning_level: Option<String>,
pub agent: Option<String>,
pub cache: Option<CacheStats>,
pub tokens: Option<TokenStats>,
#[serde(rename = "_git_branch")]
pub git_branch_override: Option<String>,
}
#[derive(Deserialize, Default)]
pub struct Model {
pub id: Option<String>,
pub display_name: Option<String>,
}
#[derive(Deserialize, Default)]
pub struct Workspace {
pub project_dir: Option<String>,
pub repo: Option<Repo>,
}
#[derive(Deserialize, Default)]
pub struct Repo {
pub owner: Option<String>,
pub name: Option<String>,
}
#[derive(Deserialize, Default)]
pub struct Worktree {
pub name: Option<String>,
}
#[derive(Deserialize, Default)]
pub struct PullRequest {
pub number: Option<serde_json::Value>,
pub review_state: Option<String>,
}
#[derive(Deserialize, Default)]
pub struct ContextWindow {
pub total_input_tokens: Option<u64>,
pub total_output_tokens: Option<u64>,
pub context_window_size: Option<u64>,
pub used_percentage: Option<f64>,
}
#[derive(Deserialize, Default)]
pub struct Cost {
pub total_cost_usd: Option<f64>,
pub total_duration_ms: Option<u64>,
pub total_lines_added: Option<u64>,
pub total_lines_removed: Option<u64>,
}
#[derive(Deserialize, Default)]
pub struct CacheStats {
pub read_tokens: Option<u64>,
pub write_tokens: Option<u64>,
pub hit_rate: Option<f64>,
}
#[derive(Deserialize, Default)]
pub struct TokenStats {
pub input: Option<u64>,
pub output: Option<u64>,
}
fn git_branch(project_dir: &str) -> Option<String> {
let try_symbolic = Command::new("git")
.args(["--no-optional-locks", "symbolic-ref", "--short", "HEAD"])
.current_dir(project_dir)
.output()
.ok()?;
if try_symbolic.status.success() {
return Some(
String::from_utf8_lossy(&try_symbolic.stdout)
.trim()
.to_string(),
);
}
let try_rev = Command::new("git")
.args(["--no-optional-locks", "rev-parse", "--short", "HEAD"])
.current_dir(project_dir)
.output()
.ok()?;
if try_rev.status.success() {
return Some(String::from_utf8_lossy(&try_rev.stdout).trim().to_string());
}
None
}
fn git_is_dirty(project_dir: &str) -> bool {
Command::new("git")
.args(["--no-optional-locks", "status", "--porcelain"])
.current_dir(project_dir)
.output()
.ok()
.map(|o| o.status.success() && !o.stdout.is_empty())
.unwrap_or(false)
}
fn format_relative_time(delta: chrono::TimeDelta) -> String {
let secs = delta.num_seconds();
if secs < 60 {
"just now".to_string()
} else if secs < 3600 {
format!("{}m ago", secs / 60)
} else {
format!("{}h ago", secs / 3600)
}
}
pub fn format_duration(ms: u64) -> String {
let total_secs = ms / 1000;
let hrs = total_secs / 3600;
let mins = (total_secs % 3600) / 60;
let secs = total_secs % 60;
if hrs > 0 {
format!("{hrs}h {mins}m")
} else if mins > 0 {
format!("{mins}m {secs}s")
} else {
format!("{secs}s")
}
}
fn main() {
match Cli::try_parse() {
Ok(cli) => match cli.command {
None => render_status_line(),
Some(Commands::Stats { month, compare }) => show_stats(month, compare),
Some(Commands::Check) => run_check(),
Some(Commands::History { limit, project }) => show_history(limit, project),
},
Err(e) if e.use_stderr() => render_status_line(),
Err(e) => e.exit(),
}
}
fn render_status_line() {
let mut raw = String::new();
std::io::stdin().read_to_string(&mut raw).unwrap();
let input: Input = serde_json::from_str(&raw).unwrap_or_default();
let project_dir = input
.workspace
.as_ref()
.and_then(|w| w.project_dir.as_deref())
.unwrap_or("");
let monthly_total: Option<f64> = (|| -> Option<f64> {
let session_id = input.session_id.as_deref()?;
let cost_data = input.cost.as_ref()?;
let conn = db::open_db().ok()?;
let record = db::SessionRecord {
session_id: session_id.to_string(),
project_dir: input.workspace.as_ref().and_then(|w| w.project_dir.clone()),
model_id: input.model.as_ref().and_then(|m| m.id.clone()),
model_name: input.model.as_ref().and_then(|m| m.display_name.clone()),
cost_usd: cost_data.total_cost_usd.unwrap_or(0.0),
duration_ms: cost_data.total_duration_ms.unwrap_or(0),
lines_added: cost_data.total_lines_added.unwrap_or(0),
lines_removed: cost_data.total_lines_removed.unwrap_or(0),
};
db::upsert_session(&conn, &record).ok()?;
let now = chrono::Local::now();
db::get_monthly_total(&conn, now.year(), now.month()).ok()
})();
let has_git =
!project_dir.is_empty() && std::path::Path::new(project_dir).join(".git").exists();
let branch = if let Some(ref b) = input.git_branch_override {
b.clone()
} else if has_git {
git_branch(project_dir).unwrap_or_default()
} else {
String::new()
};
let git_dirty = if input.git_branch_override.is_some() {
false } else {
has_git && git_is_dirty(project_dir)
};
let (cfg, config_err) = config::load_config();
let template_str = cfg
.format
.as_ref()
.and_then(|f| f.template.clone())
.unwrap_or_else(|| config::DEFAULT_TEMPLATE.to_string());
let budget_limit = config::resolve_budget_limit(&cfg);
let ctx = config::build_context(&input, monthly_total, &branch, git_dirty, budget_limit);
let output = if let Some(err) = config_err {
let error_line = config::render_error_line(&err);
let default_output =
config::render_template(config::DEFAULT_TEMPLATE, &ctx).unwrap_or_default();
format!("{error_line}\n{default_output}")
} else {
match config::render_template(&template_str, &ctx) {
Ok(rendered) => rendered,
Err(err) => {
let error_line = config::render_error_line(&err);
let default_output =
config::render_template(config::DEFAULT_TEMPLATE, &ctx).unwrap_or_default();
format!("{error_line}\n{default_output}")
}
}
};
print!("{output}");
}
fn run_check() {
let path = config::config_path();
let (cfg, config_err) = config::load_config();
let template_str = cfg
.format
.as_ref()
.and_then(|f| f.template.clone())
.unwrap_or_else(|| config::DEFAULT_TEMPLATE.to_string());
if std::path::Path::new(&path).exists() {
println!("Config: {path}");
} else {
println!("Config: none found, using default template");
}
if let Some(err) = config_err {
eprintln!("{RED}Error: {err}{RESET}");
std::process::exit(1);
}
let env = minijinja::Environment::new();
if let Err(err) = env.template_from_str(&template_str) {
eprintln!("{RED}Template syntax error: {err}{RESET}");
std::process::exit(1);
}
println!("{GREEN}Template syntax: OK{RESET}");
let ctx = config::mock_context();
match config::render_template(&template_str, &ctx) {
Ok(rendered) => {
println!("\n{GREEN}Preview:{RESET}\n");
println!("{rendered}");
}
Err(err) => {
eprintln!("{RED}Render error: {err}{RESET}");
std::process::exit(1);
}
}
}
fn show_stats(month_arg: Option<String>, compare_arg: Option<String>) {
let (year, month) = match month_arg.as_deref() {
Some(s) => parse_month_arg(s),
None => {
let now = chrono::Local::now();
(now.year(), now.month())
}
};
let conn = match db::open_db_readonly() {
Ok(c) => c,
Err(e) => {
eprintln!("Failed to open database: {e}");
std::process::exit(1);
}
};
let (total, total_dur, total_added, total_removed) =
match db::monthly_totals(&conn, year, month) {
Ok(v) => v,
Err(e) => {
eprintln!("Database error: {e}");
std::process::exit(1);
}
};
let count = match db::monthly_session_count(&conn, year, month) {
Ok(v) => v,
Err(e) => {
eprintln!("Database error: {e}");
std::process::exit(1);
}
};
let budget_limit = {
let (cfg, _) = config::load_config();
config::resolve_budget_limit(&cfg)
};
if let Some(limit) = budget_limit {
let pct = (total / limit * 100.0).round();
let remaining = (limit - total).max(0.0);
let color = config::budget_color(pct);
println!(
"{color}Budget: ${total:.2} / ${limit:.2} ({pct:.0}% used, ${remaining:.2} remaining){RESET}"
);
}
if count == 0 {
println!("No session data recorded yet.");
if let Some(ref cmp) = compare_arg {
let (cy, cm) = parse_month_arg(cmp);
show_comparison(&conn, year, month, cy, cm);
}
return;
}
let month_full = month_name_full(month);
let month_short = month_name_short(month);
let dur_str = format_duration(total_dur);
println!(
"{YELLOW}Monthly Cost: ${total:.2}{RESET} ({month_full} {year}, {count} session{}, {dur_str})",
if count == 1 { "" } else { "s" }
);
if total_added > 0 || total_removed > 0 {
println!(
" {GREEN}+{total_added}{RESET}{GRAY}/{RESET}{RED}-{total_removed}{RESET} {GRAY}lines{RESET}"
);
}
println!();
if let Ok(days) = db::daily_breakdown(&conn, year, month) {
let max_cost = days.iter().map(|(_, c, _)| *c).fold(0.0_f64, f64::max);
println!("{GREEN}By Day:{RESET}");
for (day, cost, sessions) in &days {
let bar = render_bar(*cost, max_cost, 10);
println!(
" {month_short} {day:<2} {YELLOW}${cost:.2}{RESET} {bar} {GRAY}({sessions} session{}){RESET}",
if *sessions == 1 { "" } else { "s" }
);
}
println!();
}
if let Ok(models) = db::model_breakdown(&conn, year, month) {
println!("{GREEN}By Model:{RESET}");
let name_width = models.iter().map(|(n, ..)| n.len()).max().unwrap_or(0);
for (name, cost, dur) in &models {
let pct = if total > 0.0 {
cost / total * 100.0
} else {
0.0
};
let dur_str = format_duration(*dur);
println!(
" {name:<name_width$} {YELLOW}${cost:.2}{RESET} {GRAY}({pct:.0}%) {dur_str}{RESET}"
);
}
println!();
}
if let Ok(projects) = db::project_breakdown(&conn, year, month) {
println!("{GREEN}By Project:{RESET}");
let name_width = projects.iter().map(|(n, ..)| n.len()).max().unwrap_or(0);
for (name, cost, dur, added, removed) in &projects {
let pct = if total > 0.0 {
cost / total * 100.0
} else {
0.0
};
let dur_str = format_duration(*dur);
let lines = if *added > 0 || *removed > 0 {
format!(" {GREEN}+{added}{RESET}{GRAY}/{RESET}{RED}-{removed}{RESET}")
} else {
String::new()
};
println!(
" {name:<name_width$} {YELLOW}${cost:.2}{RESET} {GRAY}({pct:.0}%) {dur_str}{RESET}{lines}"
);
}
println!();
}
if let Ok(active) = db::active_sessions(&conn, 15)
&& !active.is_empty()
{
println!("{GREEN}Active Sessions:{RESET}");
let now = chrono::Utc::now();
let proj_width = active.iter().map(|s| s.project.len()).max().unwrap_or(0);
for s in &active {
let dur = format_duration(s.duration_ms);
let ago = chrono::DateTime::parse_from_rfc3339(&s.updated_at)
.ok()
.map(|t| format_relative_time(now - t.to_utc()))
.unwrap_or_default();
println!(
" {:<proj_width$} {YELLOW}${:.2}{RESET} {GRAY}{dur} · {} · {ago}{RESET}",
s.project, s.cost_usd, s.model
);
}
}
if let Ok((all_cost, all_count, all_dur)) = db::all_time_totals(&conn)
&& all_count > count
{
let dur_str = format_duration(all_dur);
println!("\n{GRAY}All-time: ${all_cost:.2} across {all_count} sessions ({dur_str}){RESET}");
}
if let Some(ref cmp) = compare_arg {
let (cy, cm) = parse_month_arg(cmp);
show_comparison(&conn, year, month, cy, cm);
}
}
fn show_comparison(
conn: &rusqlite::Connection,
sel_year: i32,
sel_month: u32,
cmp_year: i32,
cmp_month: u32,
) {
let (cmp_cost, cmp_dur, _, _) = match db::monthly_totals(conn, cmp_year, cmp_month) {
Ok(v) => v,
Err(e) => {
eprintln!("Database error: {e}");
return;
}
};
let cmp_count = match db::monthly_session_count(conn, cmp_year, cmp_month) {
Ok(v) => v,
Err(e) => {
eprintln!("Database error: {e}");
return;
}
};
let cmp_month_name = month_name_full(cmp_month);
if cmp_count == 0 {
println!("\nNo data for {cmp_month_name} {cmp_year}.");
return;
}
let (sel_cost, sel_dur, _, _) =
db::monthly_totals(conn, sel_year, sel_month).unwrap_or((0.0, 0, 0, 0));
let sel_count = db::monthly_session_count(conn, sel_year, sel_month).unwrap_or_default();
let cost_delta = sel_cost - cmp_cost;
let cost_sign = if cost_delta >= 0.0 { "+" } else { "-" };
let cost_pct_str = if cmp_cost > 0.0 {
let pct = (cost_delta.abs() / cmp_cost * 100.0).round() as i64;
format!(", {cost_sign}{pct}%")
} else {
String::new()
};
let session_delta = sel_count as i64 - cmp_count as i64;
let session_sign = if session_delta >= 0 { "+" } else { "" };
let cmp_dur_str = format_duration(cmp_dur);
let sel_dur_str = format_duration(sel_dur);
let dur_pct_str = if cmp_dur > 0 {
let pct = ((sel_dur as f64 - cmp_dur as f64) / cmp_dur as f64 * 100.0).round() as i64;
let dur_sign = if pct >= 0 { "+" } else { "" };
format!(" ({dur_sign}{pct}%)")
} else {
String::new()
};
println!("\n{GREEN}vs {cmp_month_name} {cmp_year}:{RESET}");
println!(
" Cost: {YELLOW}${cmp_cost:.2}{RESET} -> {YELLOW}${sel_cost:.2}{RESET} ({cost_sign}${}{cost_pct_str})",
cost_delta.abs()
);
println!(" Sessions: {cmp_count} -> {sel_count} ({session_sign}{session_delta})");
println!(" Duration: {GRAY}{cmp_dur_str}{RESET} -> {GRAY}{sel_dur_str}{RESET}{dur_pct_str}");
}
fn show_history(limit: u32, project_filter: Option<String>) {
let conn = match db::open_db_readonly() {
Ok(c) => c,
Err(e) => {
eprintln!("Failed to open database: {e}");
std::process::exit(1);
}
};
let sessions = match db::list_sessions(&conn, limit, project_filter.as_deref()) {
Ok(v) => v,
Err(e) => {
eprintln!("Database error: {e}");
std::process::exit(1);
}
};
if sessions.is_empty() {
println!("No sessions found.");
return;
}
let proj_width = sessions.iter().map(|s| s.project.len()).max().unwrap_or(0);
let model_width = sessions.iter().map(|s| s.model.len()).max().unwrap_or(0);
for s in &sessions {
let date = chrono::DateTime::parse_from_rfc3339(&s.started_at)
.map(|dt| {
chrono::Local
.from_utc_datetime(&dt.naive_utc())
.format("%Y-%m-%d")
.to_string()
})
.unwrap_or_else(|_| s.started_at[..10].to_string());
let dur = format_duration(s.duration_ms);
let lines = if s.lines_added > 0 || s.lines_removed > 0 {
format!(
" {GREEN}+{}{RESET}{GRAY}/{RESET}{RED}-{}{RESET}",
s.lines_added, s.lines_removed
)
} else {
String::new()
};
println!(
" {GRAY}{date}{RESET} {BLUE}{:<proj_width$}{RESET} {:<model_width$} {YELLOW}${:.2}{RESET} {GRAY}{dur}{RESET}{lines}",
s.project, s.model, s.cost_usd
);
}
}
fn parse_month_arg(s: &str) -> (i32, u32) {
let parts: Vec<&str> = s.split('-').collect();
if parts.len() == 2
&& let (Ok(y), Ok(m)) = (parts[0].parse::<i32>(), parts[1].parse::<u32>())
&& (1..=12).contains(&m)
{
return (y, m);
}
eprintln!("Invalid month format '{s}', expected YYYY-MM");
std::process::exit(1);
}
fn month_name_short(month: u32) -> &'static str {
match month {
1 => "Jan",
2 => "Feb",
3 => "Mar",
4 => "Apr",
5 => "May",
6 => "Jun",
7 => "Jul",
8 => "Aug",
9 => "Sep",
10 => "Oct",
11 => "Nov",
12 => "Dec",
_ => "???",
}
}
fn month_name_full(month: u32) -> &'static str {
match month {
1 => "January",
2 => "February",
3 => "March",
4 => "April",
5 => "May",
6 => "June",
7 => "July",
8 => "August",
9 => "September",
10 => "October",
11 => "November",
12 => "December",
_ => "???",
}
}
fn render_bar(value: f64, max: f64, width: usize) -> String {
let filled = if max > 0.0 {
(value / max * width as f64).round() as usize
} else {
0
};
let empty = width.saturating_sub(filled);
format!(
"{GREEN}{}{GRAY}{}{RESET}",
"█".repeat(filled),
"░".repeat(empty)
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_duration() {
assert_eq!(format_duration(0), "0s");
assert_eq!(format_duration(5_000), "5s");
assert_eq!(format_duration(65_000), "1m 5s");
assert_eq!(format_duration(3_661_000), "1h 1m");
}
#[test]
fn test_show_comparison_no_data_message() {
let conn = db::open_db_in_memory().unwrap();
let count = db::monthly_session_count(&conn, 2026, 1).unwrap();
assert_eq!(count, 0);
}
#[test]
fn test_comparison_delta_math() {
let cmp_cost = 142.0_f64;
let sel_cost = 85.5_f64;
let delta = sel_cost - cmp_cost;
assert!((delta - (-56.5)).abs() < f64::EPSILON);
let pct = (delta / cmp_cost * 100.0).round() as i64;
assert_eq!(pct, -40);
}
#[test]
fn test_comparison_zero_cost_no_percentage() {
let cmp_cost = 0.0_f64;
assert!(!(cmp_cost > 0.0));
}
}