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 host: String,
160    pub port: u16,
161}
162
163#[derive(Clone)]
164pub struct StatuslineArgs {
165    pub offline: bool,
166    pub no_offline: bool,
167    pub visual_burn_rate: VisualBurnRate,
168    pub cost_source: CostSource,
169    pub cache: bool,
170    pub no_cache: bool,
171    pub refresh_interval: u64,
172    pub context_low_threshold: u8,
173    pub context_medium_threshold: u8,
174    pub timezone: Option<String>,
175    pub config: Option<PathBuf>,
176    pub debug: bool,
177    pub model_label_aliases: HashMap<String, String>,
178    pub pricing_overrides: BTreeMap<String, PricingOverride>,
179}
180
181#[derive(Clone)]
182pub struct AgentCommandArgs {
183    pub shared: SharedArgs,
184    pub kind: AgentReportKind,
185    pub sections: Option<Vec<AgentReportKind>>,
186    pub by_agent: bool,
187    pub pi_path: Option<String>,
188    pub open_claw_path: Option<String>,
189    pub codex_speed: CodexSpeed,
190}
191
192#[derive(Clone, Debug, Eq, PartialEq)]
193pub struct NamedPiStore {
194    pub name: String,
195    pub path: String,
196}
197
198#[derive(Clone, Copy, Debug, Eq, PartialEq)]
199pub enum AgentReportKind {
200    Daily,
201    Weekly,
202    Monthly,
203    Session,
204}
205
206pub const STANDARD_AGENT_REPORTS: &[(&str, AgentReportKind)] = &[
207    ("daily", AgentReportKind::Daily),
208    ("monthly", AgentReportKind::Monthly),
209    ("session", AgentReportKind::Session),
210];
211
212pub const OPENCODE_AGENT_REPORTS: &[(&str, AgentReportKind)] = &[
213    ("daily", AgentReportKind::Daily),
214    ("weekly", AgentReportKind::Weekly),
215    ("monthly", AgentReportKind::Monthly),
216    ("session", AgentReportKind::Session),
217];
218
219#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
220pub enum CodexSpeed {
221    #[default]
222    Auto,
223    Standard,
224    Fast,
225}
226
227impl Default for StatuslineArgs {
228    fn default() -> Self {
229        Self {
230            offline: true,
231            no_offline: false,
232            visual_burn_rate: VisualBurnRate::Off,
233            cost_source: CostSource::Auto,
234            cache: true,
235            no_cache: false,
236            refresh_interval: 1,
237            context_low_threshold: 50,
238            context_medium_threshold: 80,
239            timezone: None,
240            config: None,
241            debug: false,
242            model_label_aliases: HashMap::new(),
243            pricing_overrides: BTreeMap::new(),
244        }
245    }
246}
247
248#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
249pub enum CostMode {
250    #[default]
251    Auto,
252    Calculate,
253    Display,
254}
255
256#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
257pub enum SortOrder {
258    Desc,
259    #[default]
260    Asc,
261}
262
263#[derive(Clone, Copy, Debug, Eq, PartialEq)]
264pub enum WeekDay {
265    Sunday,
266    Monday,
267    Tuesday,
268    Wednesday,
269    Thursday,
270    Friday,
271    Saturday,
272}
273
274#[derive(Clone, Copy, Debug, Eq, PartialEq)]
275pub enum VisualBurnRate {
276    Off,
277    Emoji,
278    Text,
279    EmojiText,
280}
281
282#[derive(Clone, Copy, Debug, Eq, PartialEq)]
283pub enum CostSource {
284    Auto,
285    Ccusage,
286    Cc,
287    Both,
288}
289
290#[derive(Clone, Debug, Default, PartialEq)]
291pub struct PricingOverride {
292    pub input_cost_per_token: Option<f64>,
293    pub output_cost_per_token: Option<f64>,
294    pub cache_creation_input_token_cost: Option<f64>,
295    pub cache_read_input_token_cost: Option<f64>,
296    pub input_cost_per_token_above_200k_tokens: Option<f64>,
297    pub output_cost_per_token_above_200k_tokens: Option<f64>,
298    pub cache_creation_input_token_cost_above_200k_tokens: Option<f64>,
299    pub cache_read_input_token_cost_above_200k_tokens: Option<f64>,
300    pub max_input_tokens: Option<u64>,
301    pub fast_multiplier: Option<f64>,
302}
303
304pub trait CliConfig {
305    fn config_error(&self) -> Option<&str> {
306        None
307    }
308
309    fn apply_shared(&self, _shared: &mut SharedArgs) {}
310
311    fn apply_daily_args(&self, _args: &mut DailyArgs) {}
312
313    fn apply_weekly_args(&self, _args: &mut WeeklyArgs) {}
314
315    fn apply_blocks_args(&self, _args: &mut BlocksArgs) {}
316
317    fn apply_statusline_args(&self, _args: &mut StatuslineArgs) {}
318
319    fn apply_agent_args(
320        &self,
321        _codex_speed: &mut CodexSpeed,
322        _pi_path: Option<&mut Option<String>>,
323        _open_claw_path: Option<&mut Option<String>>,
324    ) {
325    }
326}
327
328pub struct NoConfig;
329
330impl CliConfig for NoConfig {}
331
332#[cfg(test)]
333mod tests {
334    use super::normalize_date_bound;
335
336    #[test]
337    fn accepts_both_documented_formats() {
338        assert_eq!(
339            normalize_date_bound("2026-07-10").as_deref(),
340            Some("20260710")
341        );
342        assert_eq!(
343            normalize_date_bound("20260710").as_deref(),
344            Some("20260710")
345        );
346    }
347
348    #[test]
349    fn accepts_leap_day_only_in_leap_years() {
350        assert_eq!(
351            normalize_date_bound("2024-02-29").as_deref(),
352            Some("20240229")
353        );
354        assert_eq!(
355            normalize_date_bound("2000-02-29").as_deref(),
356            Some("20000229")
357        );
358        assert_eq!(normalize_date_bound("2026-02-29"), None);
359        assert_eq!(normalize_date_bound("1900-02-29"), None);
360    }
361
362    #[test]
363    fn rejects_impossible_calendar_dates() {
364        assert_eq!(normalize_date_bound("2026-02-30"), None);
365        assert_eq!(normalize_date_bound("2026-13-01"), None);
366        assert_eq!(normalize_date_bound("2026-00-10"), None);
367        assert_eq!(normalize_date_bound("2026-07-00"), None);
368        assert_eq!(normalize_date_bound("2026-04-31"), None);
369    }
370
371    #[test]
372    fn rejects_undocumented_spellings() {
373        assert_eq!(normalize_date_bound("abc"), None);
374        assert_eq!(normalize_date_bound(""), None);
375        assert_eq!(normalize_date_bound("2026/07/10"), None);
376        assert_eq!(normalize_date_bound("2026-7-10"), None);
377        assert_eq!(normalize_date_bound("2026-07-10T00:00:00Z"), None);
378        assert_eq!(normalize_date_bound("2026-07-1"), None);
379        assert_eq!(normalize_date_bound("2026_07_10"), None);
380    }
381
382    #[test]
383    fn rejects_non_ascii_values_without_panicking() {
384        assert_eq!(normalize_date_bound("20260710"), None);
385        assert_eq!(normalize_date_bound("2026-07-10"), None);
386    }
387}