1use std::io;
12
13use chrono::Utc;
14
15use crate::output::{emit, format_count, Report};
16use crate::store::{Partition, ScanQuery, Scanner};
17
18use super::Env;
19
20const RATE_WINDOW_MS: i64 = 60 * 60 * 1000;
22
23#[derive(Debug, Clone, PartialEq, Eq, Default)]
25pub struct Burn {
26 pub project: Option<String>,
27 pub tokens_per_hour: u64,
29 pub session_id: Option<String>,
30 pub session_ms: i64,
31 pub session_tokens: u64,
32}
33
34impl Burn {
35 pub fn line(&self) -> String {
37 let Some(project) = &self.project else {
38 return "no activity in the current partition".to_string();
39 };
40 format!(
41 "{project} · {} tok/hr · session {} · {} this session",
42 format_count(self.tokens_per_hour as i64),
43 crate::reports::format_span(self.session_ms),
44 format_count(self.session_tokens as i64),
45 )
46 }
47}
48
49pub fn run(env: &Env<'_>, oneline: bool) -> io::Result<Burn> {
50 if !oneline {
51 return Err(io::Error::new(
52 io::ErrorKind::Unsupported,
53 "streaming `warden watch` is not implemented in this release; use \
54 `warden watch --oneline` (e.g. from a tmux status line, or `watch -n5`)",
55 ));
56 }
57
58 env.pre_ingest()?;
59 let now = Utc::now();
60 let burn = burn(
61 &Scanner::new(env.paths.clone()),
62 env.project,
63 now.timestamp_millis(),
64 )?;
65
66 emit(&report(&burn, env), env.json)?;
67 Ok(burn)
68}
69
70pub fn burn(scanner: &Scanner, project: Option<&str>, now_ms: i64) -> io::Result<Burn> {
75 let Some(current) = Partition::for_timestamp(now_ms) else {
76 return Ok(Burn::default());
77 };
78 let window = crate::cli::TimeWindow::new(current.start_ms(), now_ms.saturating_add(1));
79 let query = ScanQuery::new(window).with_project(project.map(str::to_string));
80
81 let mut latest: Option<(i64, Option<String>, Option<String>)> = None;
83 let mut recent_tokens: u64 = 0;
84 let mut per_session: std::collections::HashMap<String, (i64, i64, u64)> =
85 std::collections::HashMap::new();
86
87 scanner.scan_with(&query, |event| {
88 let tokens = event.total_tokens();
89 if event.ts >= now_ms - RATE_WINDOW_MS {
90 recent_tokens += tokens;
91 }
92 if latest.as_ref().is_none_or(|(ts, _, _)| event.ts >= *ts) {
93 latest = Some((event.ts, event.project.clone(), event.session_id.clone()));
94 }
95 if let Some(session) = &event.session_id {
96 let entry = per_session
97 .entry(session.clone())
98 .or_insert((event.ts, event.ts, 0));
99 entry.0 = entry.0.min(event.ts);
100 entry.1 = entry.1.max(event.ts);
101 entry.2 += tokens;
102 }
103 })?;
104
105 let Some((_, project, session_id)) = latest else {
106 return Ok(Burn::default());
107 };
108 let (session_ms, session_tokens) = session_id
109 .as_ref()
110 .and_then(|id| per_session.get(id))
111 .map(|(first, last, tokens)| (last - first, *tokens))
112 .unwrap_or((0, 0));
113
114 Ok(Burn {
115 project,
116 tokens_per_hour: recent_tokens,
117 session_id,
118 session_ms,
119 session_tokens,
120 })
121}
122
123fn report(burn: &Burn, env: &Env<'_>) -> Report {
127 Report::prose("watch", env.window, format!("{}\n", burn.line()))
128 .with_json_rows(vec![serde_json::json!({
129 "project": burn.project,
130 "tokens_per_hour": burn.tokens_per_hour,
131 "session_id": burn.session_id,
132 "session_ms": burn.session_ms,
133 "session_tokens": burn.session_tokens,
134 })])
135 .with_notes([
136 "tok/hr is every token in the last hour — input, output, and cache — from the \
137 current month's partition only",
138 "--since does not apply to watch: it always reports on now",
139 ])
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145 use crate::cli::TimeWindow;
146 use crate::config::Config;
147 use crate::output::{write_report, Style};
148 use crate::reports::testkit::{store, used};
149 use crate::store::StorePaths;
150 use chrono::{TimeZone, Utc};
151
152 fn now() -> i64 {
154 Utc.with_ymd_and_hms(2026, 8, 20, 12, 0, 0)
155 .unwrap()
156 .timestamp_millis()
157 }
158
159 fn mins(n: i64) -> i64 {
160 now() - n * 60_000
161 }
162
163 fn env<'a>(paths: &'a StorePaths, config: &'a Config) -> Env<'a> {
164 Env {
165 config,
166 paths,
167 window: TimeWindow::all(),
168 project: None,
169 json: false,
170 no_ingest: true,
171 include_sidechain: true,
172 }
173 }
174
175 #[test]
176 fn rate_counts_only_the_last_hour_but_the_session_counts_all_of_it() {
177 let (_dir, paths) = store(&[
178 used("a", mins(180), "acme-api", "m", 1_000, 100),
180 used("b", mins(30), "acme-api", "m", 2_000, 200),
181 used("c", mins(5), "acme-api", "m", 3_000, 300),
182 ]);
183 let burn = burn(&Scanner::new(paths), None, now()).unwrap();
184
185 assert_eq!(
187 burn.tokens_per_hour,
188 (2_000 + 200 + 20_000) + (3_000 + 300 + 30_000)
189 );
190 assert_eq!(burn.session_tokens, 11_100 + 22_200 + 33_300);
191 assert_eq!(burn.session_ms, 175 * 60_000);
192 assert_eq!(burn.project.as_deref(), Some("acme-api"));
193 assert!(burn.line().starts_with("acme-api · "), "{}", burn.line());
194 assert!(burn.line().contains("2h55m"), "{}", burn.line());
195 }
196
197 #[test]
198 fn the_session_is_the_one_the_latest_event_belongs_to() {
199 let (_dir, paths) = store(&[
200 used("a", mins(50), "acme-api", "m", 1_000, 0),
201 used("b", mins(10), "dotfiles", "m", 5, 0),
202 ]);
203 let burn = burn(&Scanner::new(paths), None, now()).unwrap();
204 assert_eq!(burn.project.as_deref(), Some("dotfiles"));
205 assert_eq!(burn.session_tokens, 55);
206 assert_eq!(burn.tokens_per_hour, 11_000 + 55);
208 }
209
210 #[test]
211 fn an_empty_store_says_so_rather_than_printing_zeroes() {
212 let (_dir, paths) = store(&[]);
213 let burn = burn(&Scanner::new(paths), None, now()).unwrap();
214 assert_eq!(burn, Burn::default());
215 assert_eq!(burn.line(), "no activity in the current partition");
216 }
217
218 #[test]
219 fn the_human_writer_gets_exactly_the_burn_line() {
220 let (_dir, paths) = store(&[used("a", mins(10), "acme-api", "m", 1_000, 0)]);
221 let burn = burn(&Scanner::new(paths.clone()), None, now()).unwrap();
222 let config = Config::default();
223 let report = report(&burn, &env(&paths, &config));
224
225 let mut human = Vec::new();
226 write_report(&mut human, &report, false, Style::plain()).unwrap();
227 assert_eq!(
228 String::from_utf8(human).unwrap(),
229 format!("{}\n", burn.line())
230 );
231
232 let mut json = Vec::new();
233 write_report(&mut json, &report, true, Style::plain()).unwrap();
234 let out = String::from_utf8(json).unwrap();
235 let v: serde_json::Value = serde_json::from_str(&out).unwrap();
236 assert_eq!(v["report"], "watch");
237 assert_eq!(v["rows"][0]["project"], "acme-api");
238 assert_eq!(v["rows"][0]["tokens_per_hour"], burn.tokens_per_hour);
239 assert_eq!(v["notes"].as_array().unwrap().len(), 2);
240 }
241
242 #[test]
243 fn honours_the_project_filter() {
244 let (_dir, paths) = store(&[
245 used("a", mins(10), "acme-api", "m", 1_000, 0),
246 used("b", mins(5), "dotfiles", "m", 7, 0),
247 ]);
248 let burn = burn(&Scanner::new(paths), Some("acme-api"), now()).unwrap();
249 assert_eq!(burn.project.as_deref(), Some("acme-api"));
250 assert_eq!(burn.tokens_per_hour, 11_000);
251 }
252}