Skip to main content

tokenburn_web/
params.rs

1//! Query-string handling: `?tool=pi&bucket=daily&window=month`.
2
3use tokenburn_core::{Bucket, Metric, Query, Selection, ToolFilter, Window};
4use topcoat::router::query_params;
5
6/// Window choices offered by the interactive UI. `auto` means "the chosen
7/// bucket's natural window" (see [`Bucket::default_window`]).
8pub const WINDOW_CHOICES: [&str; 4] = ["auto", "today", "month", "all"];
9
10/// Raw, optional query-string values. Unknown values fall back to defaults
11/// instead of failing the request, so a stale bookmark never 400s.
12#[query_params(error = redirect("?"))]
13pub struct UsageParams {
14    pub tool: Option<String>,
15    pub bucket: Option<String>,
16    pub window: Option<String>,
17    /// Colour theme name (see `/api/themes`). Unknown names are ignored.
18    pub theme: Option<String>,
19    /// Chart metric: `tokens` (default) or `cost`.
20    pub metric: Option<String>,
21    /// Clicked chart bucket (a label such as `2026-09-22`).
22    pub sel_bucket: Option<String>,
23    /// Clicked donut slice (a `tool:project` key).
24    pub sel_project: Option<String>,
25}
26
27impl UsageParams {
28    /// Translate into a core [`Query`].
29    ///
30    /// The window defaults to the chosen bucket's natural window (same rule as
31    /// the CLI/TUI/GUI) unless given explicitly.
32    pub fn to_query(&self) -> Query {
33        parse(
34            self.tool.as_deref(),
35            self.bucket.as_deref(),
36            self.window.as_deref(),
37        )
38    }
39}
40
41/// A selection from optional strings; empty strings mean "nothing selected".
42pub fn parse_selection(bucket: Option<&str>, project: Option<&str>) -> Selection {
43    let non_empty = |s: Option<&str>| s.filter(|v| !v.is_empty()).map(str::to_string);
44    Selection {
45        bucket: non_empty(bucket),
46        project: non_empty(project),
47    }
48}
49
50impl UsageParams {
51    /// The clicked bar / slice from the URL.
52    pub fn selection(&self) -> Selection {
53        parse_selection(self.sel_bucket.as_deref(), self.sel_project.as_deref())
54    }
55}
56
57/// Parse optional string parameters into a [`Query`], ignoring invalid values.
58pub fn parse(tool: Option<&str>, bucket: Option<&str>, window: Option<&str>) -> Query {
59    let tool = tool
60        .and_then(|s| s.parse::<ToolFilter>().ok())
61        .unwrap_or_default();
62    let bucket = bucket
63        .and_then(|s| s.parse::<Bucket>().ok())
64        .unwrap_or_default();
65    let window = window
66        .and_then(|s| s.parse::<Window>().ok())
67        .unwrap_or_else(|| bucket.default_window());
68    Query {
69        tool,
70        bucket,
71        window,
72    }
73}
74
75/// The chart metric from an optional string (unknown values fall back to tokens).
76pub fn parse_metric(s: Option<&str>) -> Metric {
77    s.and_then(|m| m.parse().ok()).unwrap_or_default()
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn defaults_when_missing() {
86        assert_eq!(parse(None, None, None), Query::default());
87    }
88
89    #[test]
90    fn bucket_picks_natural_window() {
91        let q = parse(None, Some("hourly"), None);
92        assert_eq!((q.bucket, q.window), (Bucket::Hour, Window::Today));
93        let q = parse(None, Some("monthly"), None);
94        assert_eq!(q.window, Window::All);
95    }
96
97    #[test]
98    fn explicit_window_wins() {
99        let q = parse(Some("pi"), Some("daily"), Some("all"));
100        assert_eq!(
101            (q.tool, q.bucket, q.window),
102            (ToolFilter::PI, Bucket::Day, Window::All)
103        );
104    }
105
106    #[test]
107    fn selections_ignore_empty_strings() {
108        assert!(parse_selection(None, None).is_empty());
109        assert!(parse_selection(Some(""), Some("")).is_empty());
110        let s = parse_selection(Some("2026-09-22"), Some("pi:atlas"));
111        assert_eq!(s.bucket.as_deref(), Some("2026-09-22"));
112        assert_eq!(s.project.as_deref(), Some("pi:atlas"));
113    }
114
115    #[test]
116    fn metric_parses_and_defaults_to_tokens() {
117        assert_eq!(parse_metric(Some("cost")), Metric::Cost);
118        assert_eq!(parse_metric(Some("COST")), Metric::Cost);
119        assert_eq!(parse_metric(Some("bytes")), Metric::Tokens);
120        assert_eq!(parse_metric(None), Metric::Tokens);
121    }
122
123    #[test]
124    fn invalid_values_fall_back() {
125        // Regression guard: a bad bookmark must not break the page.
126        assert_eq!(
127            parse(Some("vim"), Some("weekly"), Some("year")),
128            Query::default()
129        );
130    }
131}
132
133#[cfg(test)]
134mod signal_value_tests {
135    use super::*;
136
137    #[test]
138    fn every_window_choice_parses_to_a_query() {
139        // `auto` is not a real Window: it must fall back to the bucket's natural window.
140        let q = parse(None, Some("hourly"), Some("auto"));
141        assert_eq!(q.window, Window::Today);
142        for w in WINDOW_CHOICES {
143            let _ = parse(None, None, Some(w));
144        }
145    }
146
147    #[test]
148    fn hostile_signal_values_are_harmless() {
149        // Signals come back from the browser: arbitrary strings must not panic.
150        let evil = "'; DROP TABLE <script>\0";
151        assert_eq!(parse(Some(evil), Some(evil), Some(evil)), Query::default());
152    }
153}