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