claudex-cli 0.10.1

Query, search, and analyze agent coding sessions from the command line
Documentation
use anyhow::{Result, bail};
use chrono::{Datelike, Local, NaiveTime, TimeZone, Timelike, Utc};

use crate::cli::ResolvedFilter;
use crate::ui;
use claudex::index::IndexStore;
use claudex::providers::enabled_default;
use claudex::time_utils::local_day_start_ms;

pub fn run(monthly: f64, json: bool, filter: &ResolvedFilter) -> Result<()> {
    if monthly <= 0.0 {
        bail!("--monthly must be greater than 0");
    }
    let providers = enabled_default()?;
    let mut idx = IndexStore::open()?;
    idx.ensure_fresh(&providers)?;

    let today = Local::now().date_naive();
    let default_start = today
        .with_day(1)
        .expect("every calendar month has a first day");
    let mut scoped = filter.clone();
    let period_start = scoped.since_ms.map(date_from_ms).unwrap_or(default_start);
    if scoped.since_ms.is_none() {
        scoped.since_ms = Some(local_day_start_ms(period_start));
    }
    let period_end = scoped.until_ms.map(date_from_ms).unwrap_or(today);
    let days_elapsed = (period_end - period_start).num_days().max(0) + 1;
    let days_in_month = days_in_month(period_end.year(), period_end.month()) as i64;

    let summary = idx.query_summary(&scoped)?;
    let spent = summary.total_cost;
    let projected = if days_elapsed > 0 {
        spent / days_elapsed as f64 * days_in_month as f64
    } else {
        spent
    };
    let remaining = monthly - spent;
    let pct = spent / monthly * 100.0;

    if json {
        println!(
            "{}",
            serde_json::to_string_pretty(&serde_json::json!({
                "monthly_budget_usd": monthly,
                "period_start": period_start.to_string(),
                "period_end": period_end.to_string(),
                "days_elapsed": days_elapsed,
                "days_in_month": days_in_month,
                "spent_usd": spent,
                "remaining_usd": remaining,
                "used_percent": pct,
                "projected_month_end_usd": projected,
                "projected_over_budget_usd": (projected - monthly).max(0.0),
                "sessions": summary.total_sessions,
            }))?
        );
        return Ok(());
    }

    let mut table = ui::table();
    table.set_header(ui::header([
        "Budget",
        "Spent",
        "Remaining",
        "Used",
        "Projected",
    ]));
    ui::right_align(&mut table, &[0, 1, 2, 3, 4]);
    table.add_row([
        ui::cell_cost(monthly),
        ui::cell_cost(spent),
        ui::cell_cost(remaining),
        ui::cell_plain(format!("{pct:.1}%")),
        ui::cell_cost(projected),
    ]);
    println!("{table}");
    Ok(())
}

fn days_in_month(year: i32, month: u32) -> u32 {
    let (next_year, next_month) = if month == 12 {
        (year + 1, 1)
    } else {
        (year, month + 1)
    };
    let next = chrono::NaiveDate::from_ymd_opt(next_year, next_month, 1).unwrap();
    let last = next - chrono::Duration::days(1);
    last.day()
}

fn date_from_ms(ms: i64) -> chrono::NaiveDate {
    let utc = Utc
        .timestamp_millis_opt(ms)
        .single()
        .unwrap_or_else(Utc::now);
    let time = utc.time();
    if time == NaiveTime::from_hms_opt(0, 0, 0).unwrap()
        || (time.hour(), time.minute(), time.second(), time.nanosecond())
            == (23, 59, 59, 999_000_000)
    {
        return utc.date_naive();
    }

    Local
        .timestamp_millis_opt(ms)
        .single()
        .unwrap_or_else(Local::now)
        .date_naive()
}