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