1use std::collections::BTreeMap;
4
5use tokenburn_core::{Bucket, Currency, Metric, Query, Selection, ToolFilter, Window};
6use topcoat::router::query_params;
7
8pub const WINDOW_CHOICES: [&str; 4] = ["auto", "today", "month", "all"];
11
12#[query_params(error = redirect("?"))]
15pub struct UsageParams {
16 pub tool: Option<String>,
17 pub bucket: Option<String>,
18 pub window: Option<String>,
19 pub theme: Option<String>,
21 pub metric: Option<String>,
23 pub currency: Option<String>,
25 pub sel_bucket: Option<String>,
27 pub sel_project: Option<String>,
29}
30
31impl UsageParams {
32 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
45pub 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
58pub 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 pub fn selection(&self) -> Selection {
70 parse_selection(self.sel_bucket.as_deref(), self.sel_project.as_deref())
71 }
72}
73
74pub 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
92pub 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 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 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 let evil = "'; DROP TABLE <script>\0";
182 assert_eq!(parse(Some(evil), Some(evil), Some(evil)), Query::default());
183 }
184}