1use std::{
30 io::{BufRead, BufReader},
31 path::{Path, PathBuf},
32};
33
34#[derive(Debug, Clone, PartialEq)]
37pub struct UsageSnapshot {
38 pub cost_usd: f64,
42 pub context_tokens: u64,
46 pub model: Option<String>,
48}
49
50pub fn claude_projects_dir() -> PathBuf {
55 if let Ok(p) = std::env::var("NINOX_CLAUDE_PROJECTS_DIR") {
56 if !p.is_empty() {
57 return PathBuf::from(p);
58 }
59 }
60 dirs::home_dir()
61 .unwrap_or_else(|| PathBuf::from("."))
62 .join(".claude")
63 .join("projects")
64}
65
66pub fn claude_project_slug(workspace: &str) -> String {
74 workspace
75 .chars()
76 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
77 .collect()
78}
79
80fn price_per_million(model: &str) -> (f64, f64) {
83 if model.contains("fable") {
84 (20.0, 100.0)
85 } else if model.contains("opus") {
86 (15.0, 75.0)
87 } else if model.contains("sonnet") {
88 (3.0, 15.0)
89 } else if model.contains("haiku") {
90 (0.8, 4.0)
91 } else {
92 (3.0, 15.0)
95 }
96}
97
98fn turn_cost_usd(model: &str, input: u64, output: u64, cache_creation: u64, cache_read: u64) -> f64 {
102 let (in_rate, out_rate) = price_per_million(model);
103 let cache_read_rate = in_rate * 0.1;
104 let cache_write_rate = in_rate * 1.25;
105 (input as f64 * in_rate
106 + output as f64 * out_rate
107 + cache_read as f64 * cache_read_rate
108 + cache_creation as f64 * cache_write_rate)
109 / 1_000_000.0
110}
111
112struct TurnUsage {
114 model: String,
115 input: u64,
116 output: u64,
117 cache_creation: u64,
118 cache_read: u64,
119 timestamp: String,
120}
121
122fn parse_turn(line: &str) -> Option<TurnUsage> {
123 let v: serde_json::Value = serde_json::from_str(line).ok()?;
124 if v.get("type").and_then(|t| t.as_str()) != Some("assistant") {
125 return None;
126 }
127 let usage = v.pointer("/message/usage")?;
128 let get_u64 = |k: &str| usage.get(k).and_then(|x| x.as_u64()).unwrap_or(0);
129 Some(TurnUsage {
130 model: v.pointer("/message/model").and_then(|m| m.as_str()).unwrap_or("").to_string(),
131 input: get_u64("input_tokens"),
132 output: get_u64("output_tokens"),
133 cache_creation: get_u64("cache_creation_input_tokens"),
134 cache_read: get_u64("cache_read_input_tokens"),
135 timestamp: v.get("timestamp").and_then(|t| t.as_str()).unwrap_or("").to_string(),
136 })
137}
138
139fn ingest_dir(dir: &Path) -> Option<UsageSnapshot> {
143 let entries = std::fs::read_dir(dir).ok()?;
144
145 let mut total_cost = 0.0f64;
146 let mut latest_ts = String::new();
147 let mut latest_context: u64 = 0;
148 let mut latest_model: Option<String> = None;
149 let mut found = false;
150
151 for entry in entries.flatten() {
152 let path = entry.path();
153 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
154 continue;
155 }
156 let Ok(file) = std::fs::File::open(&path) else { continue };
157 for line in BufReader::new(file).lines().map_while(Result::ok) {
158 let Some(turn) = parse_turn(&line) else { continue };
159 found = true;
160 total_cost += turn_cost_usd(&turn.model, turn.input, turn.output, turn.cache_creation, turn.cache_read);
161 if turn.timestamp >= latest_ts {
162 latest_ts = turn.timestamp;
163 latest_context = turn.input + turn.cache_creation + turn.cache_read;
164 latest_model = Some(turn.model);
165 }
166 }
167 }
168
169 found.then_some(UsageSnapshot {
170 cost_usd: total_cost,
171 context_tokens: latest_context,
172 model: latest_model,
173 })
174}
175
176pub fn ingest_usage_for_workspace(workspace: &str) -> Option<UsageSnapshot> {
181 let dir = claude_projects_dir().join(claude_project_slug(workspace));
182 ingest_dir(&dir)
183}
184
185#[cfg(test)]
190pub(crate) static ENV_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use std::io::Write;
196 use tempfile::tempdir;
197
198 #[test]
199 fn slug_replaces_non_alphanumeric_with_dash() {
200 assert_eq!(
201 claude_project_slug("/Users/ethan.brodie/Library/Application Support/ninox/orchestrator/2"),
202 "-Users-ethan-brodie-Library-Application-Support-ninox-orchestrator-2",
203 );
204 }
205
206 #[test]
207 fn slug_handles_nested_dot_worktree_dirs() {
208 assert_eq!(
209 claude_project_slug("/Users/x/proj/iac/.claude/worktrees/y"),
210 "-Users-x-proj-iac--claude-worktrees-y",
211 );
212 }
213
214 #[test]
215 fn price_ordering_matches_fleet_ranking() {
216 let (fable_in, fable_out) = price_per_million("claude-fable-5");
219 let (opus_in, opus_out) = price_per_million("claude-opus-4-8");
220 let (haiku_in, haiku_out) = price_per_million("claude-haiku-4-5");
221 assert!(fable_in > opus_in && opus_in > haiku_in);
222 assert!(fable_out > opus_out && opus_out > haiku_out);
223 }
224
225 #[test]
226 fn turn_cost_is_positive_and_scales_with_tokens() {
227 let small = turn_cost_usd("claude-opus-4-8", 100, 100, 0, 0);
228 let large = turn_cost_usd("claude-opus-4-8", 10_000, 10_000, 0, 0);
229 assert!(small > 0.0);
230 assert!(large > small);
231 }
232
233 #[test]
234 fn cache_read_is_cheaper_than_fresh_input() {
235 let fresh = turn_cost_usd("claude-sonnet-4-5", 1000, 0, 0, 0);
236 let cached = turn_cost_usd("claude-sonnet-4-5", 0, 0, 0, 1000);
237 assert!(cached < fresh);
238 }
239
240 fn write_jsonl(dir: &Path, name: &str, lines: &[&str]) {
241 let mut f = std::fs::File::create(dir.join(name)).unwrap();
242 for l in lines {
243 writeln!(f, "{l}").unwrap();
244 }
245 }
246
247 #[test]
248 fn ingest_dir_sums_cost_and_uses_latest_turn_for_context() {
249 let dir = tempdir().unwrap();
250 write_jsonl(dir.path(), "s1.jsonl", &[
251 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}}}"#,
252 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}}}"#,
253 r#"{"type":"system","subtype":"turn_duration"}"#,
255 "not even json",
256 ]);
257
258 let snap = ingest_dir(dir.path()).expect("usage found");
259 assert!(snap.cost_usd > 0.0);
260 assert_eq!(snap.context_tokens, 2 + 500 + 45000);
262 assert_eq!(snap.model.as_deref(), Some("claude-fable-5"));
263 }
264
265 #[test]
266 fn ingest_dir_sums_across_multiple_transcript_files() {
267 let dir = tempdir().unwrap();
268 write_jsonl(dir.path(), "a.jsonl", &[
269 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}}}"#,
270 ]);
271 write_jsonl(dir.path(), "b.jsonl", &[
272 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}}}"#,
273 ]);
274 let one_file = ingest_dir(dir.path()).unwrap();
275 let single = turn_cost_usd("claude-haiku-4-5", 10, 10, 0, 0)
277 + turn_cost_usd("claude-haiku-4-5", 20, 20, 0, 0);
278 assert!((one_file.cost_usd - single).abs() < 1e-12);
279 assert_eq!(one_file.context_tokens, 20);
281 }
282
283 #[test]
284 fn ingest_dir_returns_none_for_missing_directory() {
285 let dir = tempdir().unwrap();
286 assert!(ingest_dir(&dir.path().join("does-not-exist")).is_none());
287 }
288
289 #[test]
290 fn ingest_dir_returns_none_when_no_assistant_usage_lines() {
291 let dir = tempdir().unwrap();
292 write_jsonl(dir.path(), "empty.jsonl", &[r#"{"type":"system"}"#]);
293 assert!(ingest_dir(dir.path()).is_none());
294 }
295
296 #[test]
297 fn ingest_usage_for_workspace_resolves_via_slug_and_projects_dir_override() {
298 let dir = tempdir().unwrap();
299 let workspace = "/tmp/my-workspace";
300 let project_dir = dir.path().join(claude_project_slug(workspace));
301 std::fs::create_dir_all(&project_dir).unwrap();
302 write_jsonl(&project_dir, "s.jsonl", &[
303 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}}}"#,
304 ]);
305
306 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
307 let prior = std::env::var("NINOX_CLAUDE_PROJECTS_DIR").ok();
308 std::env::set_var("NINOX_CLAUDE_PROJECTS_DIR", dir.path());
309
310 let snap = ingest_usage_for_workspace(workspace);
311
312 match prior {
313 Some(v) => std::env::set_var("NINOX_CLAUDE_PROJECTS_DIR", v),
314 None => std::env::remove_var("NINOX_CLAUDE_PROJECTS_DIR"),
315 }
316
317 let snap = snap.expect("usage found via projects-dir override");
318 assert!(snap.cost_usd > 0.0);
319 assert_eq!(snap.model.as_deref(), Some("claude-opus-4-8"));
320 }
321}