use crate::config;
use chrono::Datelike;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::IsTerminal;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Sample {
pub at: i64,
pub pct: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resets_at: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plan: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Log {
#[serde(default)]
pub series: HashMap<String, Vec<Sample>>,
}
const RETAIN_SECS: i64 = 365 * 24 * 60 * 60;
const MAX_SAMPLES: usize = 6000;
pub fn key(provider: &str, profile: &str, window: &str) -> String {
format!("{provider}/{profile}/{window}")
}
impl Log {
pub fn load() -> Self {
Self::load_from(&config::BURN_LOG_FILE)
}
fn load_from(path: &Path) -> Self {
std::fs::read_to_string(path)
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default()
}
pub fn save(&self) {
let _ = std::fs::create_dir_all(&*config::CACHE_DIR);
self.save_to(&config::BURN_LOG_FILE);
}
fn save_to(&self, path: &Path) {
let Ok(text) = serde_json::to_string(self) else {
return;
};
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, text).is_ok() && std::fs::rename(&tmp, path).is_err() {
let _ = std::fs::remove_file(&tmp);
}
}
pub fn record(&mut self, key: String, sample: Sample) -> bool {
let series = self.series.entry(key).or_default();
if let Some(last) = series.last()
&& last.pct == sample.pct
&& last.resets_at == sample.resets_at
{
return false;
}
series.push(sample);
prune(series);
true
}
pub fn windows(&self, key: &str) -> Vec<Window> {
self.series.get(key).map(|s| windows(s)).unwrap_or_default()
}
}
fn prune(series: &mut Vec<Sample>) {
let Some(newest) = series.iter().map(|s| s.at).max() else {
return;
};
let cutoff = newest - RETAIN_SECS;
series.retain(|s| s.at >= cutoff);
if series.len() <= MAX_SAMPLES {
return;
}
let half = series.len() / 2;
let mut thinned: Vec<Sample> = series[..half].iter().step_by(2).cloned().collect();
thinned.extend_from_slice(&series[half..]);
*series = thinned;
}
#[derive(Debug, Clone, PartialEq)]
pub struct Window {
pub ended_at: i64,
pub peak: u32,
pub samples: usize,
pub observed_secs: i64,
pub length_secs: Option<i64>,
pub plan: Option<String>,
}
impl Window {
pub fn coverage(&self) -> Option<f64> {
let length = self.length_secs.filter(|l| *l > 0)?;
Some((self.observed_secs as f64 / length as f64).clamp(0.0, 1.0))
}
pub fn unused(&self) -> u32 {
100u32.saturating_sub(self.peak)
}
pub fn thin(&self) -> bool {
self.coverage().is_none_or(|c| c < 0.66)
}
}
fn windows(series: &[Sample]) -> Vec<Window> {
let mut out = Vec::new();
let mut current: Vec<&Sample> = Vec::new();
for sample in series {
let boundary = match current.last() {
None => false,
Some(prev) => match (prev.resets_at, sample.resets_at) {
(Some(a), Some(b)) => b > a,
_ => sample.pct < prev.pct,
},
};
if boundary {
out.extend(finish(¤t));
current.clear();
}
current.push(sample);
}
out
}
fn finish(samples: &[&Sample]) -> Option<Window> {
let first = samples.first()?;
let last = samples.last()?;
let length_secs = last.resets_at.map(|reset| reset - first.at);
Some(Window {
ended_at: last.resets_at.unwrap_or(last.at),
peak: samples.iter().map(|s| s.pct).max().unwrap_or(0),
samples: samples.len(),
observed_secs: last.at - first.at,
length_secs: length_secs.filter(|l| *l > 0),
plan: last.plan.clone(),
})
}
pub fn average_unused(windows: &[Window]) -> Option<(u32, usize)> {
let usable: Vec<&Window> = windows.iter().filter(|w| !w.thin()).collect();
if usable.is_empty() {
return None;
}
let total: u32 = usable.iter().map(|w| w.unused()).sum();
Some((total / usable.len() as u32, usable.len()))
}
const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
fn block(pct: u32) -> char {
BLOCKS[(pct.min(100) as usize * (BLOCKS.len() - 1)) / 100]
}
fn sparkline(windows: &[Window], cols: usize, colour: bool) -> String {
let n = windows.len();
let cols = cols.min(n);
let mut out = String::new();
let mut dim = false;
for c in 0..cols {
let bucket = &windows[c * n / cols..(c + 1) * n / cols];
let thin = bucket.iter().all(|w| w.thin());
if colour && thin != dim {
out.push_str(if thin { "\x1b[2m" } else { "\x1b[0m" });
dim = thin;
}
out.push(block(bucket.iter().map(|w| w.peak).max().unwrap_or(0)));
}
if dim {
out.push_str("\x1b[0m");
}
out
}
fn short_date(at: i64) -> String {
let Some(d) = chrono::DateTime::from_timestamp(at, 0).map(|d| d.with_timezone(&chrono::Local))
else {
return "?".into();
};
match d.year() == chrono::Local::now().year() {
true => d.format("%b %-d").to_string(),
false => d.format("%b ’%y").to_string(),
}
}
pub fn suffix(log: &Log, provider: &str, profile: &str, window: &str) -> Option<String> {
let (unused, n) = average_unused(&log.windows(&key(provider, profile, window)))?;
(n >= 2).then(|| format!("~{unused}% of {window} unused"))
}
pub const HELP: &str = "\
cctop burn — what your subscription windows were paid for and did not use
USAGE:
cctop burn [--json]
A rate-limit window is use-it-or-lose-it: when it resets, whatever was not
spent is gone. The provider reports only how full the window is now, so cctop
writes those readings down as it sees them and reconstructs what each completed
window came to.
The chart draws one bar per completed window, oldest on the left, at the
highest percentage seen before it reset; dates under the ends mark the span.
When the windows outnumber the columns, a bar is the highest peak in its
column. Dimmed bars are windows cctop did not watch for long — a floor, not a
measurement.
It only sees them while it is running. A window whose busiest hours happened
with cctop closed has a recorded peak below its real one, so the unused share
is an upper bound rather than a measurement — every row says how much of its
window was actually observed, and thinly-observed windows are left out of the
averages.
OPTIONS:
--json Machine-readable.
-h, --help This.
";
pub fn run(argv: &[String]) -> i32 {
if argv.iter().any(|a| a == "-h" || a == "--help") {
print!("{HELP}");
return 0;
}
let json = argv.iter().any(|a| a == "--json");
if let Some(bad) = argv.iter().find(|a| *a != "--json") {
eprintln!("cctop burn: unexpected argument `{bad}`; see --help");
return 2;
}
let log = Log::load();
match json {
true => println!("{}", as_json(&log)),
false => {
let width = crossterm::terminal::size()
.map(|(w, _)| w as usize)
.unwrap_or(80);
let colour = std::io::stdout().is_terminal();
print!("{}", report(&log, width, colour));
}
}
0
}
fn series_in_order(log: &Log) -> Vec<(&String, Vec<Window>)> {
let mut keys: Vec<&String> = log.series.keys().collect();
keys.sort();
keys.into_iter()
.map(|k| (k, log.windows(k)))
.filter(|(_, w)| !w.is_empty())
.collect()
}
pub fn report(log: &Log, width: usize, colour: bool) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let series = series_in_order(log);
if series.is_empty() {
return "No completed windows recorded yet.\n\n\
cctop writes down each account's rate-limit reading while it \
runs, and\na window has to reset before there is anything to \
say about it — so this\nfills in after a few hours of Codex or \
a few days of Claude.\n"
.into();
}
let cols = width.saturating_sub(3).max(16);
out.push('\n');
for (key, windows) in &series {
let plan = windows
.iter()
.rev()
.find_map(|w| w.plan.clone())
.map(|p| format!(" ({p})"))
.unwrap_or_default();
let _ = writeln!(out, " {key}{plan}");
let drawn = cols.min(windows.len());
let _ = writeln!(out, " {}", sparkline(windows, cols, colour));
let (a, b) = (
short_date(windows.first().map(|w| w.ended_at).unwrap_or(0)),
short_date(windows.last().map(|w| w.ended_at).unwrap_or(0)),
);
let (wa, wb) = (crate::util::cells(&a), crate::util::cells(&b));
if a == b {
let _ = writeln!(out, " {a} · window peaks");
} else if drawn > wa + wb + 10 {
let pad = drawn - wa - wb;
let _ = writeln!(out, " {a}{}{b} · window peaks", " ".repeat(pad));
} else {
let _ = writeln!(out, " {a} → {b} · window peaks");
}
let usable = windows.iter().filter(|w| !w.thin()).count();
match average_unused(windows) {
Some((unused, n)) => {
let _ = writeln!(
out,
" {unused}% went unused on average, across {} well-observed {}",
n,
match n {
1 => "window",
_ => "windows",
}
);
if usable < windows.len() {
let _ = writeln!(
out,
" {} of {} left out — cctop was not running for enough of them",
windows.len() - usable,
windows.len()
);
}
}
None => {
let _ = writeln!(
out,
" no average: all {} were half-watched — cctop was not running for enough of them",
windows.len()
);
}
}
out.push('\n');
}
out.push_str(
" An upper bound, not a measurement. cctop samples only while it is\n \
running, so a window it half-watched looks quieter than it was and its\n \
unused share reads high. Percentages are also not dollars: how a\n \
provider maps tokens onto a percentage is undocumented, so a share of\n \
a plan's price derived from one would be an illustration.\n",
);
out
}
fn as_json(log: &Log) -> String {
let doc: Vec<serde_json::Value> = series_in_order(log)
.into_iter()
.map(|(key, windows)| {
let avg = average_unused(&windows);
serde_json::json!({
"key": key,
"average_unused_pct": avg.map(|(u, _)| u),
"windows_averaged": avg.map(|(_, n)| n),
"windows": windows.iter().map(|w| serde_json::json!({
"ended_at": w.ended_at,
"peak_pct": w.peak,
"unused_pct": w.unused(),
"samples": w.samples,
"observed_secs": w.observed_secs,
"length_secs": w.length_secs,
"coverage": w.coverage(),
"thinly_observed": w.thin(),
"plan": w.plan,
})).collect::<Vec<_>>(),
})
})
.collect();
serde_json::to_string_pretty(&serde_json::json!({
"series": doc,
"caveat": "Unused shares are upper bounds. cctop samples only while it \
is running, so a partly-observed window understates usage and \
overstates what was left. Percentages are not dollars.",
}))
.unwrap_or_else(|_| "{}".into())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(at: i64, pct: u32, resets_at: i64) -> Sample {
Sample {
at,
pct,
resets_at: Some(resets_at),
plan: None,
}
}
#[test]
fn a_reading_that_says_nothing_new_is_not_stored() {
let mut log = Log::default();
let k = key("claude", "default", "7d");
assert!(log.record(k.clone(), sample(100, 40, 1000)));
assert!(!log.record(k.clone(), sample(400, 40, 1000)));
assert!(log.record(k.clone(), sample(700, 41, 1000)));
assert_eq!(log.series[&k].len(), 2);
assert!(log.record(k.clone(), sample(1100, 41, 2000)));
assert_eq!(log.series[&k].len(), 3);
}
#[test]
fn a_completed_window_reports_the_highest_reading_it_saw() {
let mut log = Log::default();
let k = key("claude", "default", "7d");
for s in [
sample(0, 10, 1000),
sample(300, 55, 1000),
sample(600, 48, 1000), sample(900, 62, 1000),
sample(1000, 5, 2000),
] {
log.record(k.clone(), s);
}
let windows = log.windows(&k);
assert_eq!(windows.len(), 1, "only the completed window is reported");
assert_eq!(windows[0].peak, 62);
assert_eq!(windows[0].unused(), 38);
}
#[test]
fn the_open_window_is_not_reported_as_wasted() {
let mut log = Log::default();
let k = key("codex", "default", "5h");
log.record(k.clone(), sample(0, 5, 1000));
log.record(k.clone(), sample(300, 20, 1000));
assert!(log.windows(&k).is_empty());
}
#[test]
fn a_barely_observed_window_is_not_averaged_in() {
let long = Window {
ended_at: 10_000,
peak: 90,
samples: 20,
observed_secs: 900,
length_secs: Some(1000),
plan: None,
};
let glimpsed = Window {
ended_at: 20_000,
peak: 5,
samples: 2,
observed_secs: 50,
length_secs: Some(1000),
plan: None,
};
assert!(!long.thin());
assert!(glimpsed.thin());
assert_eq!(
average_unused(&[long.clone(), glimpsed.clone()]),
Some((10, 1))
);
assert_eq!(average_unused(&[glimpsed]), None);
}
#[test]
fn the_chart_fits_its_width_and_dims_the_half_watched() {
let solid = |peak| Window {
ended_at: 0,
peak,
samples: 20,
observed_secs: 900,
length_secs: Some(1000),
plan: None,
};
let glimpsed = |peak| Window {
ended_at: 0,
peak,
samples: 2,
observed_secs: 50,
length_secs: Some(1000),
plan: None,
};
let windows: Vec<Window> = [solid(10), solid(80), solid(30), solid(40)]
.into_iter()
.chain((0..6).map(|_| glimpsed(60)))
.collect();
assert_eq!(sparkline(&windows, 5, false), "▆▃▅▅▅");
assert_eq!(sparkline(&windows, 5, true), "▆▃\u{1b}[2m▅▅▅\u{1b}[0m");
assert_eq!(sparkline(&windows[..2], 40, false), "▁▆");
}
#[test]
fn a_window_of_unknown_length_is_treated_as_thin() {
let w = Window {
ended_at: 0,
peak: 50,
samples: 5,
observed_secs: 100,
length_secs: None,
plan: None,
};
assert_eq!(w.coverage(), None);
assert!(w.thin());
}
}