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