1use std::{
2 collections::{BTreeMap, HashMap},
3 path::PathBuf,
4};
5
6pub enum Command {
7 All(AgentCommandArgs),
8 Daily(DailyArgs),
9 Monthly(SharedArgs),
10 Weekly(WeeklyArgs),
11 Session(SessionArgs),
12 Blocks(BlocksArgs),
13 Statusline(StatuslineArgs),
14 Web(WebArgs),
15 Codex(AgentCommandArgs),
16 OpenCode(AgentCommandArgs),
17 Amp(AgentCommandArgs),
18 Droid(AgentCommandArgs),
19 Codebuff(AgentCommandArgs),
20 Hermes(AgentCommandArgs),
21 Pi(AgentCommandArgs),
22 Goose(AgentCommandArgs),
23 Kilo(AgentCommandArgs),
24 Copilot(AgentCommandArgs),
25 Gemini(AgentCommandArgs),
26 Antigravity(AgentCommandArgs),
27 Kimi(AgentCommandArgs),
28 Qwen(AgentCommandArgs),
29 OpenClaw(AgentCommandArgs),
30 Grok(AgentCommandArgs),
31 ZCode(AgentCommandArgs),
32 ClaudeScience(AgentCommandArgs),
33 OpenHands(AgentCommandArgs),
34}
35
36#[derive(Clone, Debug, Default)]
37pub struct SharedArgs {
38 pub since: Option<String>,
39 pub until: Option<String>,
40 pub last: Option<u32>,
43 pub json: bool,
44 pub mode: CostMode,
45 pub debug: bool,
46 pub debug_samples: usize,
47 pub order: SortOrder,
48 pub breakdown: bool,
49 pub offline: bool,
50 pub no_offline: bool,
51 pub color: bool,
52 pub no_color: bool,
53 pub timezone: Option<String>,
54 pub jq: Option<String>,
55 pub config: Option<PathBuf>,
56 pub compact: bool,
57 pub single_thread: bool,
58 pub no_cost: bool,
59 pub pricing_overrides: BTreeMap<String, PricingOverride>,
60 pub pi_stores: Vec<NamedPiStore>,
61}
62
63impl SharedArgs {
64 pub fn with_defaults() -> Self {
65 Self {
66 mode: CostMode::Auto,
67 debug_samples: 5,
68 order: SortOrder::Asc,
69 ..Self::default()
70 }
71 }
72}
73
74pub const DATE_BOUND_FORMATS: &str = "YYYY-MM-DD or YYYYMMDD";
76
77pub fn normalize_date_bound(value: &str) -> Option<String> {
84 let bytes = value.as_bytes();
85 let digits: [u8; 8] = match bytes.len() {
86 8 => bytes.try_into().ok()?,
87 10 if bytes[4] == b'-' && bytes[7] == b'-' => {
88 let mut digits = [0u8; 8];
89 digits[..4].copy_from_slice(&bytes[..4]);
90 digits[4..6].copy_from_slice(&bytes[5..7]);
91 digits[6..].copy_from_slice(&bytes[8..]);
92 digits
93 }
94 _ => return None,
95 };
96 if !digits.iter().all(u8::is_ascii_digit) {
97 return None;
98 }
99 let number = |slice: &[u8]| {
100 slice
101 .iter()
102 .fold(0u32, |value, digit| value * 10 + u32::from(digit - b'0'))
103 };
104 let year = number(&digits[..4]) as i32;
105 let month = number(&digits[4..6]);
106 let day = number(&digits[6..]);
107 if day == 0 || day > days_in_month(year, month) {
108 return None;
109 }
110 std::str::from_utf8(&digits).ok().map(str::to_string)
111}
112
113fn days_in_month(year: i32, month: u32) -> u32 {
114 match month {
115 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
116 4 | 6 | 9 | 11 => 30,
117 2 if is_leap_year(year) => 29,
118 2 => 28,
119 _ => 0,
120 }
121}
122
123fn is_leap_year(year: i32) -> bool {
124 year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
125}
126
127#[derive(Clone)]
128pub struct DailyArgs {
129 pub shared: SharedArgs,
130 pub instances: bool,
131 pub project: Option<String>,
132 pub project_aliases: Option<String>,
133}
134
135#[derive(Clone)]
136pub struct WeeklyArgs {
137 pub shared: SharedArgs,
138 pub start_of_week: WeekDay,
139}
140
141#[derive(Clone)]
142pub struct SessionArgs {
143 pub shared: SharedArgs,
144 pub id: Option<String>,
145}
146
147#[derive(Clone)]
148pub struct BlocksArgs {
149 pub shared: SharedArgs,
150 pub active: bool,
151 pub recent: bool,
152 pub token_limit: Option<String>,
153 pub session_length: f64,
154}
155
156#[derive(Clone)]
157pub struct WebArgs {
158 pub shared: SharedArgs,
159 pub host: String,
160 pub port: u16,
161}
162
163#[derive(Clone)]
164pub struct StatuslineArgs {
165 pub offline: bool,
166 pub no_offline: bool,
167 pub visual_burn_rate: VisualBurnRate,
168 pub cost_source: CostSource,
169 pub cache: bool,
170 pub no_cache: bool,
171 pub refresh_interval: u64,
172 pub context_low_threshold: u8,
173 pub context_medium_threshold: u8,
174 pub timezone: Option<String>,
175 pub config: Option<PathBuf>,
176 pub debug: bool,
177 pub model_label_aliases: HashMap<String, String>,
178 pub pricing_overrides: BTreeMap<String, PricingOverride>,
179}
180
181#[derive(Clone)]
182pub struct AgentCommandArgs {
183 pub shared: SharedArgs,
184 pub kind: AgentReportKind,
185 pub sections: Option<Vec<AgentReportKind>>,
186 pub by_agent: bool,
187 pub pi_path: Option<String>,
188 pub open_claw_path: Option<String>,
189 pub codex_speed: CodexSpeed,
190}
191
192#[derive(Clone, Debug, Eq, PartialEq)]
193pub struct NamedPiStore {
194 pub name: String,
195 pub path: String,
196}
197
198#[derive(Clone, Copy, Debug, Eq, PartialEq)]
199pub enum AgentReportKind {
200 Daily,
201 Weekly,
202 Monthly,
203 Session,
204}
205
206pub const STANDARD_AGENT_REPORTS: &[(&str, AgentReportKind)] = &[
207 ("daily", AgentReportKind::Daily),
208 ("monthly", AgentReportKind::Monthly),
209 ("session", AgentReportKind::Session),
210];
211
212pub const OPENCODE_AGENT_REPORTS: &[(&str, AgentReportKind)] = &[
213 ("daily", AgentReportKind::Daily),
214 ("weekly", AgentReportKind::Weekly),
215 ("monthly", AgentReportKind::Monthly),
216 ("session", AgentReportKind::Session),
217];
218
219#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
220pub enum CodexSpeed {
221 #[default]
222 Auto,
223 Standard,
224 Fast,
225}
226
227impl Default for StatuslineArgs {
228 fn default() -> Self {
229 Self {
230 offline: true,
231 no_offline: false,
232 visual_burn_rate: VisualBurnRate::Off,
233 cost_source: CostSource::Auto,
234 cache: true,
235 no_cache: false,
236 refresh_interval: 1,
237 context_low_threshold: 50,
238 context_medium_threshold: 80,
239 timezone: None,
240 config: None,
241 debug: false,
242 model_label_aliases: HashMap::new(),
243 pricing_overrides: BTreeMap::new(),
244 }
245 }
246}
247
248#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
249pub enum CostMode {
250 #[default]
251 Auto,
252 Calculate,
253 Display,
254}
255
256#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
257pub enum SortOrder {
258 Desc,
259 #[default]
260 Asc,
261}
262
263#[derive(Clone, Copy, Debug, Eq, PartialEq)]
264pub enum WeekDay {
265 Sunday,
266 Monday,
267 Tuesday,
268 Wednesday,
269 Thursday,
270 Friday,
271 Saturday,
272}
273
274#[derive(Clone, Copy, Debug, Eq, PartialEq)]
275pub enum VisualBurnRate {
276 Off,
277 Emoji,
278 Text,
279 EmojiText,
280}
281
282#[derive(Clone, Copy, Debug, Eq, PartialEq)]
283pub enum CostSource {
284 Auto,
285 Ccusage,
286 Cc,
287 Both,
288}
289
290#[derive(Clone, Debug, Default, PartialEq)]
291pub struct PricingOverride {
292 pub input_cost_per_token: Option<f64>,
293 pub output_cost_per_token: Option<f64>,
294 pub cache_creation_input_token_cost: Option<f64>,
295 pub cache_read_input_token_cost: Option<f64>,
296 pub input_cost_per_token_above_200k_tokens: Option<f64>,
297 pub output_cost_per_token_above_200k_tokens: Option<f64>,
298 pub cache_creation_input_token_cost_above_200k_tokens: Option<f64>,
299 pub cache_read_input_token_cost_above_200k_tokens: Option<f64>,
300 pub max_input_tokens: Option<u64>,
301 pub fast_multiplier: Option<f64>,
302}
303
304pub trait CliConfig {
305 fn config_error(&self) -> Option<&str> {
306 None
307 }
308
309 fn apply_shared(&self, _shared: &mut SharedArgs) {}
310
311 fn apply_daily_args(&self, _args: &mut DailyArgs) {}
312
313 fn apply_weekly_args(&self, _args: &mut WeeklyArgs) {}
314
315 fn apply_blocks_args(&self, _args: &mut BlocksArgs) {}
316
317 fn apply_statusline_args(&self, _args: &mut StatuslineArgs) {}
318
319 fn apply_agent_args(
320 &self,
321 _codex_speed: &mut CodexSpeed,
322 _pi_path: Option<&mut Option<String>>,
323 _open_claw_path: Option<&mut Option<String>>,
324 ) {
325 }
326}
327
328pub struct NoConfig;
329
330impl CliConfig for NoConfig {}
331
332#[cfg(test)]
333mod tests {
334 use super::normalize_date_bound;
335
336 #[test]
337 fn accepts_both_documented_formats() {
338 assert_eq!(
339 normalize_date_bound("2026-07-10").as_deref(),
340 Some("20260710")
341 );
342 assert_eq!(
343 normalize_date_bound("20260710").as_deref(),
344 Some("20260710")
345 );
346 }
347
348 #[test]
349 fn accepts_leap_day_only_in_leap_years() {
350 assert_eq!(
351 normalize_date_bound("2024-02-29").as_deref(),
352 Some("20240229")
353 );
354 assert_eq!(
355 normalize_date_bound("2000-02-29").as_deref(),
356 Some("20000229")
357 );
358 assert_eq!(normalize_date_bound("2026-02-29"), None);
359 assert_eq!(normalize_date_bound("1900-02-29"), None);
360 }
361
362 #[test]
363 fn rejects_impossible_calendar_dates() {
364 assert_eq!(normalize_date_bound("2026-02-30"), None);
365 assert_eq!(normalize_date_bound("2026-13-01"), None);
366 assert_eq!(normalize_date_bound("2026-00-10"), None);
367 assert_eq!(normalize_date_bound("2026-07-00"), None);
368 assert_eq!(normalize_date_bound("2026-04-31"), None);
369 }
370
371 #[test]
372 fn rejects_undocumented_spellings() {
373 assert_eq!(normalize_date_bound("abc"), None);
374 assert_eq!(normalize_date_bound(""), None);
375 assert_eq!(normalize_date_bound("2026/07/10"), None);
376 assert_eq!(normalize_date_bound("2026-7-10"), None);
377 assert_eq!(normalize_date_bound("2026-07-10T00:00:00Z"), None);
378 assert_eq!(normalize_date_bound("2026-07-1"), None);
379 assert_eq!(normalize_date_bound("2026_07_10"), None);
380 }
381
382 #[test]
383 fn rejects_non_ascii_values_without_panicking() {
384 assert_eq!(normalize_date_bound("20260710"), None);
385 assert_eq!(normalize_date_bound("2026-07-10"), None);
386 }
387}