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