1use tokenburn_core::{Bucket, Metric, Query, Selection, ToolFilter, Window};
4use topcoat::router::query_params;
5
6pub const WINDOW_CHOICES: [&str; 4] = ["auto", "today", "month", "all"];
9
10#[query_params(error = redirect("?"))]
13pub struct UsageParams {
14 pub tool: Option<String>,
15 pub bucket: Option<String>,
16 pub window: Option<String>,
17 pub theme: Option<String>,
19 pub metric: Option<String>,
21 pub sel_bucket: Option<String>,
23 pub sel_project: Option<String>,
25}
26
27impl UsageParams {
28 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
41pub 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 pub fn selection(&self) -> Selection {
53 parse_selection(self.sel_bucket.as_deref(), self.sel_project.as_deref())
54 }
55}
56
57pub 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
75pub 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 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 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 let evil = "'; DROP TABLE <script>\0";
151 assert_eq!(parse(Some(evil), Some(evil), Some(evil)), Query::default());
152 }
153}