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 port: u16,
160}
161
162#[derive(Clone)]
163pub struct StatuslineArgs {
164 pub offline: bool,
165 pub no_offline: bool,
166 pub visual_burn_rate: VisualBurnRate,
167 pub cost_source: CostSource,
168 pub cache: bool,
169 pub no_cache: bool,
170 pub refresh_interval: u64,
171 pub context_low_threshold: u8,
172 pub context_medium_threshold: u8,
173 pub timezone: Option<String>,
174 pub config: Option<PathBuf>,
175 pub debug: bool,
176 pub model_label_aliases: HashMap<String, String>,
177 pub pricing_overrides: BTreeMap<String, PricingOverride>,
178}
179
180#[derive(Clone)]
181pub struct AgentCommandArgs {
182 pub shared: SharedArgs,
183 pub kind: AgentReportKind,
184 pub sections: Option<Vec<AgentReportKind>>,
185 pub by_agent: bool,
186 pub pi_path: Option<String>,
187 pub open_claw_path: Option<String>,
188 pub codex_speed: CodexSpeed,
189}
190
191#[derive(Clone, Debug, Eq, PartialEq)]
192pub struct NamedPiStore {
193 pub name: String,
194 pub path: String,
195}
196
197#[derive(Clone, Copy, Debug, Eq, PartialEq)]
198pub enum AgentReportKind {
199 Daily,
200 Weekly,
201 Monthly,
202 Session,
203}
204
205pub const STANDARD_AGENT_REPORTS: &[(&str, AgentReportKind)] = &[
206 ("daily", AgentReportKind::Daily),
207 ("monthly", AgentReportKind::Monthly),
208 ("session", AgentReportKind::Session),
209];
210
211pub const OPENCODE_AGENT_REPORTS: &[(&str, AgentReportKind)] = &[
212 ("daily", AgentReportKind::Daily),
213 ("weekly", AgentReportKind::Weekly),
214 ("monthly", AgentReportKind::Monthly),
215 ("session", AgentReportKind::Session),
216];
217
218#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
219pub enum CodexSpeed {
220 #[default]
221 Auto,
222 Standard,
223 Fast,
224}
225
226impl Default for StatuslineArgs {
227 fn default() -> Self {
228 Self {
229 offline: true,
230 no_offline: false,
231 visual_burn_rate: VisualBurnRate::Off,
232 cost_source: CostSource::Auto,
233 cache: true,
234 no_cache: false,
235 refresh_interval: 1,
236 context_low_threshold: 50,
237 context_medium_threshold: 80,
238 timezone: None,
239 config: None,
240 debug: false,
241 model_label_aliases: HashMap::new(),
242 pricing_overrides: BTreeMap::new(),
243 }
244 }
245}
246
247#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
248pub enum CostMode {
249 #[default]
250 Auto,
251 Calculate,
252 Display,
253}
254
255#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
256pub enum SortOrder {
257 Desc,
258 #[default]
259 Asc,
260}
261
262#[derive(Clone, Copy, Debug, Eq, PartialEq)]
263pub enum WeekDay {
264 Sunday,
265 Monday,
266 Tuesday,
267 Wednesday,
268 Thursday,
269 Friday,
270 Saturday,
271}
272
273#[derive(Clone, Copy, Debug, Eq, PartialEq)]
274pub enum VisualBurnRate {
275 Off,
276 Emoji,
277 Text,
278 EmojiText,
279}
280
281#[derive(Clone, Copy, Debug, Eq, PartialEq)]
282pub enum CostSource {
283 Auto,
284 Ccusage,
285 Cc,
286 Both,
287}
288
289#[derive(Clone, Debug, Default, PartialEq)]
290pub struct PricingOverride {
291 pub input_cost_per_token: Option<f64>,
292 pub output_cost_per_token: Option<f64>,
293 pub cache_creation_input_token_cost: Option<f64>,
294 pub cache_read_input_token_cost: Option<f64>,
295 pub input_cost_per_token_above_200k_tokens: Option<f64>,
296 pub output_cost_per_token_above_200k_tokens: Option<f64>,
297 pub cache_creation_input_token_cost_above_200k_tokens: Option<f64>,
298 pub cache_read_input_token_cost_above_200k_tokens: Option<f64>,
299 pub max_input_tokens: Option<u64>,
300 pub fast_multiplier: Option<f64>,
301}
302
303pub trait CliConfig {
304 fn config_error(&self) -> Option<&str> {
305 None
306 }
307
308 fn apply_shared(&self, _shared: &mut SharedArgs) {}
309
310 fn apply_daily_args(&self, _args: &mut DailyArgs) {}
311
312 fn apply_weekly_args(&self, _args: &mut WeeklyArgs) {}
313
314 fn apply_blocks_args(&self, _args: &mut BlocksArgs) {}
315
316 fn apply_statusline_args(&self, _args: &mut StatuslineArgs) {}
317
318 fn apply_agent_args(
319 &self,
320 _codex_speed: &mut CodexSpeed,
321 _pi_path: Option<&mut Option<String>>,
322 _open_claw_path: Option<&mut Option<String>>,
323 ) {
324 }
325}
326
327pub struct NoConfig;
328
329impl CliConfig for NoConfig {}
330
331#[cfg(test)]
332mod tests {
333 use super::normalize_date_bound;
334
335 #[test]
336 fn accepts_both_documented_formats() {
337 assert_eq!(
338 normalize_date_bound("2026-07-10").as_deref(),
339 Some("20260710")
340 );
341 assert_eq!(
342 normalize_date_bound("20260710").as_deref(),
343 Some("20260710")
344 );
345 }
346
347 #[test]
348 fn accepts_leap_day_only_in_leap_years() {
349 assert_eq!(
350 normalize_date_bound("2024-02-29").as_deref(),
351 Some("20240229")
352 );
353 assert_eq!(
354 normalize_date_bound("2000-02-29").as_deref(),
355 Some("20000229")
356 );
357 assert_eq!(normalize_date_bound("2026-02-29"), None);
358 assert_eq!(normalize_date_bound("1900-02-29"), None);
359 }
360
361 #[test]
362 fn rejects_impossible_calendar_dates() {
363 assert_eq!(normalize_date_bound("2026-02-30"), None);
364 assert_eq!(normalize_date_bound("2026-13-01"), None);
365 assert_eq!(normalize_date_bound("2026-00-10"), None);
366 assert_eq!(normalize_date_bound("2026-07-00"), None);
367 assert_eq!(normalize_date_bound("2026-04-31"), None);
368 }
369
370 #[test]
371 fn rejects_undocumented_spellings() {
372 assert_eq!(normalize_date_bound("abc"), None);
373 assert_eq!(normalize_date_bound(""), None);
374 assert_eq!(normalize_date_bound("2026/07/10"), None);
375 assert_eq!(normalize_date_bound("2026-7-10"), None);
376 assert_eq!(normalize_date_bound("2026-07-10T00:00:00Z"), None);
377 assert_eq!(normalize_date_bound("2026-07-1"), None);
378 assert_eq!(normalize_date_bound("2026_07_10"), None);
379 }
380
381 #[test]
382 fn rejects_non_ascii_values_without_panicking() {
383 assert_eq!(normalize_date_bound("20260710"), None);
384 assert_eq!(normalize_date_bound("2026-07-10"), None);
385 }
386}