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 Settings page's limit fields, as submitted.
46#[query_params(error = redirect("/settings"))]
47pub struct LimitParams {
48    /// Present when the limits form was submitted.
49    pub save: Option<String>,
50    pub limit_daily: Option<String>,
51    pub limit_weekly: Option<String>,
52    pub limit_monthly: Option<String>,
53    /// A checked checkbox sends `on`; unchecked sends nothing.
54    pub notify: Option<String>,
55}
56
57/// The currency to show: an explicit, valid `requested` code wins (with the user's own
58/// rate from `rates` if they set one), otherwise `saved`.
59pub fn pick_currency(
60    requested: Option<&str>,
61    saved: Currency,
62    rates: &BTreeMap<String, f64>,
63) -> Currency {
64    match requested.and_then(tokenburn_core::currency_by_code) {
65        Some(c) => tokenburn_core::features::currency::resolve(c.code, rates),
66        None => saved,
67    }
68}
69
70/// A selection from optional strings; empty strings mean "nothing selected".
71pub fn parse_selection(bucket: Option<&str>, project: Option<&str>) -> Selection {
72    let non_empty = |s: Option<&str>| s.filter(|v| !v.is_empty()).map(str::to_string);
73    Selection {
74        bucket: non_empty(bucket),
75        project: non_empty(project),
76    }
77}
78
79impl UsageParams {
80    /// The clicked bar / slice from the URL.
81    pub fn selection(&self) -> Selection {
82        parse_selection(self.sel_bucket.as_deref(), self.sel_project.as_deref())
83    }
84}
85
86/// Parse optional string parameters into a [`Query`], ignoring invalid values.
87pub fn parse(tool: Option<&str>, bucket: Option<&str>, window: Option<&str>) -> Query {
88    let tool = tool
89        .and_then(|s| s.parse::<ToolFilter>().ok())
90        .unwrap_or_default();
91    let bucket = bucket
92        .and_then(|s| s.parse::<Bucket>().ok())
93        .unwrap_or_default();
94    let window = window
95        .and_then(|s| s.parse::<Window>().ok())
96        .unwrap_or_else(|| bucket.default_window());
97    Query {
98        tool,
99        bucket,
100        window,
101    }
102}
103
104/// The chart metric from an optional string (unknown values fall back to tokens).
105pub fn parse_metric(s: Option<&str>) -> Metric {
106    s.and_then(|m| m.parse().ok()).unwrap_or_default()
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn defaults_when_missing() {
115        assert_eq!(parse(None, None, None), Query::default());
116    }
117
118    #[test]
119    fn bucket_picks_natural_window() {
120        let q = parse(None, Some("hourly"), None);
121        assert_eq!((q.bucket, q.window), (Bucket::Hour, Window::Today));
122        let q = parse(None, Some("monthly"), None);
123        assert_eq!(q.window, Window::All);
124    }
125
126    #[test]
127    fn explicit_window_wins() {
128        let q = parse(Some("pi"), Some("daily"), Some("all"));
129        assert_eq!(
130            (q.tool, q.bucket, q.window),
131            (ToolFilter::PI, Bucket::Day, Window::All)
132        );
133    }
134
135    #[test]
136    fn an_explicit_currency_wins_and_junk_keeps_the_saved_one() {
137        let rates = BTreeMap::from([("EUR".to_string(), 0.5)]);
138        let saved = Currency::USD;
139        assert_eq!(pick_currency(Some("gbp"), saved, &rates).code, "GBP");
140        assert_eq!(pick_currency(None, saved, &rates).code, "USD");
141        assert_eq!(pick_currency(Some("nope"), saved, &rates).code, "USD");
142        let eur = pick_currency(Some("EUR"), saved, &rates);
143        assert!(
144            eur.custom_rate && eur.per_usd == 0.5,
145            "the user's own rate is applied"
146        );
147    }
148
149    #[test]
150    fn selections_ignore_empty_strings() {
151        assert!(parse_selection(None, None).is_empty());
152        assert!(parse_selection(Some(""), Some("")).is_empty());
153        let s = parse_selection(Some("2026-09-22"), Some("pi:atlas"));
154        assert_eq!(s.bucket.as_deref(), Some("2026-09-22"));
155        assert_eq!(s.project.as_deref(), Some("pi:atlas"));
156    }
157
158    #[test]
159    fn metric_parses_and_defaults_to_tokens() {
160        assert_eq!(parse_metric(Some("cost")), Metric::Cost);
161        assert_eq!(parse_metric(Some("COST")), Metric::Cost);
162        assert_eq!(parse_metric(Some("bytes")), Metric::Tokens);
163        assert_eq!(parse_metric(None), Metric::Tokens);
164    }
165
166    #[test]
167    fn invalid_values_fall_back() {
168        // Regression guard: a bad bookmark must not break the page.
169        assert_eq!(
170            parse(Some("vim"), Some("weekly"), Some("year")),
171            Query::default()
172        );
173    }
174}
175
176#[cfg(test)]
177mod signal_value_tests {
178    use super::*;
179
180    #[test]
181    fn every_window_choice_parses_to_a_query() {
182        // `auto` is not a real Window: it must fall back to the bucket's natural window.
183        let q = parse(None, Some("hourly"), Some("auto"));
184        assert_eq!(q.window, Window::Today);
185        for w in WINDOW_CHOICES {
186            let _ = parse(None, None, Some(w));
187        }
188    }
189
190    #[test]
191    fn hostile_signal_values_are_harmless() {
192        // Signals come back from the browser: arbitrary strings must not panic.
193        let evil = "'; DROP TABLE <script>\0";
194        assert_eq!(parse(Some(evil), Some(evil), Some(evil)), Query::default());
195    }
196}