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