use comfy_table::{Cell, Color};
use crate::cli::SortOrder;
use crate::core::{ProjectStats, Stats};
use crate::output::format::{
NumberFormat, compare_cost, cost_json_value, create_styled_table, format_compact, format_cost,
format_number, header_cell, right_cell, styled_cell,
};
use crate::pricing::{CurrencyConverter, PricingDb, attach_costs};
#[derive(Debug, Clone, Copy)]
pub(crate) struct ProjectTableOptions<'a> {
pub(crate) order: SortOrder,
pub(crate) use_color: bool,
pub(crate) compact: bool,
pub(crate) show_cost: bool,
pub(crate) source_label: &'a str,
pub(crate) number_format: NumberFormat,
pub(crate) currency: Option<&'a CurrencyConverter>,
}
#[allow(clippy::too_many_lines)]
pub(crate) fn print_project_table(
projects: &[ProjectStats],
pricing_db: &PricingDb,
options: ProjectTableOptions<'_>,
) {
let order = options.order;
let use_color = options.use_color;
let compact = options.compact;
let show_cost = options.show_cost;
let source_label = options.source_label;
let number_format = options.number_format;
let mut sorted_projects = attach_costs(projects, |p| &p.models, pricing_db);
match order {
SortOrder::Asc => sorted_projects.sort_by(|a, b| compare_cost(a.cost, b.cost)),
SortOrder::Desc => sorted_projects.sort_by(|a, b| compare_cost(b.cost, a.cost)),
}
let mut table = create_styled_table();
if compact {
let mut header = vec![
header_cell("Project", use_color),
header_cell("Sessions", use_color),
header_cell("Total", use_color),
];
if show_cost {
header.push(header_cell("Cost", use_color));
}
table.set_header(header);
} else {
let mut header = vec![
header_cell("Project", use_color),
header_cell("Sessions", use_color),
header_cell("Input", use_color),
header_cell("Output", use_color),
header_cell("Total", use_color),
];
if show_cost {
header.push(header_cell("Cost", use_color));
}
table.set_header(header);
}
let cost_color = if use_color { Some(Color::Green) } else { None };
let mut total_stats = Stats::default();
let mut total_cost = 0.0;
let mut total_sessions = 0usize;
for costed in &sorted_projects {
let project = costed.item;
let project_cost = costed.cost;
total_cost += project_cost;
total_stats.add(&project.stats);
total_sessions += project.session_count;
if compact {
let mut row = vec![
Cell::new(&project.project_name),
right_cell(
&format_number(project.session_count as i64, number_format),
None,
false,
),
right_cell(
&format_compact(project.stats.total_tokens(), number_format),
None,
false,
),
];
if show_cost {
row.push(right_cell(
&format_cost(project_cost, options.currency),
cost_color,
false,
));
}
table.add_row(row);
} else {
let mut row = vec![
Cell::new(&project.project_name),
right_cell(
&format_number(project.session_count as i64, number_format),
None,
false,
),
right_cell(
&format_number(project.stats.input_tokens, number_format),
None,
false,
),
right_cell(
&format_number(project.stats.output_tokens, number_format),
None,
false,
),
right_cell(
&format_number(project.stats.total_tokens(), number_format),
None,
false,
),
];
if show_cost {
row.push(right_cell(
&format_cost(project_cost, options.currency),
cost_color,
false,
));
}
table.add_row(row);
}
}
let cyan = if use_color { Some(Color::Cyan) } else { None };
let green = if use_color { Some(Color::Green) } else { None };
if compact {
let mut row = vec![
styled_cell("TOTAL", cyan, true),
right_cell(
&format_number(total_sessions as i64, number_format),
cyan,
true,
),
right_cell(
&format_compact(total_stats.total_tokens(), number_format),
cyan,
true,
),
];
if show_cost {
row.push(right_cell(
&format_cost(total_cost, options.currency),
green,
true,
));
}
table.add_row(row);
} else {
let mut row = vec![
styled_cell("TOTAL", cyan, true),
right_cell(
&format_number(total_sessions as i64, number_format),
cyan,
true,
),
right_cell(
&format_number(total_stats.input_tokens, number_format),
cyan,
true,
),
right_cell(
&format_number(total_stats.output_tokens, number_format),
cyan,
true,
),
right_cell(
&format_number(total_stats.total_tokens(), number_format),
cyan,
true,
),
];
if show_cost {
row.push(right_cell(
&format_cost(total_cost, options.currency),
green,
true,
));
}
table.add_row(row);
}
println!("\n {source_label} Project Usage\n");
println!("{table}");
println!(
"\n {} projects, {} sessions\n",
format_number(sorted_projects.len() as i64, number_format),
format_number(total_sessions as i64, number_format)
);
}
pub(crate) fn output_project_json(
projects: &[ProjectStats],
pricing_db: &PricingDb,
order: SortOrder,
show_cost: bool,
currency: Option<&CurrencyConverter>,
) -> String {
let mut sorted_projects = attach_costs(projects, |p| &p.models, pricing_db);
match order {
SortOrder::Asc => sorted_projects.sort_by(|a, b| compare_cost(a.cost, b.cost)),
SortOrder::Desc => sorted_projects.sort_by(|a, b| compare_cost(b.cost, a.cost)),
}
let output: Vec<serde_json::Value> = sorted_projects
.iter()
.map(|costed| {
let project = costed.item;
let project_cost = costed.cost;
let mut models: Vec<_> = project.models.keys().cloned().collect();
models.sort();
let mut obj = serde_json::json!({
"project": project.project_name,
"project_path": project.project_path,
"session_count": project.session_count,
"input_tokens": project.stats.input_tokens,
"output_tokens": project.stats.output_tokens,
"cache_creation_tokens": project.stats.cache_creation,
"cache_read_tokens": project.stats.cache_read,
"total_tokens": project.stats.total_tokens(),
"models": models,
});
if show_cost {
obj["cost"] = cost_json_value(project_cost, currency);
}
obj
})
.collect();
serde_json::to_string_pretty(&output).unwrap_or_else(|e| {
eprintln!("Failed to serialize JSON output: {e}");
"[]".to_string()
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn make_project(
name: &str,
path: &str,
sessions: usize,
input: i64,
output: i64,
) -> ProjectStats {
ProjectStats {
project_name: name.to_string(),
project_path: path.to_string(),
session_count: sessions,
stats: Stats {
input_tokens: input,
output_tokens: output,
count: 1,
..Default::default()
},
models: HashMap::from([(
"claude".to_string(),
Stats {
input_tokens: input,
output_tokens: output,
count: 1,
..Default::default()
},
)]),
}
}
#[test]
fn output_project_json_empty() {
let db = PricingDb::default();
let result = output_project_json(&[], &db, SortOrder::Desc, false, None);
assert_eq!(result.trim(), "[]");
}
#[test]
fn output_project_json_single_project() {
let db = PricingDb::default();
let projects = vec![make_project("myapp", "/path/myapp", 3, 1000, 500)];
let result = output_project_json(&projects, &db, SortOrder::Desc, false, None);
let parsed: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0]["project"], "myapp");
assert_eq!(parsed[0]["project_path"], "/path/myapp");
assert_eq!(parsed[0]["session_count"], 3);
assert_eq!(parsed[0]["input_tokens"], 1000);
assert_eq!(parsed[0]["output_tokens"], 500);
assert_eq!(parsed[0]["total_tokens"], 1500);
assert!(parsed[0].get("cost").is_none());
}
#[test]
fn output_project_json_with_cost() {
let db = PricingDb::default();
let projects = vec![make_project("app", "/app", 1, 100, 50)];
let result = output_project_json(&projects, &db, SortOrder::Desc, true, None);
let parsed: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
assert!(parsed[0].get("cost").is_some());
}
#[test]
fn output_project_json_models_sorted() {
let db = PricingDb::default();
let projects = vec![ProjectStats {
project_name: "app".to_string(),
project_path: "/app".to_string(),
session_count: 1,
stats: Stats {
input_tokens: 300,
count: 2,
..Default::default()
},
models: HashMap::from([
(
"gpt-4".to_string(),
Stats {
input_tokens: 100,
count: 1,
..Default::default()
},
),
(
"claude".to_string(),
Stats {
input_tokens: 200,
count: 1,
..Default::default()
},
),
]),
}];
let result = output_project_json(&projects, &db, SortOrder::Desc, false, None);
let parsed: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
let models = parsed[0]["models"].as_array().unwrap();
assert_eq!(models[0], "claude");
assert_eq!(models[1], "gpt-4");
}
#[test]
fn output_project_json_sort_order() {
let db = PricingDb::default();
let projects = vec![
make_project("small", "/small", 1, 10, 5),
make_project("big", "/big", 1, 1000, 500),
];
let desc = output_project_json(&projects, &db, SortOrder::Desc, false, None);
let parsed: Vec<serde_json::Value> = serde_json::from_str(&desc).unwrap();
assert_eq!(parsed.len(), 2);
let asc = output_project_json(&projects, &db, SortOrder::Asc, false, None);
let parsed_asc: Vec<serde_json::Value> = serde_json::from_str(&asc).unwrap();
assert_eq!(parsed_asc.len(), 2);
}
}