use crate::commands::usage::{fetch_pricing, parse_time_range};
use crate::error::{Error, Result};
use clap::Args;
use comfy_table::{presets::UTF8_FULL, ContentArrangement, Table};
use otelite_core::api::ProjectRollupResponse;
use otelite_storage::StorageBackend;
use std::sync::Arc;
#[derive(Debug, Args)]
pub struct ProjectsCommand {
#[arg(long, default_value = "24h", value_parser = crate::commands::usage::validate_since)]
pub since: String,
}
impl ProjectsCommand {
pub async fn execute(
&self,
storage: Arc<dyn StorageBackend>,
format: crate::config::OutputFormat,
) -> Result<()> {
let (start_time, end_time) = parse_time_range(&self.since)?;
let rollups = storage
.query_project_rollup(Some(start_time), Some(end_time))
.await
.map_err(|e| Error::ApiError(format!("Failed to query project rollup: {e}")))?;
let pricing_db = fetch_pricing().await;
let mut projects: Vec<_> = rollups.into_iter().map(|r| r.enrich(&pricing_db)).collect();
projects.sort_by(|a, b| {
b.cost_usd
.unwrap_or(0.0)
.partial_cmp(&a.cost_usd.unwrap_or(0.0))
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.project_id.cmp(&b.project_id))
});
let response = ProjectRollupResponse {
projects,
filters_applied: Vec::new(),
};
use crate::config::OutputFormat;
match format {
OutputFormat::Json | OutputFormat::JsonCompact => {
let json = if matches!(format, OutputFormat::JsonCompact) {
serde_json::to_string(&response)
} else {
serde_json::to_string_pretty(&response)
};
println!(
"{}",
json.map_err(|e| Error::ApiError(format!("JSON serialization failed: {e}")))?
);
},
OutputFormat::Pretty => display_projects(&response),
}
Ok(())
}
}
fn fmt_tokens(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.2}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1}k", n as f64 / 1_000.0)
} else {
n.to_string()
}
}
fn fmt_cost(c: Option<f64>, source: Option<&str>) -> String {
match c {
Some(v) if v > 0.0 => match source {
Some("actual") => format!("${:.2} (actual)", v),
Some("mixed") => format!("${:.2} (mixed)", v),
_ => format!("${:.2}", v),
},
Some(_) => "$0.00".to_string(),
None => "—".to_string(),
}
}
fn display_projects(response: &ProjectRollupResponse) {
if response.projects.is_empty() {
println!("\nNo project activity in the window.\n");
return;
}
println!("\nProjects (codex/claude have no project label → unattributed):\n");
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec!["project", "sessions", "cost", "tokens", "top model"]);
for p in &response.projects {
let top = p
.top_models
.first()
.map(|m| format!("{} ({})", m.model, fmt_tokens(m.tokens.total())))
.unwrap_or_else(|| "—".to_string());
table.add_row(vec![
p.project_id.clone(),
p.sessions.to_string(),
fmt_cost(p.cost_usd, p.cost_source.as_deref()),
fmt_tokens(p.tokens.total()),
top,
]);
}
println!("{table}");
println!();
}