use std::{
io::{BufRead, BufReader},
path::{Path, PathBuf},
};
#[derive(Debug, Clone, PartialEq)]
pub struct UsageSnapshot {
pub cost_usd: f64,
pub context_tokens: u64,
pub model: Option<String>,
}
pub fn claude_projects_dir() -> PathBuf {
if let Ok(p) = std::env::var("NINOX_CLAUDE_PROJECTS_DIR") {
if !p.is_empty() {
return PathBuf::from(p);
}
}
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".claude")
.join("projects")
}
pub fn claude_project_slug(workspace: &str) -> String {
workspace
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect()
}
fn price_per_million(model: &str) -> (f64, f64) {
if model.contains("fable") {
(20.0, 100.0)
} else if model.contains("opus") {
(15.0, 75.0)
} else if model.contains("sonnet") {
(3.0, 15.0)
} else if model.contains("haiku") {
(0.8, 4.0)
} else {
(3.0, 15.0)
}
}
fn turn_cost_usd(model: &str, input: u64, output: u64, cache_creation: u64, cache_read: u64) -> f64 {
let (in_rate, out_rate) = price_per_million(model);
let cache_read_rate = in_rate * 0.1;
let cache_write_rate = in_rate * 1.25;
(input as f64 * in_rate
+ output as f64 * out_rate
+ cache_read as f64 * cache_read_rate
+ cache_creation as f64 * cache_write_rate)
/ 1_000_000.0
}
struct TurnUsage {
model: String,
input: u64,
output: u64,
cache_creation: u64,
cache_read: u64,
timestamp: String,
}
fn parse_turn(line: &str) -> Option<TurnUsage> {
let v: serde_json::Value = serde_json::from_str(line).ok()?;
if v.get("type").and_then(|t| t.as_str()) != Some("assistant") {
return None;
}
let usage = v.pointer("/message/usage")?;
let get_u64 = |k: &str| usage.get(k).and_then(|x| x.as_u64()).unwrap_or(0);
Some(TurnUsage {
model: v.pointer("/message/model").and_then(|m| m.as_str()).unwrap_or("").to_string(),
input: get_u64("input_tokens"),
output: get_u64("output_tokens"),
cache_creation: get_u64("cache_creation_input_tokens"),
cache_read: get_u64("cache_read_input_tokens"),
timestamp: v.get("timestamp").and_then(|t| t.as_str()).unwrap_or("").to_string(),
})
}
fn ingest_dir(dir: &Path) -> Option<UsageSnapshot> {
let entries = std::fs::read_dir(dir).ok()?;
let mut total_cost = 0.0f64;
let mut latest_ts = String::new();
let mut latest_context: u64 = 0;
let mut latest_model: Option<String> = None;
let mut found = false;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
let Ok(file) = std::fs::File::open(&path) else { continue };
for line in BufReader::new(file).lines().map_while(Result::ok) {
let Some(turn) = parse_turn(&line) else { continue };
found = true;
total_cost += turn_cost_usd(&turn.model, turn.input, turn.output, turn.cache_creation, turn.cache_read);
if turn.timestamp >= latest_ts {
latest_ts = turn.timestamp;
latest_context = turn.input + turn.cache_creation + turn.cache_read;
latest_model = Some(turn.model);
}
}
}
found.then_some(UsageSnapshot {
cost_usd: total_cost,
context_tokens: latest_context,
model: latest_model,
})
}
pub fn ingest_usage_for_workspace(workspace: &str) -> Option<UsageSnapshot> {
let dir = claude_projects_dir().join(claude_project_slug(workspace));
ingest_dir(&dir)
}
#[cfg(test)]
pub(crate) static ENV_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::tempdir;
#[test]
fn slug_replaces_non_alphanumeric_with_dash() {
assert_eq!(
claude_project_slug("/Users/ethan.brodie/Library/Application Support/ninox/orchestrator/2"),
"-Users-ethan-brodie-Library-Application-Support-ninox-orchestrator-2",
);
}
#[test]
fn slug_handles_nested_dot_worktree_dirs() {
assert_eq!(
claude_project_slug("/Users/x/proj/iac/.claude/worktrees/y"),
"-Users-x-proj-iac--claude-worktrees-y",
);
}
#[test]
fn price_ordering_matches_fleet_ranking() {
let (fable_in, fable_out) = price_per_million("claude-fable-5");
let (opus_in, opus_out) = price_per_million("claude-opus-4-8");
let (haiku_in, haiku_out) = price_per_million("claude-haiku-4-5");
assert!(fable_in > opus_in && opus_in > haiku_in);
assert!(fable_out > opus_out && opus_out > haiku_out);
}
#[test]
fn turn_cost_is_positive_and_scales_with_tokens() {
let small = turn_cost_usd("claude-opus-4-8", 100, 100, 0, 0);
let large = turn_cost_usd("claude-opus-4-8", 10_000, 10_000, 0, 0);
assert!(small > 0.0);
assert!(large > small);
}
#[test]
fn cache_read_is_cheaper_than_fresh_input() {
let fresh = turn_cost_usd("claude-sonnet-4-5", 1000, 0, 0, 0);
let cached = turn_cost_usd("claude-sonnet-4-5", 0, 0, 0, 1000);
assert!(cached < fresh);
}
fn write_jsonl(dir: &Path, name: &str, lines: &[&str]) {
let mut f = std::fs::File::create(dir.join(name)).unwrap();
for l in lines {
writeln!(f, "{l}").unwrap();
}
}
#[test]
fn ingest_dir_sums_cost_and_uses_latest_turn_for_context() {
let dir = tempdir().unwrap();
write_jsonl(dir.path(), "s1.jsonl", &[
r#"{"type":"assistant","timestamp":"2026-07-05T13:00:00.000Z","message":{"model":"claude-fable-5","usage":{"input_tokens":2,"output_tokens":100,"cache_creation_input_tokens":0,"cache_read_input_tokens":1000}}}"#,
r#"{"type":"assistant","timestamp":"2026-07-05T13:01:00.000Z","message":{"model":"claude-fable-5","usage":{"input_tokens":2,"output_tokens":200,"cache_creation_input_tokens":500,"cache_read_input_tokens":45000}}}"#,
r#"{"type":"system","subtype":"turn_duration"}"#,
"not even json",
]);
let snap = ingest_dir(dir.path()).expect("usage found");
assert!(snap.cost_usd > 0.0);
assert_eq!(snap.context_tokens, 2 + 500 + 45000);
assert_eq!(snap.model.as_deref(), Some("claude-fable-5"));
}
#[test]
fn ingest_dir_sums_across_multiple_transcript_files() {
let dir = tempdir().unwrap();
write_jsonl(dir.path(), "a.jsonl", &[
r#"{"type":"assistant","timestamp":"2026-07-05T13:00:00.000Z","message":{"model":"claude-haiku-4-5","usage":{"input_tokens":10,"output_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}"#,
]);
write_jsonl(dir.path(), "b.jsonl", &[
r#"{"type":"assistant","timestamp":"2026-07-05T14:00:00.000Z","message":{"model":"claude-haiku-4-5","usage":{"input_tokens":20,"output_tokens":20,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}"#,
]);
let one_file = ingest_dir(dir.path()).unwrap();
let single = turn_cost_usd("claude-haiku-4-5", 10, 10, 0, 0)
+ turn_cost_usd("claude-haiku-4-5", 20, 20, 0, 0);
assert!((one_file.cost_usd - single).abs() < 1e-12);
assert_eq!(one_file.context_tokens, 20);
}
#[test]
fn ingest_dir_returns_none_for_missing_directory() {
let dir = tempdir().unwrap();
assert!(ingest_dir(&dir.path().join("does-not-exist")).is_none());
}
#[test]
fn ingest_dir_returns_none_when_no_assistant_usage_lines() {
let dir = tempdir().unwrap();
write_jsonl(dir.path(), "empty.jsonl", &[r#"{"type":"system"}"#]);
assert!(ingest_dir(dir.path()).is_none());
}
#[test]
fn ingest_usage_for_workspace_resolves_via_slug_and_projects_dir_override() {
let dir = tempdir().unwrap();
let workspace = "/tmp/my-workspace";
let project_dir = dir.path().join(claude_project_slug(workspace));
std::fs::create_dir_all(&project_dir).unwrap();
write_jsonl(&project_dir, "s.jsonl", &[
r#"{"type":"assistant","timestamp":"2026-07-05T13:00:00.000Z","message":{"model":"claude-opus-4-8","usage":{"input_tokens":5,"output_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}"#,
]);
let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let prior = std::env::var("NINOX_CLAUDE_PROJECTS_DIR").ok();
std::env::set_var("NINOX_CLAUDE_PROJECTS_DIR", dir.path());
let snap = ingest_usage_for_workspace(workspace);
match prior {
Some(v) => std::env::set_var("NINOX_CLAUDE_PROJECTS_DIR", v),
None => std::env::remove_var("NINOX_CLAUDE_PROJECTS_DIR"),
}
let snap = snap.expect("usage found via projects-dir override");
assert!(snap.cost_usd > 0.0);
assert_eq!(snap.model.as_deref(), Some("claude-opus-4-8"));
}
}