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
45#[query_params(error = redirect("/settings"))]
47pub struct LimitParams {
48 pub save: Option<String>,
50 pub limit_daily: Option<String>,
51 pub limit_weekly: Option<String>,
52 pub limit_monthly: Option<String>,
53 pub notify: Option<String>,
55}
56
57pub 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
70pub 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 pub fn selection(&self) -> Selection {
82 parse_selection(self.sel_bucket.as_deref(), self.sel_project.as_deref())
83 }
84}
85
86pub 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
104pub 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 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 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 let evil = "'; DROP TABLE <script>\0";
194 assert_eq!(parse(Some(evil), Some(evil), Some(evil)), Query::default());
195 }
196}