Skip to main content

warden/commands/
watch.rs

1//! `warden watch` — the nice-to-have, built last.
2//!
3//! warden ships the `--oneline` form only: one status-bar line, computed from the
4//! current month's partition, then exit. That is the shape a tmux `status-right`
5//! actually wants, and it is honest about cost — a refresh is one bounded scan.
6//!
7//! The streaming form is deliberately absent rather than half-built: doing it
8//! properly means tailing the partition from a byte offset instead of rescanning,
9//! and that is not in scope yet. It says so rather than pretending.
10
11use 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
20/// The window burn rate is averaged over.
21const RATE_WINDOW_MS: i64 = 60 * 60 * 1000;
22
23/// What the status line says.
24#[derive(Debug, Clone, PartialEq, Eq, Default)]
25pub struct Burn {
26    pub project: Option<String>,
27    /// Tokens in the last hour — the rate is per hour, so this *is* the rate.
28    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    /// `acme-api · 418.0k tok/hr · session 1h12m · 84.2k this session`
36    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
70/// Burn rate from the current month's partition only.
71///
72/// Bounded by construction: the scan window starts at the beginning of the
73/// current UTC month, so `watch` never widens with the age of the store.
74pub 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    // The session is whichever one the most recent event belongs to.
82    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
123/// `watch`'s human form is one status-bar line, not a table — so it is a prose
124/// report, the same shape `ingest` and `doctor` use. The envelope still carries
125/// the figures as real rows for a harness to do its own arithmetic on.
126fn 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    /// Timestamps relative to a fixed "now" inside a real month partition.
153    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            // Three hours ago: in the session, out of the rate window.
179            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        // `used` also sets cache_read = input * 10.
186        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        // The rate still spans both projects.
207        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}