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