Skip to main content

tokenburn_web/
params.rs

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