ccstats 0.2.61

Fast Claude Code token usage statistics CLI
//! `ccstats` is a local-first CLI for token and cost analytics from Claude Code and
//! OpenAI Codex session logs.
//!
//! Common commands:
//! - `ccstats today` / `ccstats daily` for Claude Code usage
//! - `ccstats codex today` / `ccstats codex daily` for Codex usage
//! - `ccstats <command> --source codex` for unified source selection
//!
//! Project page: <https://github.com/majiayu000/ccstats>
// Enable pedantic lints for new code quality; allow domain-inherent cast warnings
// (token counts are i64, display needs f64/u32; precision loss is acceptable)
#![warn(clippy::pedantic)]
#![allow(
    clippy::cast_possible_truncation,
    clippy::cast_possible_wrap,
    clippy::cast_precision_loss,
    clippy::cast_sign_loss
)]

mod app;
mod cli;
mod config;
mod consts;
mod core;
mod error;
mod output;
mod pricing;
mod source;
mod utils;

use chrono::Utc;
use clap::Parser;

use app::{CommandContext, handle_source_command};
use cli::{Cli, parse_command};
use config::Config;
use core::DateFilter;
use output::NumberFormat;
use pricing::{CurrencyConverter, PricingDb};
use source::get_source;
use utils::{Timezone, parse_date};

enum TimezoneSource {
    Cli,
    Config,
}

#[allow(clippy::too_many_lines)] // Main wires CLI/config/runtime setup before dispatch.
fn main() {
    // Parse CLI and extract source command
    let raw_cli = Cli::parse();
    let raw_timezone = raw_cli.timezone.clone();
    let parsed_command = parse_command(raw_cli.command.as_ref());
    let source_cmd = parsed_command.command;
    let is_statusline = source_cmd.is_statusline();

    // Load config file (quiet for statusline)
    let config = if is_statusline {
        Config::load_quiet()
    } else {
        Config::load()
    };

    // Merge config with CLI (CLI takes precedence)
    let cli = raw_cli.with_config(&config);

    let timezone_source = if raw_timezone.is_some() {
        Some(TimezoneSource::Cli)
    } else if cli.timezone.is_some() {
        Some(TimezoneSource::Config)
    } else {
        None
    };

    let timezone = match Timezone::parse(cli.timezone.as_deref()) {
        Ok(tz) => tz,
        Err(err) => {
            if let Some(TimezoneSource::Config) = timezone_source {
                eprintln!("Warning: {err}. Falling back to local timezone.");
                Timezone::Local
            } else {
                eprintln!("{err}");
                std::process::exit(1);
            }
        }
    };

    let number_format = match NumberFormat::from_locale(cli.locale.as_deref()) {
        Ok(format) => format,
        Err(err) => {
            eprintln!("Warning: {err}. Using default locale.");
            NumberFormat::default()
        }
    };

    let jq_filter = cli.jq.as_deref();

    let parse_date_flag = |value: &Option<String>, flag: &str| {
        value.as_ref().map(|s| match parse_date(s) {
            Ok(date) => date,
            Err(err) => {
                eprintln!("{flag}: {err}");
                std::process::exit(1);
            }
        })
    };
    let since = parse_date_flag(&cli.since, "--since");
    let until = parse_date_flag(&cli.until, "--until");

    // Validate date range
    if let (Some(s), Some(u)) = (since, until)
        && s > u
    {
        eprintln!("Error: --since ({s}) is after --until ({u})");
        std::process::exit(1);
    }

    // For "today" and "statusline" commands, set since/until to today
    let filter = if source_cmd.needs_today_filter() {
        let today = timezone.to_fixed_offset(Utc::now()).date_naive();
        DateFilter::new(Some(today), Some(today))
    } else {
        DateFilter::new(since, until)
    };

    // Load pricing database (quiet mode for statusline)
    let pricing_db = if is_statusline {
        PricingDb::load_quiet(cli.offline, cli.strict_pricing)
    } else {
        PricingDb::load(cli.offline, cli.strict_pricing)
    };

    // Resolve source name from subcommand hint and optional --source override.
    let source_name = match (parsed_command.source_hint, cli.source.as_deref()) {
        (Some(hint), Some(override_name)) => {
            let Some(override_source) = get_source(override_name) else {
                eprintln!("Error: unknown source '{override_name}'");
                std::process::exit(1);
            };
            if override_source.name() != hint {
                eprintln!(
                    "Error: command source '{hint}' conflicts with --source '{}'",
                    override_source.name()
                );
                std::process::exit(1);
            }
            override_source.name()
        }
        (Some(hint), None) => hint,
        (None, Some(name)) => name,
        (None, None) => "claude",
    };

    let Some(source) = get_source(source_name) else {
        eprintln!("Error: {source_name} source not found");
        std::process::exit(1);
    };

    // Initialize currency converter if requested
    let currency_converter =
        cli.currency
            .as_ref()
            .map(|code| match CurrencyConverter::load(code, cli.offline) {
                Some(conv) => {
                    if !is_statusline {
                        eprintln!(
                            "Converting costs to {} (rate: displayed as {})",
                            conv.currency_code(),
                            conv.format(1.0)
                        );
                    }
                    conv
                }
                None => {
                    eprintln!("Error: failed to load exchange rate for '{code}'");
                    std::process::exit(1);
                }
            });

    handle_source_command(
        source,
        source_cmd,
        &CommandContext {
            filter: &filter,
            cli: &cli,
            pricing_db: &pricing_db,
            timezone,
            number_format,
            jq_filter,
            currency: currency_converter.as_ref(),
        },
    );
}