use ccstat_core::aggregation_types::{
DailyInstanceUsage, DailyUsage, MonthlyUsage, SessionBlock, SessionUsage, Totals, WeeklyUsage,
};
use ccstat_core::model_formatter::{format_model_list, format_model_name};
use prettytable::{Cell, Row, Table, format, row};
use serde_json::json;
pub trait OutputFormatter {
fn format_daily(&self, data: &[DailyUsage], totals: &Totals) -> String;
fn format_daily_by_instance(&self, data: &[DailyInstanceUsage], totals: &Totals) -> String;
fn format_sessions(&self, data: &[SessionUsage], totals: &Totals, tz: &chrono_tz::Tz)
-> String;
fn format_monthly(&self, data: &[MonthlyUsage], totals: &Totals) -> String;
fn format_weekly(&self, data: &[WeeklyUsage], totals: &Totals) -> String;
fn format_blocks(&self, data: &[SessionBlock], tz: &chrono_tz::Tz) -> String;
}
pub struct TableFormatter {
pub full_model_names: bool,
}
impl TableFormatter {
pub fn new(full_model_names: bool) -> Self {
Self { full_model_names }
}
fn format_number(n: u64) -> String {
let s = n.to_string();
let mut result = String::new();
for (count, ch) in s.chars().rev().enumerate() {
if count > 0 && count % 3 == 0 {
result.push(',');
}
result.push(ch);
}
result.chars().rev().collect()
}
fn format_currency(amount: f64) -> String {
format!("${amount:.2}")
}
fn format_totals_row(totals: &Totals) -> Row {
row![
b -> "TOTAL",
b -> Self::format_number(totals.tokens.input_tokens),
b -> Self::format_number(totals.tokens.output_tokens),
b -> Self::format_number(totals.tokens.cache_creation_tokens),
b -> Self::format_number(totals.tokens.cache_read_tokens),
b -> Self::format_number(totals.tokens.total()),
b -> Self::format_currency(totals.total_cost),
""
]
}
fn format_datetime_with_tz(dt: &chrono::DateTime<chrono::Utc>, tz: &chrono_tz::Tz) -> String {
dt.with_timezone(tz).format("%Y-%m-%d %H:%M %Z").to_string()
}
fn format_duration(duration: chrono::Duration) -> String {
if duration.num_seconds() <= 0 {
return "0m".to_string();
}
let total_minutes = duration.num_minutes();
if total_minutes < 60 {
format!("{}m", total_minutes)
} else {
let hours = total_minutes / 60;
let minutes = total_minutes % 60;
format!("{}h {}m", hours, minutes)
}
}
pub(crate) fn format_blocks_with_now(
&self,
data: &[SessionBlock],
tz: &chrono_tz::Tz,
now: chrono::DateTime<chrono::Utc>,
) -> String {
let mut table = Table::new();
table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
table.set_titles(row![
b -> "Block Start",
b -> "Status",
b -> "Sessions",
b -> "Input",
b -> "Output",
b -> "Total Tokens",
b -> "Cost",
b -> "Time Remaining"
]);
for block in data {
let status = if block.is_gap {
"GAP"
} else if block.is_active {
"ACTIVE"
} else {
"Complete"
};
let time_remaining = if block.is_active {
let remaining = block.end_time - now;
if remaining.num_seconds() > 0 {
format!(
"{}h {}m",
remaining.num_hours(),
remaining.num_minutes() % 60
)
} else {
"Expired".to_string()
}
} else {
"-".to_string()
};
let formatted_start = if block.is_gap {
let gap_duration = block.end_time - block.start_time;
format!(
"{} - {} ({} gap)",
Self::format_datetime_with_tz(&block.start_time, tz),
Self::format_datetime_with_tz(&block.end_time, tz),
Self::format_duration(gap_duration)
)
} else if block.is_active {
let elapsed = now - block.start_time;
format!(
"{} ({} elapsed)",
Self::format_datetime_with_tz(&block.start_time, tz),
Self::format_duration(elapsed)
)
} else {
if let (Some(start), Some(end)) = (block.actual_start_time, block.actual_end_time) {
let activity_duration = end - start;
format!(
"{} ({})",
Self::format_datetime_with_tz(&block.start_time, tz),
Self::format_duration(activity_duration)
)
} else {
Self::format_datetime_with_tz(&block.start_time, tz)
}
};
table.add_row(row![
formatted_start,
status,
c -> block.sessions.len(),
r -> Self::format_number(block.tokens.input_tokens),
r -> Self::format_number(block.tokens.output_tokens),
r -> Self::format_number(block.tokens.total()),
r -> Self::format_currency(block.total_cost),
time_remaining
]);
}
table.to_string()
}
}
impl OutputFormatter for TableFormatter {
fn format_daily(&self, data: &[DailyUsage], totals: &Totals) -> String {
let mut output = String::new();
let is_verbose = data.iter().any(|d| d.entries.is_some());
if is_verbose {
for daily in data {
output.push_str(&format!("\n=== {} ===\n", daily.date.format("%Y-%m-%d")));
if let Some(ref entries) = daily.entries {
let mut table = Table::new();
table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
table.set_titles(row![
b -> "Time",
b -> "Session ID",
b -> "Model",
b -> "Input",
b -> "Output",
b -> "Cache Create",
b -> "Cache Read",
b -> "Total",
b -> "Cost"
]);
for entry in entries {
table.add_row(row![
entry.timestamp.format("%H:%M:%S"),
entry.session_id,
format_model_name(&entry.model, self.full_model_names),
r -> Self::format_number(entry.tokens.input_tokens),
r -> Self::format_number(entry.tokens.output_tokens),
r -> Self::format_number(entry.tokens.cache_creation_tokens),
r -> Self::format_number(entry.tokens.cache_read_tokens),
r -> Self::format_number(entry.tokens.total()),
r -> Self::format_currency(entry.cost)
]);
}
output.push_str(&table.to_string());
}
output.push_str(&format!(
"\nDay Total: {} tokens, {}\n",
Self::format_number(daily.tokens.total()),
Self::format_currency(daily.total_cost)
));
}
output.push_str("\n=== OVERALL SUMMARY ===\n");
}
let mut table = Table::new();
table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
table.set_titles(row![
b -> "Date",
b -> "Input",
b -> "Output",
b -> "Cache Create",
b -> "Cache Read",
b -> "Total",
b -> "Cost",
b -> "Models"
]);
for entry in data {
table.add_row(row![
entry.date.format("%Y-%m-%d"),
r -> Self::format_number(entry.tokens.input_tokens),
r -> Self::format_number(entry.tokens.output_tokens),
r -> Self::format_number(entry.tokens.cache_creation_tokens),
r -> Self::format_number(entry.tokens.cache_read_tokens),
r -> Self::format_number(entry.tokens.total()),
r -> Self::format_currency(entry.total_cost),
format_model_list(&entry.models_used, self.full_model_names, ", ")
]);
}
table.add_row(Row::new(vec![Cell::new(""); 8]));
table.add_row(Self::format_totals_row(totals));
output.push_str(&table.to_string());
output
}
fn format_daily_by_instance(&self, data: &[DailyInstanceUsage], totals: &Totals) -> String {
let mut table = Table::new();
table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
table.set_titles(row![
b -> "Date",
b -> "Instance",
b -> "Input",
b -> "Output",
b -> "Cache Create",
b -> "Cache Read",
b -> "Total Tokens",
b -> "Cost",
b -> "Models"
]);
for entry in data {
table.add_row(row![
entry.date.format("%Y-%m-%d"),
entry.instance_id,
r -> Self::format_number(entry.tokens.input_tokens),
r -> Self::format_number(entry.tokens.output_tokens),
r -> Self::format_number(entry.tokens.cache_creation_tokens),
r -> Self::format_number(entry.tokens.cache_read_tokens),
r -> Self::format_number(entry.tokens.total()),
r -> Self::format_currency(entry.total_cost),
format_model_list(&entry.models_used, self.full_model_names, ", ")
]);
}
table.add_row(Row::new(vec![Cell::new(""); 9]));
table.add_row(row![
b -> "TOTAL",
"",
b -> Self::format_number(totals.tokens.input_tokens),
b -> Self::format_number(totals.tokens.output_tokens),
b -> Self::format_number(totals.tokens.cache_creation_tokens),
b -> Self::format_number(totals.tokens.cache_read_tokens),
b -> Self::format_number(totals.tokens.total()),
b -> Self::format_currency(totals.total_cost),
""
]);
table.to_string()
}
fn format_sessions(
&self,
data: &[SessionUsage],
totals: &Totals,
tz: &chrono_tz::Tz,
) -> String {
let mut table = Table::new();
table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
table.set_titles(row![
b -> "Session ID",
b -> "Start Time",
b -> "Duration",
b -> "Input",
b -> "Output",
b -> "Total Tokens",
b -> "Cost",
b -> "Model"
]);
for session in data {
let duration = session.end_time - session.start_time;
let duration_str =
format!("{}h {}m", duration.num_hours(), duration.num_minutes() % 60);
let formatted_start = Self::format_datetime_with_tz(&session.start_time, tz);
table.add_row(row![
session.session_id.as_str(),
formatted_start,
duration_str,
r -> Self::format_number(session.tokens.input_tokens),
r -> Self::format_number(session.tokens.output_tokens),
r -> Self::format_number(session.tokens.total()),
r -> Self::format_currency(session.total_cost),
format_model_name(session.model.as_str(), self.full_model_names)
]);
}
table.add_row(Row::new(vec![Cell::new(""); 8]));
table.add_row(row![
b -> "TOTAL",
"",
"",
b -> Self::format_number(totals.tokens.input_tokens),
b -> Self::format_number(totals.tokens.output_tokens),
b -> Self::format_number(totals.tokens.total()),
b -> Self::format_currency(totals.total_cost),
""
]);
table.to_string()
}
fn format_monthly(&self, data: &[MonthlyUsage], totals: &Totals) -> String {
let mut table = Table::new();
table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
table.set_titles(row![
b -> "Month",
b -> "Input",
b -> "Output",
b -> "Cache Create",
b -> "Cache Read",
b -> "Total",
b -> "Cost",
b -> "Active Days"
]);
for entry in data {
table.add_row(row![
entry.month,
r -> Self::format_number(entry.tokens.input_tokens),
r -> Self::format_number(entry.tokens.output_tokens),
r -> Self::format_number(entry.tokens.cache_creation_tokens),
r -> Self::format_number(entry.tokens.cache_read_tokens),
r -> Self::format_number(entry.tokens.total()),
r -> Self::format_currency(entry.total_cost),
c -> entry.active_days
]);
}
table.add_row(Row::new(vec![Cell::new(""); 8]));
table.add_row(Self::format_totals_row(totals));
table.to_string()
}
fn format_weekly(&self, data: &[WeeklyUsage], totals: &Totals) -> String {
let mut table = Table::new();
table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
table.set_titles(row![
b -> "Week",
b -> "Input",
b -> "Output",
b -> "Cache Create",
b -> "Cache Read",
b -> "Total",
b -> "Cost",
b -> "Active Days"
]);
for entry in data {
table.add_row(row![
entry.week,
r -> Self::format_number(entry.tokens.input_tokens),
r -> Self::format_number(entry.tokens.output_tokens),
r -> Self::format_number(entry.tokens.cache_creation_tokens),
r -> Self::format_number(entry.tokens.cache_read_tokens),
r -> Self::format_number(entry.tokens.total()),
r -> Self::format_currency(entry.total_cost),
c -> entry.active_days
]);
}
table.add_row(Row::new(vec![Cell::new(""); 8]));
table.add_row(Self::format_totals_row(totals));
table.to_string()
}
fn format_blocks(&self, data: &[SessionBlock], tz: &chrono_tz::Tz) -> String {
self.format_blocks_with_now(data, tz, chrono::Utc::now())
}
}
pub struct JsonFormatter;
impl OutputFormatter for JsonFormatter {
fn format_daily(&self, data: &[DailyUsage], totals: &Totals) -> String {
let output = json!({
"daily": data.iter().map(|d| {
let mut day_json = json!({
"date": d.date.format("%Y-%m-%d"),
"tokens": {
"input_tokens": d.tokens.input_tokens,
"output_tokens": d.tokens.output_tokens,
"cache_creation_tokens": d.tokens.cache_creation_tokens,
"cache_read_tokens": d.tokens.cache_read_tokens,
"total": d.tokens.total(),
},
"total_cost": d.total_cost,
"models_used": d.models_used,
});
if let Some(ref entries) = d.entries {
day_json["entries"] = json!(entries.iter().map(|e| json!({
"timestamp": e.timestamp.to_rfc3339(),
"session_id": e.session_id,
"model": e.model,
"tokens": {
"input_tokens": e.tokens.input_tokens,
"output_tokens": e.tokens.output_tokens,
"cache_creation_tokens": e.tokens.cache_creation_tokens,
"cache_read_tokens": e.tokens.cache_read_tokens,
"total": e.tokens.total(),
},
"cost": e.cost,
})).collect::<Vec<_>>());
}
day_json
}).collect::<Vec<_>>(),
"totals": {
"tokens": {
"input_tokens": totals.tokens.input_tokens,
"output_tokens": totals.tokens.output_tokens,
"cache_creation_tokens": totals.tokens.cache_creation_tokens,
"cache_read_tokens": totals.tokens.cache_read_tokens,
"total": totals.tokens.total(),
},
"total_cost": totals.total_cost,
}
});
serde_json::to_string_pretty(&output).unwrap()
}
fn format_daily_by_instance(&self, data: &[DailyInstanceUsage], totals: &Totals) -> String {
let output = json!({
"daily_by_instance": data.iter().map(|d| json!({
"date": d.date.format("%Y-%m-%d"),
"instance_id": d.instance_id,
"tokens": {
"input_tokens": d.tokens.input_tokens,
"output_tokens": d.tokens.output_tokens,
"cache_creation_tokens": d.tokens.cache_creation_tokens,
"cache_read_tokens": d.tokens.cache_read_tokens,
"total": d.tokens.total(),
},
"total_cost": d.total_cost,
"models_used": d.models_used,
})).collect::<Vec<_>>(),
"totals": {
"tokens": {
"input_tokens": totals.tokens.input_tokens,
"output_tokens": totals.tokens.output_tokens,
"cache_creation_tokens": totals.tokens.cache_creation_tokens,
"cache_read_tokens": totals.tokens.cache_read_tokens,
"total": totals.tokens.total(),
},
"total_cost": totals.total_cost,
}
});
serde_json::to_string_pretty(&output).unwrap()
}
fn format_sessions(
&self,
data: &[SessionUsage],
totals: &Totals,
_tz: &chrono_tz::Tz,
) -> String {
let output = json!({
"sessions": data.iter().map(|s| json!({
"session_id": s.session_id.as_str(),
"start_time": s.start_time.to_rfc3339(),
"end_time": s.end_time.to_rfc3339(),
"duration_seconds": (s.end_time - s.start_time).num_seconds(),
"tokens": {
"input_tokens": s.tokens.input_tokens,
"output_tokens": s.tokens.output_tokens,
"cache_creation_tokens": s.tokens.cache_creation_tokens,
"cache_read_tokens": s.tokens.cache_read_tokens,
"total": s.tokens.total(),
},
"total_cost": s.total_cost,
"model": s.model.as_str(),
})).collect::<Vec<_>>(),
"totals": {
"tokens": {
"input_tokens": totals.tokens.input_tokens,
"output_tokens": totals.tokens.output_tokens,
"cache_creation_tokens": totals.tokens.cache_creation_tokens,
"cache_read_tokens": totals.tokens.cache_read_tokens,
"total": totals.tokens.total(),
},
"total_cost": totals.total_cost,
}
});
serde_json::to_string_pretty(&output).unwrap()
}
fn format_monthly(&self, data: &[MonthlyUsage], totals: &Totals) -> String {
let output = json!({
"monthly": data.iter().map(|m| json!({
"month": m.month,
"tokens": {
"input_tokens": m.tokens.input_tokens,
"output_tokens": m.tokens.output_tokens,
"cache_creation_tokens": m.tokens.cache_creation_tokens,
"cache_read_tokens": m.tokens.cache_read_tokens,
"total": m.tokens.total(),
},
"total_cost": m.total_cost,
"active_days": m.active_days,
})).collect::<Vec<_>>(),
"totals": {
"tokens": {
"input_tokens": totals.tokens.input_tokens,
"output_tokens": totals.tokens.output_tokens,
"cache_creation_tokens": totals.tokens.cache_creation_tokens,
"cache_read_tokens": totals.tokens.cache_read_tokens,
"total": totals.tokens.total(),
},
"total_cost": totals.total_cost,
}
});
serde_json::to_string_pretty(&output).unwrap()
}
fn format_weekly(&self, data: &[WeeklyUsage], totals: &Totals) -> String {
let output = json!({
"weekly": data.iter().map(|w| json!({
"week": w.week,
"tokens": {
"input_tokens": w.tokens.input_tokens,
"output_tokens": w.tokens.output_tokens,
"cache_creation_tokens": w.tokens.cache_creation_tokens,
"cache_read_tokens": w.tokens.cache_read_tokens,
"total": w.tokens.total(),
},
"total_cost": w.total_cost,
"active_days": w.active_days,
})).collect::<Vec<_>>(),
"totals": {
"tokens": {
"input_tokens": totals.tokens.input_tokens,
"output_tokens": totals.tokens.output_tokens,
"cache_creation_tokens": totals.tokens.cache_creation_tokens,
"cache_read_tokens": totals.tokens.cache_read_tokens,
"total": totals.tokens.total(),
},
"total_cost": totals.total_cost,
}
});
serde_json::to_string_pretty(&output).unwrap()
}
fn format_blocks(&self, data: &[SessionBlock], _tz: &chrono_tz::Tz) -> String {
let output = json!({
"blocks": data.iter().map(|b| json!({
"start_time": b.start_time.to_rfc3339(),
"end_time": b.end_time.to_rfc3339(),
"is_active": b.is_active,
"is_gap": b.is_gap,
"session_count": b.sessions.len(),
"tokens": {
"input_tokens": b.tokens.input_tokens,
"output_tokens": b.tokens.output_tokens,
"cache_creation_tokens": b.tokens.cache_creation_tokens,
"cache_read_tokens": b.tokens.cache_read_tokens,
"total": b.tokens.total(),
},
"total_cost": b.total_cost,
"sessions": b.sessions.iter().map(|s| s.session_id.as_str()).collect::<Vec<_>>(),
"models_used": &b.models_used,
})).collect::<Vec<_>>()
});
serde_json::to_string_pretty(&output).unwrap()
}
}
pub fn get_formatter(json: bool, full_model_names: bool) -> Box<dyn OutputFormatter> {
if json {
Box::new(JsonFormatter)
} else {
Box::new(TableFormatter::new(full_model_names))
}
}
#[cfg(test)]
mod tests {
use super::*;
use ccstat_core::aggregation_types::{MonthlyUsage, SessionBlock, SessionUsage};
use ccstat_core::types::{DailyDate, ModelName, SessionId, TokenCounts};
use chrono::{NaiveDate, TimeZone, Utc};
#[test]
fn test_number_formatting() {
assert_eq!(TableFormatter::format_number(1234567), "1,234,567");
assert_eq!(TableFormatter::format_number(999), "999");
assert_eq!(TableFormatter::format_number(0), "0");
assert_eq!(TableFormatter::format_number(1000000000), "1,000,000,000");
assert_eq!(TableFormatter::format_number(42), "42");
}
#[test]
fn test_currency_formatting() {
assert_eq!(TableFormatter::format_currency(12.345), "$12.35");
assert_eq!(TableFormatter::format_currency(0.0), "$0.00");
assert_eq!(TableFormatter::format_currency(1000.0), "$1000.00");
assert_eq!(TableFormatter::format_currency(0.001), "$0.00");
assert_eq!(TableFormatter::format_currency(999999.99), "$999999.99");
}
#[test]
fn test_get_formatter() {
let json_formatter = get_formatter(true, false);
assert!(
json_formatter
.format_daily(&[], &Totals::default())
.contains("\"daily\"")
);
let table_formatter = get_formatter(false, true);
let daily_data = vec![DailyUsage {
date: DailyDate::new(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
tokens: TokenCounts::new(100, 50, 10, 5),
total_cost: 1.25,
models_used: vec!["claude-3-opus".to_string()],
entries: None,
}];
let totals = Totals::from_daily(&daily_data);
let output = table_formatter.format_daily(&daily_data, &totals);
assert!(output.contains("2024-01-01"));
}
#[test]
fn test_table_formatter_daily() {
let formatter = TableFormatter::new(false);
let empty_totals = Totals::default();
let empty_output = formatter.format_daily(&[], &empty_totals);
assert!(empty_output.contains("TOTAL"));
let daily_data = vec![DailyUsage {
date: DailyDate::new(NaiveDate::from_ymd_opt(2024, 3, 15).unwrap()),
tokens: TokenCounts::new(1000, 500, 100, 50),
total_cost: 2.50,
models_used: vec!["claude-3-opus".to_string(), "claude-3-sonnet".to_string()],
entries: None,
}];
let totals = Totals::from_daily(&daily_data);
let output = formatter.format_daily(&daily_data, &totals);
assert!(output.contains("2024-03-15"));
assert!(output.contains("1,000"));
assert!(output.contains("500"));
assert!(output.contains("$2.50"));
assert!(output.contains("TOTAL"));
let multi_day_data = vec![
DailyUsage {
date: DailyDate::new(NaiveDate::from_ymd_opt(2024, 3, 15).unwrap()),
tokens: TokenCounts::new(1000, 500, 0, 0),
total_cost: 1.50,
models_used: vec!["claude-3-opus".to_string()],
entries: None,
},
DailyUsage {
date: DailyDate::new(NaiveDate::from_ymd_opt(2024, 3, 16).unwrap()),
tokens: TokenCounts::new(2000, 1000, 200, 100),
total_cost: 3.00,
models_used: vec!["claude-3-sonnet".to_string()],
entries: None,
},
];
let multi_totals = Totals::from_daily(&multi_day_data);
let multi_output = formatter.format_daily(&multi_day_data, &multi_totals);
assert!(multi_output.contains("2024-03-15"));
assert!(multi_output.contains("2024-03-16"));
assert!(multi_output.contains("3,000")); }
#[test]
fn test_table_formatter_daily_verbose() {
let formatter = TableFormatter::new(false);
let timestamp = Utc.with_ymd_and_hms(2024, 3, 15, 10, 30, 0).unwrap();
let verbose_entry = ccstat_core::aggregation_types::VerboseEntry {
timestamp,
session_id: "test-session".to_string(),
model: "claude-3-opus".to_string(),
tokens: TokenCounts::new(100, 50, 10, 5),
cost: 0.25,
};
let daily_data = vec![DailyUsage {
date: DailyDate::new(NaiveDate::from_ymd_opt(2024, 3, 15).unwrap()),
tokens: TokenCounts::new(100, 50, 10, 5),
total_cost: 0.25,
models_used: vec!["claude-3-opus".to_string()],
entries: Some(vec![verbose_entry]),
}];
let totals = Totals::from_daily(&daily_data);
let output = formatter.format_daily(&daily_data, &totals);
assert!(output.contains("=== 2024-03-15 ==="));
assert!(output.contains("test-session"));
assert!(output.contains("10:30:00"));
assert!(output.contains("Day Total"));
assert!(output.contains("OVERALL SUMMARY"));
}
#[test]
fn test_table_formatter_daily_by_instance() {
let formatter = TableFormatter::new(false);
let instance_data = vec![
DailyInstanceUsage {
date: DailyDate::new(NaiveDate::from_ymd_opt(2024, 3, 15).unwrap()),
instance_id: "instance-1".to_string(),
tokens: TokenCounts::new(1000, 500, 0, 0),
total_cost: 1.50,
models_used: vec!["claude-3-opus".to_string()],
},
DailyInstanceUsage {
date: DailyDate::new(NaiveDate::from_ymd_opt(2024, 3, 15).unwrap()),
instance_id: "instance-2".to_string(),
tokens: TokenCounts::new(2000, 1000, 100, 50),
total_cost: 3.00,
models_used: vec!["claude-3-sonnet".to_string()],
},
];
let totals = Totals::from_daily_instances(&instance_data);
let output = formatter.format_daily_by_instance(&instance_data, &totals);
assert!(output.contains("instance-1"));
assert!(output.contains("instance-2"));
assert!(output.contains("2024-03-15"));
assert!(output.contains("3,000")); assert!(output.contains("$4.50")); }
#[test]
fn test_table_formatter_sessions() {
let formatter = TableFormatter::new(false);
let tz = chrono_tz::UTC;
let start_time = Utc.with_ymd_and_hms(2024, 3, 15, 10, 0, 0).unwrap();
let end_time = Utc.with_ymd_and_hms(2024, 3, 15, 12, 30, 0).unwrap();
let sessions = vec![SessionUsage {
session_id: SessionId::new("session-123"),
start_time,
end_time,
tokens: TokenCounts::new(5000, 2500, 500, 250),
total_cost: 7.50,
model: ModelName::new("claude-3-opus"),
}];
let totals = Totals::from_sessions(&sessions);
let output = formatter.format_sessions(&sessions, &totals, &tz);
assert!(output.contains("session-123"));
assert!(output.contains("2h 30m")); assert!(output.contains("5,000")); assert!(output.contains("$7.50"));
assert!(output.contains("Opus")); }
#[test]
fn test_table_formatter_monthly() {
let formatter = TableFormatter::new(true);
let monthly_data = vec![
MonthlyUsage {
month: "2024-01".to_string(),
tokens: TokenCounts::new(100000, 50000, 10000, 5000),
total_cost: 150.00,
active_days: 15,
},
MonthlyUsage {
month: "2024-02".to_string(),
tokens: TokenCounts::new(200000, 100000, 20000, 10000),
total_cost: 300.00,
active_days: 20,
},
];
let totals = Totals::from_monthly(&monthly_data);
let output = formatter.format_monthly(&monthly_data, &totals);
assert!(output.contains("2024-01"));
assert!(output.contains("2024-02"));
assert!(output.contains("100,000"));
assert!(output.contains("200,000"));
assert!(output.contains("$450.00")); assert!(output.contains("15")); assert!(output.contains("20"));
}
#[test]
fn test_table_formatter_blocks() {
let formatter = TableFormatter::new(false);
let tz = chrono_tz::US::Eastern;
let now = Utc.with_ymd_and_hms(2024, 7, 15, 12, 0, 0).unwrap();
let session1 = SessionUsage {
session_id: SessionId::new("session-1"),
start_time: now - chrono::Duration::hours(2),
end_time: now - chrono::Duration::hours(1),
tokens: TokenCounts::new(1500, 750, 150, 75),
total_cost: 2.25,
model: ModelName::new("claude-3-opus"),
};
let session2 = SessionUsage {
session_id: SessionId::new("session-2"),
start_time: now - chrono::Duration::hours(1),
end_time: now,
tokens: TokenCounts::new(1500, 750, 150, 75),
total_cost: 2.25,
model: ModelName::new("claude-3-sonnet"),
};
let session3 = SessionUsage {
session_id: SessionId::new("session-3"),
start_time: now - chrono::Duration::hours(10),
end_time: now - chrono::Duration::hours(9),
tokens: TokenCounts::new(1000, 500, 100, 50),
total_cost: 1.50,
model: ModelName::new("claude-3-haiku"),
};
let active_block = SessionBlock {
start_time: now - chrono::Duration::hours(2),
end_time: now + chrono::Duration::hours(3),
actual_start_time: Some(now - chrono::Duration::hours(2)),
actual_end_time: Some(now - chrono::Duration::minutes(30)),
is_active: true,
is_gap: false,
sessions: vec![session1, session2],
tokens: TokenCounts::new(3000, 1500, 300, 150),
total_cost: 4.50,
models_used: vec!["claude-3-opus".to_string(), "claude-3-sonnet".to_string()],
projects_used: vec![],
warning: None,
};
let expired_block = SessionBlock {
start_time: now - chrono::Duration::hours(10),
end_time: now - chrono::Duration::hours(5),
actual_start_time: Some(now - chrono::Duration::hours(10)),
actual_end_time: Some(now - chrono::Duration::hours(5) - chrono::Duration::minutes(30)),
is_active: false,
is_gap: false,
sessions: vec![session3],
tokens: TokenCounts::new(1000, 500, 100, 50),
total_cost: 1.50,
models_used: vec!["claude-3-haiku".to_string()],
projects_used: vec![],
warning: None,
};
let gap_block = SessionBlock {
start_time: now - chrono::Duration::hours(20),
end_time: now - chrono::Duration::hours(15),
actual_start_time: None,
actual_end_time: None,
is_active: false,
is_gap: true,
sessions: vec![],
tokens: TokenCounts::new(0, 0, 0, 0),
total_cost: 0.0,
models_used: vec![],
projects_used: vec![],
warning: None,
};
let blocks = vec![gap_block, active_block, expired_block];
let output = formatter.format_blocks_with_now(&blocks, &tz, now);
assert!(output.contains("GAP"));
assert!(output.contains("ACTIVE"));
assert!(output.contains("Complete"));
assert!(output.contains("3,000"));
assert!(output.contains("1,000"));
assert!(output.contains("$4.50"));
assert!(output.contains("$1.50"));
assert!(output.contains("3h 0m")); }
#[test]
fn test_json_formatter_daily() {
let formatter = JsonFormatter;
let daily_data = vec![DailyUsage {
date: DailyDate::new(NaiveDate::from_ymd_opt(2024, 3, 15).unwrap()),
tokens: TokenCounts::new(1000, 500, 100, 50),
total_cost: 2.50,
models_used: vec!["claude-3-opus".to_string()],
entries: None,
}];
let totals = Totals::from_daily(&daily_data);
let output = formatter.format_daily(&daily_data, &totals);
let json: serde_json::Value =
serde_json::from_str(&output).expect("Failed to parse JSON output");
assert_eq!(json["daily"][0]["date"], "2024-03-15");
assert_eq!(json["daily"][0]["tokens"]["input_tokens"], 1000);
assert_eq!(json["daily"][0]["total_cost"], 2.5);
assert_eq!(json["totals"]["total_cost"], 2.5);
}
#[test]
fn test_json_formatter_daily_by_instance() {
let formatter = JsonFormatter;
let instance_data = vec![DailyInstanceUsage {
date: DailyDate::new(NaiveDate::from_ymd_opt(2024, 3, 15).unwrap()),
instance_id: "instance-1".to_string(),
tokens: TokenCounts::new(1000, 500, 0, 0),
total_cost: 1.50,
models_used: vec!["claude-3-opus".to_string()],
}];
let totals = Totals::from_daily_instances(&instance_data);
let output = formatter.format_daily_by_instance(&instance_data, &totals);
let json: serde_json::Value =
serde_json::from_str(&output).expect("Failed to parse JSON output");
assert_eq!(json["daily_by_instance"][0]["instance_id"], "instance-1");
assert_eq!(json["daily_by_instance"][0]["tokens"]["input_tokens"], 1000);
}
#[test]
fn test_json_formatter_sessions() {
let formatter = JsonFormatter;
let tz = chrono_tz::UTC;
let start_time = Utc.with_ymd_and_hms(2024, 3, 15, 10, 0, 0).unwrap();
let end_time = Utc.with_ymd_and_hms(2024, 3, 15, 12, 30, 0).unwrap();
let sessions = vec![SessionUsage {
session_id: SessionId::new("session-123"),
start_time,
end_time,
tokens: TokenCounts::new(5000, 2500, 0, 0),
total_cost: 7.50,
model: ModelName::new("claude-3-opus"),
}];
let totals = Totals::from_sessions(&sessions);
let output = formatter.format_sessions(&sessions, &totals, &tz);
let json: serde_json::Value =
serde_json::from_str(&output).expect("Failed to parse JSON output");
assert_eq!(json["sessions"][0]["session_id"], "session-123");
assert_eq!(json["sessions"][0]["duration_seconds"], 9000); assert_eq!(json["sessions"][0]["total_cost"], 7.5);
}
#[test]
fn test_json_formatter_monthly() {
let formatter = JsonFormatter;
let monthly_data = vec![MonthlyUsage {
month: "2024-01".to_string(),
tokens: TokenCounts::new(100000, 50000, 0, 0),
total_cost: 150.00,
active_days: 15,
}];
let totals = Totals::from_monthly(&monthly_data);
let output = formatter.format_monthly(&monthly_data, &totals);
let json: serde_json::Value =
serde_json::from_str(&output).expect("Failed to parse JSON output");
assert_eq!(json["monthly"][0]["month"], "2024-01");
assert_eq!(json["monthly"][0]["active_days"], 15);
assert_eq!(json["totals"]["total_cost"], 150.0);
}
#[test]
fn test_json_formatter_blocks() {
let formatter = JsonFormatter;
let tz = chrono_tz::UTC;
let now = Utc.with_ymd_and_hms(2024, 7, 15, 12, 0, 0).unwrap();
let session = SessionUsage {
session_id: SessionId::new("session-1"),
start_time: now - chrono::Duration::hours(2),
end_time: now - chrono::Duration::hours(1),
tokens: TokenCounts::new(3000, 1500, 0, 0),
total_cost: 4.50,
model: ModelName::new("claude-3-opus"),
};
let block = SessionBlock {
start_time: now - chrono::Duration::hours(2),
end_time: now + chrono::Duration::hours(3),
actual_start_time: Some(now - chrono::Duration::hours(2)),
actual_end_time: Some(now - chrono::Duration::hours(1)),
is_active: true,
is_gap: false,
sessions: vec![session],
tokens: TokenCounts::new(3000, 1500, 0, 0),
total_cost: 4.50,
models_used: vec!["claude-3-opus".to_string()],
projects_used: vec![],
warning: None,
};
let blocks = vec![block];
let output = formatter.format_blocks(&blocks, &tz);
let json: serde_json::Value =
serde_json::from_str(&output).expect("Failed to parse JSON output");
assert_eq!(json["blocks"][0]["is_active"], true);
assert_eq!(json["blocks"][0]["session_count"], 1);
assert_eq!(json["blocks"][0]["total_cost"], 4.5);
}
#[test]
fn test_datetime_formatting_with_timezone() {
let utc_time = Utc.with_ymd_and_hms(2024, 3, 15, 15, 30, 0).unwrap();
let utc_formatted = TableFormatter::format_datetime_with_tz(&utc_time, &chrono_tz::UTC);
assert!(utc_formatted.contains("2024-03-15 15:30"));
assert!(utc_formatted.contains("UTC"));
let est_formatted =
TableFormatter::format_datetime_with_tz(&utc_time, &chrono_tz::US::Eastern);
assert!(est_formatted.contains("2024-03-15 11:30"));
assert!(est_formatted.contains("EDT"));
}
#[test]
fn test_edge_cases() {
let formatter = TableFormatter::new(false);
let zero_data = vec![DailyUsage {
date: DailyDate::new(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
tokens: TokenCounts::new(0, 0, 0, 0),
total_cost: 0.0,
models_used: vec![],
entries: None,
}];
let zero_totals = Totals::from_daily(&zero_data);
let zero_output = formatter.format_daily(&zero_data, &zero_totals);
assert!(zero_output.contains("$0.00"));
let large_data = vec![DailyUsage {
date: DailyDate::new(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
tokens: TokenCounts::new(999999999, 888888888, 777777777, 666666666),
total_cost: 9999999.99,
models_used: vec!["model".to_string()],
entries: None,
}];
let large_totals = Totals::from_daily(&large_data);
let large_output = formatter.format_daily(&large_data, &large_totals);
assert!(large_output.contains("999,999,999"));
}
}