mod cli;
use crate::cli::{Cli, Commands, ConfigAction, QuotaProvider, resolve_time_range_with_default};
use anyhow::{Context, Result, bail};
use clap::Parser;
use comfy_table::{Cell, CellAlignment, Color, ContentArrangement, Table, presets::UTF8_FULL};
use owo_colors::OwoColorize;
use serde::Serialize;
use std::io::{self, Write};
use std::sync::Arc;
#[cfg(feature = "mimalloc")]
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
use vct_core::get_version_info;
use vct_core::scan::build_scan_pool;
use vct_core::session::{ParseMode, parse_session_file_with_diagnostics};
use vct_core::usage::scan_usage_priced;
use vct_tui::display::usage::{
display_usage_interactive_with_pool, display_usage_table, display_usage_text,
};
fn main() -> Result<()> {
vct_core::utils::tune_system_allocator();
vct_core::logging::init();
vct_tui::display::common::tui::ensure_terminal_panic_hook();
vct_core::quota::enable_cli_version_detection();
if matches!(
std::env::args_os().nth(1).and_then(|arg| arg.into_string().ok()),
Some(arg) if arg == "--version" || arg == "-V"
) {
println!("{}", vct_core::VERSION);
return Ok(());
}
let result = run();
if let Err(error) = &result {
log::error!("command failed: {error:#}");
}
result
}
fn run() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Analysis {
file,
json,
text,
table,
daily,
weekly,
monthly,
all,
} => {
match file {
Some(file_path) => {
let complete_json = json || (!text && !table);
let mode = if complete_json {
ParseMode::Full
} else {
ParseMode::UsageOnly
};
let (analysis, diagnostics) =
parse_session_file_with_diagnostics(&file_path, mode)?;
if diagnostics.skipped_records() > 0 {
eprintln!(
"Warning: Skipped {} malformed or unsupported analyzer records while parsing {}. Successful results are still shown.",
diagnostics.skipped_records(),
file_path.display()
);
}
if complete_json {
write_pretty_json(&analysis)?;
} else if text {
let projected = vct_core::analysis::project_code_analysis(&analysis);
vct_tui::display::analysis::display_analysis_text(&projected);
} else {
let projected = vct_core::analysis::project_code_analysis(&analysis);
vct_tui::display::analysis::display_analysis_table(&projected);
}
}
None => {
let config = vct_core::config::load();
vct_core::logging::apply(&config.logging);
let time_range = resolve_time_range_with_default(
daily,
weekly,
monthly,
all,
config.general.default_time_range,
);
let scan_pool =
Arc::new(build_scan_pool(config.performance.resolved_scan_threads())?);
if json {
let dataset = scan_pool.install(|| {
vct_core::analysis::collect_analysis_sessions_with(
time_range,
config.providers,
ParseMode::Full,
)
})?;
report_analysis_collection(&dataset.diagnostics)?;
write_pretty_json(&dataset)?;
} else if text || table {
let aggregation = scan_pool.install(|| {
vct_core::analysis::aggregate_sessions_by_model_with_diagnostics(
time_range,
config.providers,
)
})?;
report_analysis_collection(&aggregation.diagnostics)?;
if text {
vct_tui::display::analysis::display_analysis_text(&aggregation.data);
} else {
vct_tui::display::analysis::display_analysis_table(&aggregation.data);
}
} else {
vct_tui::display::analysis::display_analysis_interactive_loading_with_pool(
time_range,
config.providers,
config.analysis.refresh_secs(),
scan_pool,
)?;
}
}
}
}
Commands::Usage {
json,
text,
table,
merge_providers,
daily,
weekly,
monthly,
all,
} => {
let config = vct_core::config::load();
vct_core::logging::apply(&config.logging);
let time_range = resolve_time_range_with_default(
daily,
weekly,
monthly,
all,
config.general.default_time_range,
);
let merge = merge_providers || config.usage.merge_models;
let scan_pool = Arc::new(build_scan_pool(config.performance.resolved_scan_threads())?);
if json {
let scan = scan_usage_priced(time_range, config.providers, &scan_pool)?;
if let Some(error) = &scan.pricing_error {
eprintln!(
"Warning: Failed to fetch pricing data: {error}. Costs will be unavailable."
);
}
report_usage_collection(&scan.collection.diagnostics)?;
let priced =
vct_core::usage::price_usage_data(&scan.collection.data, &scan.pricing);
write_pretty_json(&priced)?;
} else if text {
let scan = scan_usage_priced(time_range, config.providers, &scan_pool)?;
report_usage_collection(&scan.collection.diagnostics)?;
display_usage_text(&scan.collection.data, merge);
} else if table {
let scan = scan_usage_priced(time_range, config.providers, &scan_pool)?;
report_usage_collection(&scan.collection.diagnostics)?;
display_usage_table(&scan.collection.data, merge);
} else {
let refresh = config.usage.refresh_secs();
let quota_refresh = config.usage.quota_refresh_secs();
display_usage_interactive_with_pool(
time_range,
merge,
config.usage.quota.panels,
config.providers,
refresh,
quota_refresh,
scan_pool,
)?;
}
}
Commands::Version { json, text } => {
let version_info = get_version_info();
if json {
let json_output = serde_json::json!({
"Version": version_info.version,
"Rust Version": version_info.rust_version,
"Cargo Version": version_info.cargo_version
});
println!("{}", serde_json::to_string_pretty(&json_output)?);
} else if text {
println!("Version: {}", version_info.version);
println!("Rust Version: {}", version_info.rust_version);
println!("Cargo Version: {}", version_info.cargo_version);
} else {
println!("{}", "Vibe Coding Tracker".bright_cyan().bold());
println!();
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.add_row(vec![
Cell::new("Version")
.fg(Color::Green)
.set_alignment(CellAlignment::Left),
Cell::new(&version_info.version)
.fg(Color::White)
.set_alignment(CellAlignment::Left),
])
.add_row(vec![
Cell::new("Rust Version")
.fg(Color::Green)
.set_alignment(CellAlignment::Left),
Cell::new(&version_info.rust_version)
.fg(Color::White)
.set_alignment(CellAlignment::Left),
])
.add_row(vec![
Cell::new("Cargo Version")
.fg(Color::Green)
.set_alignment(CellAlignment::Left),
Cell::new(&version_info.cargo_version)
.fg(Color::White)
.set_alignment(CellAlignment::Left),
]);
println!("{table}");
}
}
Commands::Update { check, force } => {
if check {
vct_core::update::check_update()?;
} else {
vct_core::update::update_interactive(force)?;
}
}
Commands::Quota {
provider,
text,
table,
..
} => {
run_quota(provider, text, table)?;
}
Commands::Config { action } => {
run_config(action.unwrap_or(ConfigAction::Show))?;
}
}
Ok(())
}
fn run_config(action: ConfigAction) -> Result<()> {
match action {
ConfigAction::Schema => print!("{}", vct_core::config::schema_json()),
ConfigAction::Path => {
println!("{}", vct_core::utils::get_config_path()?.display());
}
ConfigAction::Show => {
let path = vct_core::utils::get_config_path()?;
let _ = vct_core::config::load();
let contents = std::fs::read_to_string(&path).unwrap_or_default();
println!("{}", "Vibe Coding Tracker settings".bright_cyan().bold());
println!("{}", path.display().dimmed());
println!();
print!("{}", contents);
}
ConfigAction::Edit => {
let path = vct_core::utils::get_config_path()?;
let _ = vct_core::config::load();
let editor = std::env::var("VISUAL")
.or_else(|_| std::env::var("EDITOR"))
.unwrap_or_else(|_| default_editor().to_string());
let mut parts = editor.split_whitespace();
let program = parts
.next()
.ok_or_else(|| anyhow::anyhow!("empty editor command"))?;
let status = std::process::Command::new(program)
.args(parts)
.arg(&path)
.status()
.with_context(|| format!("Failed to launch editor '{}'", editor))?;
if !status.success() {
anyhow::bail!("Editor '{}' exited with {}", editor, status);
}
}
ConfigAction::Migrate => {
use vct_core::config::MigrationStatus;
let path = vct_core::utils::get_config_path()?;
match vct_core::config::migrate_config_file(&path)? {
MigrationStatus::Created => {
println!("Created a new config at {}", path.display());
}
MigrationStatus::Migrated => {
println!("Migrated config to the latest format: {}", path.display());
}
MigrationStatus::AlreadyCurrent => {
println!("Config is already up to date: {}", path.display());
}
}
}
}
Ok(())
}
fn run_quota(provider: QuotaProvider, text: bool, table: bool) -> Result<()> {
use vct_core::quota::{
CLAUDE_LOGIN_HINT, CODEX_LOGIN_HINT, COPILOT_LOGIN_HINT, CURSOR_LOGIN_HINT,
GROK_LOGIN_HINT, claude, copilot, cursor, grok, http, wham,
};
use vct_tui::display::quota::{display_quota_table, display_quota_text, print_quota_json};
let client = http::build_client()?;
let (status, body) = match provider {
QuotaProvider::Claude => claude::fetch_claude_raw(&client),
QuotaProvider::Codex => wham::fetch_codex_raw(&client),
QuotaProvider::Copilot => copilot::fetch_copilot_raw(&client),
QuotaProvider::Cursor => cursor::fetch_cursor_raw(&client),
QuotaProvider::Grok => grok::fetch_grok_raw(&client),
}?;
if text {
display_quota_text(&body);
} else if table {
display_quota_table(&body);
} else {
print_quota_json(&body);
}
if !(200..300).contains(&status) {
let (name, hint) = match provider {
QuotaProvider::Claude => ("claude", CLAUDE_LOGIN_HINT),
QuotaProvider::Codex => ("codex", CODEX_LOGIN_HINT),
QuotaProvider::Copilot => ("copilot", COPILOT_LOGIN_HINT),
QuotaProvider::Cursor => ("cursor", CURSOR_LOGIN_HINT),
QuotaProvider::Grok => ("grok", GROK_LOGIN_HINT),
};
if status == 401 || status == 403 {
bail!("HTTP {status} from {name} ({hint})");
}
bail!("HTTP {status} from {name}");
}
Ok(())
}
fn write_pretty_json(value: &impl Serialize) -> Result<()> {
let stdout = io::stdout();
let mut writer = stdout.lock();
serde_json::to_writer_pretty(&mut writer, value)?;
writeln!(writer)?;
Ok(())
}
fn report_analysis_collection(diagnostics: &vct_core::analysis::ScanDiagnostics) -> Result<()> {
let Some(first) = diagnostics.failures.first() else {
return Ok(());
};
if diagnostics.all_failed() {
bail!(
"failed to parse all {} analysis sources; first failure: {} {}: {}",
diagnostics.candidates,
first.provider,
first.source.display(),
first.error
);
}
if diagnostics.partially_failed() {
eprintln!(
"Warning: Encountered {} analysis source failures while scanning {} candidates. Successful results are still shown. First failure: {} {}: {}",
diagnostics.failures.len(),
diagnostics.candidates,
first.provider,
first.source.display(),
first.error
);
}
Ok(())
}
fn report_usage_collection(diagnostics: &vct_core::usage::ScanDiagnostics) -> Result<()> {
let Some(first) = diagnostics.failures.first() else {
return Ok(());
};
if diagnostics.all_failed() {
bail!(
"failed to read all {} usage sources; first failure: {} {}: {}",
diagnostics.candidates,
first.provider,
first.source.display(),
first.error
);
}
if diagnostics.partially_failed() {
eprintln!(
"Warning: Encountered {} usage source failures while scanning {} candidates. Successful results are still shown. First failure: {} {}: {}",
diagnostics.failures.len(),
diagnostics.candidates,
first.provider,
first.source.display(),
first.error
);
}
Ok(())
}
fn default_editor() -> &'static str {
if cfg!(windows) { "notepad" } else { "vi" }
}