use crate::config;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
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] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
pub fn spark(pcts: &[u32]) -> String {
pcts.iter()
.map(|p| BLOCKS[((*p).min(100) as usize * (BLOCKS.len() - 1)) / 100])
.collect()
}
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.
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 => print!("{}", report(&log)),
}
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) -> 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();
}
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 usable: Vec<&Window> = windows.iter().filter(|w| !w.thin()).collect();
let peaks: Vec<u32> = windows.iter().map(|w| w.peak).collect();
let _ = writeln!(
out,
" {} peak of each window, oldest first",
spark(&peaks)
);
match average_unused(windows) {
Some((unused, n)) => {
let _ = writeln!(
out,
" {unused}% went unused on average, across {} well-observed {}",
n,
match n {
1 => "window",
_ => "windows",
}
);
}
None => {
let _ = writeln!(
out,
" no average: none of these {} windows was observed for long enough",
windows.len()
);
}
}
if usable.len() < windows.len() {
let _ = writeln!(
out,
" {} of {} left out — cctop was not running for enough of them",
windows.len() - usable.len(),
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 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());
}
}