use std::collections::HashMap;
use chrono::{Datelike, Local, Timelike};
use crate::data::models::{GlobalDataQuality, SessionData};
use crate::pricing::calculator::{PriceSource, PricingCalculator};
use super::{
AggregatedTokens, CacheSavings, CostByCategory, OverviewResult, PricingWarning, SessionSummary,
SubscriptionValue,
};
#[derive(Default)]
struct FallbackAccum {
fallback_to: String,
turn_count: u64,
fallback_cost: f64,
}
pub fn analyze_overview(
sessions: &[SessionData],
quality: GlobalDataQuality,
calc: &PricingCalculator,
subscription_price: Option<f64>,
) -> OverviewResult {
let mut tokens_by_model: HashMap<String, AggregatedTokens> = HashMap::new();
let mut cost_by_model: HashMap<String, f64> = HashMap::new();
let mut total_cost = 0.0;
let mut hourly_distribution = [0usize; 24];
let mut weekday_hour_matrix = [[0usize; 24]; 7];
let mut total_turns = 0usize;
let mut total_agent_turns = 0usize;
let mut cost_by_category = CostByCategory::default();
let mut tool_count_map: HashMap<String, usize> = HashMap::new();
let mut fallback_map: HashMap<String, FallbackAccum> = HashMap::new();
for session in sessions {
for turn in session.all_responses() {
process_turn(
turn,
calc,
&mut tokens_by_model,
&mut cost_by_model,
&mut total_cost,
&mut hourly_distribution,
&mut weekday_hour_matrix,
&mut cost_by_category,
&mut fallback_map,
);
total_turns += 1;
if turn.is_agent {
total_agent_turns += 1;
}
for name in &turn.tool_names {
*tool_count_map.entry(name.clone()).or_insert(0) += 1;
}
}
}
let mut tool_counts: Vec<(String, usize)> = tool_count_map.into_iter().collect();
tool_counts.sort_by_key(|b| std::cmp::Reverse(b.1));
let mut total_output_tokens: u64 = 0;
let mut total_context_tokens: u64 = 0;
for agg in tokens_by_model.values() {
total_output_tokens += agg.output_tokens;
total_context_tokens += agg.context_tokens();
}
let total_cache_read: u64 = tokens_by_model.values().map(|a| a.cache_read_tokens).sum();
let avg_cache_hit_rate = if total_context_tokens > 0 {
(total_cache_read as f64 / total_context_tokens as f64) * 100.0
} else {
0.0
};
let session_summaries: Vec<SessionSummary> = sessions
.iter()
.map(|s| build_session_summary(s, calc))
.collect();
let cache_savings = {
let mut total_saved = 0.0f64;
let mut without_cache = 0.0f64;
let mut with_cache = 0.0f64;
let mut by_model: Vec<(String, f64)> = Vec::new();
for (model, tokens) in &tokens_by_model {
if let Some((price, _)) = calc.get_price(model) {
let cache_read_mtok = tokens.cache_read_tokens as f64 / 1_000_000.0;
let hypothetical = cache_read_mtok * price.base_input;
let actual = cache_read_mtok * price.cache_read;
let saved = hypothetical - actual;
without_cache += hypothetical;
with_cache += actual;
total_saved += saved;
if saved > 0.01 {
by_model.push((model.clone(), saved));
}
}
}
by_model.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let savings_pct = if without_cache > 0.0 {
total_saved / without_cache * 100.0
} else {
0.0
};
CacheSavings {
total_saved,
without_cache_cost: without_cache,
with_cache_cost: with_cache,
savings_pct,
by_model,
}
};
let subscription_value = subscription_price.map(|monthly_price| {
let value_multiplier = if total_cost > 0.0 {
total_cost / monthly_price
} else {
0.0
};
SubscriptionValue {
monthly_price,
api_equivalent: total_cost,
value_multiplier,
}
});
let output_ratio = if total_context_tokens > 0 {
total_output_tokens as f64 / total_context_tokens as f64 * 100.0
} else {
0.0
};
let cost_per_turn = if total_turns > 0 {
total_cost / total_turns as f64
} else {
0.0
};
let tokens_per_output_turn = if total_turns > 0 {
total_output_tokens / total_turns as u64
} else {
0
};
let mut pricing_warnings: Vec<PricingWarning> = fallback_map
.into_iter()
.map(|(unknown_model, acc)| PricingWarning {
unknown_model,
fallback_to: acc.fallback_to,
turn_count: acc.turn_count,
fallback_cost: acc.fallback_cost,
})
.collect();
pricing_warnings.sort_by(|a, b| {
b.fallback_cost
.partial_cmp(&a.fallback_cost)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.unknown_model.cmp(&b.unknown_model))
});
OverviewResult {
total_sessions: sessions.len(),
total_turns,
total_agent_turns,
tokens_by_model,
cost_by_model,
total_cost,
hourly_distribution,
quality,
subscription_value,
weekday_hour_matrix,
tool_counts,
cost_by_category,
session_summaries,
total_output_tokens,
total_context_tokens,
avg_cache_hit_rate,
cache_savings,
output_ratio,
cost_per_turn,
tokens_per_output_turn,
pricing_warnings,
}
}
#[allow(clippy::too_many_arguments)]
fn process_turn(
turn: &crate::data::models::ValidatedTurn,
calc: &PricingCalculator,
tokens_by_model: &mut HashMap<String, AggregatedTokens>,
cost_by_model: &mut HashMap<String, f64>,
total_cost: &mut f64,
hourly_distribution: &mut [usize; 24],
weekday_hour_matrix: &mut [[usize; 24]; 7],
cost_by_category: &mut CostByCategory,
fallback_map: &mut HashMap<String, FallbackAccum>,
) {
tokens_by_model
.entry(turn.model.clone())
.or_default()
.add_usage(&turn.usage);
let cost = calc.calculate_turn_cost(&turn.model, &turn.usage);
*cost_by_model.entry(turn.model.clone()).or_insert(0.0) += cost.total;
*total_cost += cost.total;
cost_by_category.input_cost += cost.input_cost;
cost_by_category.output_cost += cost.output_cost;
cost_by_category.cache_write_5m_cost += cost.cache_write_5m_cost;
cost_by_category.cache_write_1h_cost += cost.cache_write_1h_cost;
cost_by_category.cache_read_cost += cost.cache_read_cost;
if let PriceSource::Fallback {
ref requested,
ref fallback_to,
} = cost.price_source
{
let entry = fallback_map.entry(requested.clone()).or_default();
if entry.fallback_to.is_empty() {
entry.fallback_to = fallback_to.clone();
}
entry.turn_count += 1;
entry.fallback_cost += cost.total;
}
let local_ts = turn.timestamp.with_timezone(&Local);
let hour = local_ts.hour() as usize;
hourly_distribution[hour] += 1;
let weekday = local_ts.weekday().num_days_from_monday() as usize; weekday_hour_matrix[weekday][hour] += 1;
}
fn build_session_summary(session: &SessionData, calc: &PricingCalculator) -> SessionSummary {
let session_id = if session.session_id.len() > 8 {
session.session_id[..8].to_string()
} else {
session.session_id.clone()
};
let project_display_name = session
.project
.as_deref()
.map(crate::analysis::project::project_display_name)
.unwrap_or_else(|| "(unknown)".to_string());
let all_turns = session.all_responses();
let turn_count = all_turns.len();
let duration_minutes = match (session.first_timestamp, session.last_timestamp) {
(Some(first), Some(last)) => (last - first).num_seconds() as f64 / 60.0,
_ => 0.0,
};
let mut model_counts: HashMap<&str, usize> = HashMap::new();
let mut output_tokens: u64 = 0;
let mut context_tokens: u64 = 0;
let mut max_context: u64 = 0;
let mut total_cache_read: u64 = 0;
let mut total_context: u64 = 0;
let mut total_5m: u64 = 0;
let mut total_1h: u64 = 0;
let mut compaction_count: usize = 0;
let mut agent_turn_count: usize = 0;
let mut tool_use_count: usize = 0;
let mut total_cost: f64 = 0.0;
let mut prev_context_size: Option<u64> = None;
let mut tool_map: HashMap<String, usize> = HashMap::new();
for turn in &all_turns {
*model_counts.entry(&turn.model).or_insert(0) += 1;
let input = turn.usage.input_tokens.unwrap_or(0);
let cache_create = turn.usage.cache_creation_input_tokens.unwrap_or(0);
let cache_read = turn.usage.cache_read_input_tokens.unwrap_or(0);
let out = turn.usage.output_tokens.unwrap_or(0);
output_tokens += out;
let ctx = input + cache_create + cache_read;
context_tokens += ctx;
total_context += ctx;
total_cache_read += cache_read;
if ctx > max_context {
max_context = ctx;
}
if let Some(ref detail) = turn.usage.cache_creation {
total_5m += detail.ephemeral_5m_input_tokens.unwrap_or(0);
total_1h += detail.ephemeral_1h_input_tokens.unwrap_or(0);
}
if let Some(prev) = prev_context_size {
if prev > 0 && (ctx as f64) < (prev as f64 * 0.9) {
compaction_count += 1;
}
}
prev_context_size = Some(ctx);
if turn.is_agent {
agent_turn_count += 1;
}
if turn.stop_reason.as_deref() == Some("tool_use") {
tool_use_count += 1;
}
for name in &turn.tool_names {
*tool_map.entry(name.clone()).or_insert(0) += 1;
}
let cost = calc.calculate_turn_cost(&turn.model, &turn.usage);
total_cost += cost.total;
}
let model = model_counts
.into_iter()
.max_by_key(|(_, count)| *count)
.map(|(m, _)| m.to_string())
.unwrap_or_default();
let cache_hit_rate = if total_context > 0 {
(total_cache_read as f64 / total_context as f64) * 100.0
} else {
0.0
};
let total_cache_write = total_5m + total_1h;
let cache_write_5m_pct = if total_cache_write > 0 {
(total_5m as f64 / total_cache_write as f64) * 100.0
} else {
0.0
};
let output_ratio = if context_tokens > 0 {
output_tokens as f64 / context_tokens as f64 * 100.0
} else {
0.0
};
let cost_per_turn = if turn_count > 0 {
total_cost / turn_count as f64
} else {
0.0
};
SessionSummary {
session_id,
project_display_name,
first_timestamp: session.first_timestamp,
duration_minutes,
model,
turn_count,
agent_turn_count,
output_tokens,
context_tokens,
max_context,
cache_hit_rate,
cache_write_5m_pct,
compaction_count,
cost: total_cost,
tool_use_count,
top_tools: {
let mut tools: Vec<(String, usize)> = tool_map.into_iter().collect();
tools.sort_by_key(|b| std::cmp::Reverse(b.1));
tools.truncate(5);
tools
},
turn_details: None,
output_ratio,
cost_per_turn,
is_orphan: session.is_orphan,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::models::{
DataQuality, SessionData, SessionMetadata, TokenUsage, ValidatedTurn,
};
use chrono::{TimeZone, Utc};
fn make_turn(model: &str, input: u64, output: u64) -> ValidatedTurn {
ValidatedTurn {
uuid: format!("uuid-{}-{}", model, input),
request_id: None,
timestamp: Utc.with_ymd_and_hms(2026, 5, 1, 12, 0, 0).unwrap(),
model: model.to_string(),
usage: TokenUsage {
input_tokens: Some(input),
output_tokens: Some(output),
cache_creation_input_tokens: Some(0),
cache_read_input_tokens: Some(0),
cache_creation: None,
server_tool_use: None,
service_tier: None,
speed: None,
inference_geo: None,
},
stop_reason: Some("end_turn".to_string()),
content_types: vec!["text".to_string()],
is_agent: false,
agent_id: None,
user_text: None,
assistant_text: None,
tool_names: vec![],
service_tier: None,
speed: None,
inference_geo: None,
tool_error_count: 0,
git_branch: None,
attribution_plugin: None,
attribution_skill: None,
}
}
fn make_session(turns: Vec<ValidatedTurn>) -> SessionData {
SessionData {
session_id: "test-session".to_string(),
project: Some("test-project".to_string()),
turns,
subagents: vec![],
plugins: vec![],
skills: vec![],
hooks: vec![],
first_timestamp: Some(Utc.with_ymd_and_hms(2026, 5, 1, 12, 0, 0).unwrap()),
last_timestamp: Some(Utc.with_ymd_and_hms(2026, 5, 1, 13, 0, 0).unwrap()),
version: None,
quality: DataQuality::default(),
metadata: SessionMetadata::default(),
is_orphan: false,
}
}
#[test]
fn pricing_warnings_aggregated_across_session() {
let calc = PricingCalculator::new();
let session = make_session(vec![
make_turn("claude-opus-4-6", 1_000_000, 1_000_000), make_turn("claude-future-x-1", 1_000_000, 1_000_000), make_turn("claude-future-x-1", 500_000, 500_000), make_turn("claude-future-y-2", 2_000_000, 2_000_000), ]);
let result = analyze_overview(&[session], GlobalDataQuality::default(), &calc, None);
assert_eq!(
result.pricing_warnings.len(),
2,
"expected one warning per distinct unknown model"
);
let first = &result.pricing_warnings[0];
assert_eq!(first.unknown_model, "claude-future-y-2");
assert_eq!(first.turn_count, 1);
assert_eq!(first.fallback_to, "claude-opus-4-7");
assert!(
(first.fallback_cost - 60.0).abs() < 1e-9,
"fallback_cost: {}",
first.fallback_cost
);
let second = &result.pricing_warnings[1];
assert_eq!(second.unknown_model, "claude-future-x-1");
assert_eq!(second.turn_count, 2);
assert_eq!(second.fallback_to, "claude-opus-4-7");
assert!(
(second.fallback_cost - 45.0).abs() < 1e-9,
"fallback_cost: {}",
second.fallback_cost
);
assert!(
!result
.pricing_warnings
.iter()
.any(|w| w.unknown_model == "claude-opus-4-6"),
"known model leaked into pricing_warnings"
);
}
}