#[cfg(debug_assertions)]
use super::model::DebugRuntimeOptions;
use super::model::{
CacheReadMode, CachedInputCostMode, NumberFormat, ReportKind, ReportOptions,
ScannerParallelism, WatchOptions,
};
use super::render::render_report;
use super::report::{build_report_for_cli, default_timezone_name};
use super::watch::{cli_scan_behavior, run_watch_loop, validate_watch_flags};
use clap::{Args, Parser, Subcommand};
use eyre::{Result, WrapErr};
use std::ffi::OsString;
use std::num::NonZeroUsize;
use std::path::PathBuf;
use std::time::Duration;
#[derive(Args, Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(in crate::app) struct CacheCostCliOptions {
#[arg(long = "no-cache-cost", global = true)]
pub(in crate::app) no_cache_cost: bool,
#[arg(long = "exclude-cache-read", global = true)]
pub(in crate::app) exclude_cache_read: bool,
}
impl CacheCostCliOptions {
pub(in crate::app) fn cached_input_cost_mode(self) -> CachedInputCostMode {
if self.no_cache_cost || self.exclude_cache_read {
CachedInputCostMode::Free
} else {
CachedInputCostMode::Priced
}
}
pub(in crate::app) fn cache_read_mode(self) -> CacheReadMode {
if self.exclude_cache_read {
CacheReadMode::Exclude
} else {
CacheReadMode::Include
}
}
}
#[derive(Args, Clone, Debug, Default, Eq, PartialEq)]
pub(in crate::app) struct ProjectCliOptions {
#[arg(
long,
global = true,
value_name = "PATH",
conflicts_with = "current_dir"
)]
pub(in crate::app) project_dir: Option<PathBuf>,
#[arg(long, global = true, conflicts_with = "project_dir")]
pub(in crate::app) current_dir: bool,
}
impl ProjectCliOptions {
pub(in crate::app) fn resolve_project_dir(self) -> Result<Option<PathBuf>> {
if self.current_dir {
return std::env::current_dir()
.map(Some)
.wrap_err("failed to resolve current directory for --current-dir");
}
Ok(self.project_dir)
}
}
#[cfg(debug_assertions)]
#[derive(Args, Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(in crate::app) struct DebugRuntimeCliOptions {
#[arg(long = "debug-simulate-slow-disk", global = true)]
pub(in crate::app) simulate_slow_disk: bool,
}
#[cfg(debug_assertions)]
impl From<DebugRuntimeCliOptions> for DebugRuntimeOptions {
fn from(options: DebugRuntimeCliOptions) -> Self {
Self {
simulate_slow_disk: options.simulate_slow_disk,
}
}
}
pub fn run<I>(args: I) -> Result<()>
where
I: IntoIterator<Item = OsString>,
{
let cli = Cli::parse_from(args);
#[cfg(debug_assertions)]
let debug = DebugRuntimeOptions::from(cli.debug);
let Cli {
json,
since,
until,
last_days,
timezone,
locale,
number_format,
offline,
refresh_pricing,
cache_cost,
project,
session_dir,
threads,
command,
..
} = cli;
let timezone = timezone.unwrap_or_else(default_timezone_name);
let project_dir = project.resolve_project_dir()?;
let parallelism = threads.map_or(ScannerParallelism::Auto, ScannerParallelism::Fixed);
let cached_input_cost_mode = cache_cost.cached_input_cost_mode();
let cache_read_mode = cache_cost.cache_read_mode();
match command {
Some(Command::Watch {
interval,
per_model_burn_rate,
}) => {
validate_watch_flags(json, since.as_deref(), until.as_deref(), last_days)?;
run_watch_loop(&WatchOptions {
timezone,
locale,
number_format,
offline,
refresh_pricing,
cached_input_cost_mode,
cache_read_mode,
session_dirs: session_dir,
project_dir,
parallelism,
interval,
show_model_burn_rate: per_model_burn_rate,
#[cfg(debug_assertions)]
debug,
})
}
command => {
let kind = command
.as_ref()
.map_or(ReportKind::Daily, ReportKind::from_command);
let options = ReportOptions {
since,
until,
last_days,
timezone,
locale,
number_format,
json,
offline,
refresh_pricing,
cached_input_cost_mode,
cache_read_mode,
session_dirs: session_dir,
project_dir,
parallelism,
};
#[cfg(debug_assertions)]
let scan_behavior = cli_scan_behavior(true, debug.simulate_slow_disk);
#[cfg(not(debug_assertions))]
let scan_behavior = cli_scan_behavior(true);
let output = build_report_for_cli(kind, &options, scan_behavior)?;
if json {
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
println!(
"{}",
render_report(
&output,
&options.locale,
options.number_format,
options.cache_read_mode,
)
);
}
Ok(())
}
}
}
#[derive(Debug, Parser)]
#[command(
author,
version,
about = "Analyze Codex session usage with a fast Rust scanner"
)]
pub(in crate::app) struct Cli {
#[arg(long, short = 'j', global = true)]
pub(in crate::app) json: bool,
#[arg(long, short = 's', global = true)]
pub(in crate::app) since: Option<String>,
#[arg(long, short = 'u', global = true)]
pub(in crate::app) until: Option<String>,
#[arg(
long,
short = 'L',
global = true,
value_name = "N",
value_parser = clap::value_parser!(NonZeroUsize),
conflicts_with_all = ["since", "until"]
)]
pub(in crate::app) last_days: Option<NonZeroUsize>,
#[arg(long, short = 'z', global = true)]
pub(in crate::app) timezone: Option<String>,
#[arg(long, short = 'l', default_value = "en-US", global = true)]
pub(in crate::app) locale: String,
#[arg(long, value_enum, default_value_t = NumberFormat::Short, global = true)]
pub(in crate::app) number_format: NumberFormat,
#[arg(long, short = 'O', global = true)]
pub(in crate::app) offline: bool,
#[arg(long, global = true)]
pub(in crate::app) refresh_pricing: bool,
#[command(flatten)]
pub(in crate::app) cache_cost: CacheCostCliOptions,
#[command(flatten)]
pub(in crate::app) project: ProjectCliOptions,
#[arg(long, global = true)]
pub(in crate::app) session_dir: Vec<PathBuf>,
#[arg(long, global = true, value_name = "N", value_parser = clap::value_parser!(NonZeroUsize))]
pub(in crate::app) threads: Option<NonZeroUsize>,
#[cfg(debug_assertions)]
#[command(flatten)]
pub(in crate::app) debug: DebugRuntimeCliOptions,
#[command(subcommand)]
pub(in crate::app) command: Option<Command>,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Subcommand)]
pub(in crate::app) enum Command {
#[default]
Daily,
Monthly,
Session,
Watch {
#[arg(long, value_name = "SECONDS", default_value = "5", value_parser = parse_interval_seconds)]
interval: Duration,
#[arg(long)]
per_model_burn_rate: bool,
},
}
impl ReportKind {
pub(in crate::app) fn from_command(value: &Command) -> Self {
match value {
Command::Daily => Self::Daily,
Command::Monthly => Self::Monthly,
Command::Session => Self::Session,
Command::Watch { .. } => unreachable!("watch mode does not map to ReportKind"),
}
}
}
pub(in crate::app) fn parse_interval_seconds(value: &str) -> std::result::Result<Duration, String> {
let seconds = value
.parse::<u64>()
.map_err(|_| format!("invalid interval {value}; expected a positive integer"))?;
if seconds == 0 {
return Err("interval must be greater than 0 seconds".to_string());
}
Ok(Duration::from_secs(seconds))
}