use chrono::Datelike;
use clap::{Parser, Subcommand};
use serde::Deserialize;
use std::io::Read;
use std::process::Command;
mod config;
mod db;
use config::{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>,
},
Check,
}
#[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>,
}
#[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>,
}
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 }) => show_stats(month),
Some(Commands::Check) => run_check(),
},
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::Utc::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 has_git {
git_branch(project_dir).unwrap_or_default()
} else {
String::new()
};
let git_dirty = has_git && git_is_dirty(project_dir);
let (template_str, config_err) = config::load_template();
let ctx = config::build_context(&input, monthly_total, &branch, git_dirty);
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 (template_str, config_err) = config::load_template();
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>) {
let (year, month) = match month_arg.as_deref() {
Some(s) => parse_month_arg(s),
None => {
let now = chrono::Utc::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 = match db::get_monthly_total(&conn, year, month) {
Ok(v) => v,
Err(e) => {
eprintln!("Database error (run may be corrupted): {e}");
std::process::exit(1);
}
};
let count = match db::monthly_session_count(&conn, year, month) {
Ok(v) => v,
Err(e) => {
eprintln!("Database error (run may be corrupted): {e}");
std::process::exit(1);
}
};
if count == 0 {
println!("No session data recorded yet.");
return;
}
let month_full = month_name_full(month);
let month_short = month_name_short(month);
println!(
"{YELLOW}Monthly Cost: ${total:.2}{RESET} ({month_full} {year}, {count} session{})\n",
if count == 1 { "" } else { "s" }
);
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) in &models {
let pct = if total > 0.0 {
cost / total * 100.0
} else {
0.0
};
println!(" {name:<name_width$} {YELLOW}${cost:.2}{RESET} {GRAY}({pct:.0}%){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) in &projects {
let pct = if total > 0.0 {
cost / total * 100.0
} else {
0.0
};
println!(" {name:<name_width$} {YELLOW}${cost:.2}{RESET} {GRAY}({pct:.0}%){RESET}");
}
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
);
}
}
}
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");
}
}