Skip to main content

csusage_cli/
types.rs

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    /// Number of most recent report periods to keep, resolved into `since` by
41    /// the binary once the report's calendar unit is known.
42    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
74/// The two documented spellings of a `--since` / `--until` bound.
75pub const DATE_BOUND_FORMATS: &str = "YYYY-MM-DD or YYYYMMDD";
76
77/// Normalizes a date bound into the `YYYYMMDD` form the report keys use.
78///
79/// Reports compare bounds against row keys as plain strings, so a value that is
80/// not one of the two documented formats, or that is not a real calendar date,
81/// would silently turn the filter into a no-op or drop every row. Returns
82/// `None` for those values so callers can reject them.
83pub 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}