use std::fs;
use std::path::Path;
mod render;
pub use render::render;
const STEPS_DIR: &str = "steps";
const RESPONSE_FILE: &str = "response.json";
const STEP_SEQ_WIDTH: usize = 3;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct BudgetSpend {
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_read_tokens: u64,
pub cache_write_tokens: u64,
}
impl BudgetSpend {
pub fn total_tokens(&self) -> u64 {
self.input_tokens + self.output_tokens + self.cache_read_tokens + self.cache_write_tokens
}
fn add(&mut self, other: BudgetSpend) {
self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
self.cache_read_tokens = self
.cache_read_tokens
.saturating_add(other.cache_read_tokens);
self.cache_write_tokens = self
.cache_write_tokens
.saturating_add(other.cache_write_tokens);
}
}
pub fn spend(workspace: &Path, root_id: &str) -> BudgetSpend {
let Ok(entries) = fs::read_dir(workspace.join(STEPS_DIR)) else {
return BudgetSpend::default();
};
let prefix_dash = format!("{root_id}-");
let mut total = BudgetSpend::default();
for entry in entries.flatten() {
let raw = entry.file_name();
let name = raw.to_string_lossy();
if name != root_id && !name.starts_with(&prefix_dash) {
continue;
}
total.add(conv_spend(&entry.path()));
}
total
}
fn conv_spend(conv_dir: &Path) -> BudgetSpend {
let Ok(entries) = fs::read_dir(conv_dir) else {
return BudgetSpend::default();
};
let mut total = BudgetSpend::default();
for entry in entries.flatten() {
let raw = entry.file_name();
let name = raw.to_string_lossy();
if name.len() == STEP_SEQ_WIDTH && name.bytes().all(|b| b.is_ascii_digit()) {
total.add(step_spend(&entry.path()));
}
}
total
}
fn step_spend(step_dir: &Path) -> BudgetSpend {
let Ok(bytes) = fs::read(step_dir.join(RESPONSE_FILE)) else {
return BudgetSpend::default();
};
spend_from_bytes(&bytes)
}
pub fn spend_from_bytes(bytes: &[u8]) -> BudgetSpend {
let mut total = BudgetSpend::default();
for line in bytes.split(|b| *b == b'\n') {
if let Some(spend) = usage_line(line) {
total.add(spend);
}
}
total
}
fn usage_line(line: &[u8]) -> Option<BudgetSpend> {
let value: serde_json::Value = serde_json::from_slice(line).ok()?;
if value.get("type")?.as_str()? != "usage" {
return None;
}
Some(BudgetSpend {
input_tokens: counter(&value, "input_tokens"),
output_tokens: counter(&value, "output_tokens"),
cache_read_tokens: counter(&value, "cache_read_tokens"),
cache_write_tokens: counter(&value, "cache_write_tokens"),
})
}
fn counter(value: &serde_json::Value, key: &str) -> u64 {
value
.get(key)
.and_then(serde_json::Value::as_u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use tempfile::tempdir;
fn write_step(workspace: &Path, conv: &str, seq: &str, lines: &[&str]) {
let step = workspace.join(STEPS_DIR).join(conv).join(seq);
std::fs::create_dir_all(&step).unwrap();
std::fs::write(step.join(RESPONSE_FILE), lines.join("\n")).unwrap();
}
fn steps_dir(workspace: &Path) -> PathBuf {
workspace.join(STEPS_DIR)
}
#[test]
fn folds_whole_tree_across_segments_and_descent() {
let dir = tempdir().unwrap();
let root = "20260717T120000Z-root";
let child = "20260717T120000Z-root-20260717T120100Z-kid0";
write_step(
dir.path(),
root,
"001",
&[
r#"{"type":"usage","input_tokens":10,"output_tokens":5,"cache_read_tokens":2,"cache_write_tokens":1}"#,
r#"{"type":"content_delta","index":0,"delta":{"text_delta":"hi"}}"#,
r#"{"type":"usage","input_tokens":3,"output_tokens":null}"#,
"not json",
r#"{"no_type":1}"#,
r#"{"type":123}"#,
],
);
std::fs::create_dir_all(steps_dir(dir.path()).join(root).join("002")).unwrap();
std::fs::create_dir_all(steps_dir(dir.path()).join(root).join("tools")).unwrap();
write_step(
dir.path(),
child,
"001",
&[
r#"{"type":"usage","input_tokens":100,"output_tokens":50,"cache_read_tokens":4,"cache_write_tokens":8}"#,
],
);
write_step(
dir.path(),
"20260717T120000Z-othr",
"001",
&[r#"{"type":"usage","input_tokens":999,"output_tokens":999}"#],
);
std::fs::write(steps_dir(dir.path()).join(format!("{root}-stray")), b"x").unwrap();
let s = spend(dir.path(), root);
assert_eq!(s.input_tokens, 10 + 3 + 100);
assert_eq!(s.output_tokens, 5 + 50);
assert_eq!(s.cache_read_tokens, 2 + 4);
assert_eq!(s.cache_write_tokens, 1 + 8);
assert_eq!(s.total_tokens(), 113 + 55 + 6 + 9);
}
#[test]
fn missing_steps_tree_is_zero_spend() {
let dir = tempdir().unwrap();
assert_eq!(
spend(dir.path(), "20260717T120000Z-root"),
BudgetSpend::default()
);
}
}