cc_token_usage/analysis/
project.rs1use std::collections::HashMap;
2
3use crate::data::models::SessionData;
4use crate::pricing::calculator::PricingCalculator;
5
6use super::{AggregatedTokens, ProjectResult, ProjectSummary};
7
8pub fn analyze_projects(
9 sessions: &[SessionData],
10 calc: &PricingCalculator,
11 top_n: usize,
12) -> ProjectResult {
13 let mut project_map: HashMap<String, ProjectAccumulator> = HashMap::new();
14
15 for session in sessions {
16 let project_name = session
17 .project
18 .clone()
19 .unwrap_or_else(|| "(unknown)".to_string());
20
21 let acc = project_map
22 .entry(project_name.clone())
23 .or_insert_with(|| ProjectAccumulator {
24 name: project_name,
25 session_count: 0,
26 total_turns: 0,
27 agent_turns: 0,
28 tokens: AggregatedTokens::default(),
29 cost: 0.0,
30 model_counts: HashMap::new(),
31 });
32
33 acc.session_count += 1;
34
35 for turn in session.all_responses() {
36 acc.tokens.add_usage(&turn.usage);
37 acc.total_turns += 1;
38 if turn.is_agent {
39 acc.agent_turns += 1;
40 }
41 *acc.model_counts.entry(turn.model.clone()).or_insert(0) += 1;
42 let cost = calc.calculate_turn_cost(&turn.model, &turn.usage);
43 acc.cost += cost.total;
44 }
45 }
46
47 let mut projects: Vec<ProjectSummary> = project_map
48 .into_values()
49 .map(|acc| {
50 let primary_model = acc
51 .model_counts
52 .into_iter()
53 .max_by_key(|(_, c)| *c)
54 .map(|(m, _)| m)
55 .unwrap_or_default();
56 ProjectSummary {
57 display_name: project_display_name(&acc.name),
58 name: acc.name,
59 session_count: acc.session_count,
60 total_turns: acc.total_turns,
61 agent_turns: acc.agent_turns,
62 tokens: acc.tokens,
63 cost: acc.cost,
64 primary_model,
65 }
66 })
67 .collect();
68
69 projects.sort_by(|a, b| {
71 b.cost
72 .partial_cmp(&a.cost)
73 .unwrap_or(std::cmp::Ordering::Equal)
74 });
75
76 if top_n > 0 {
78 projects.truncate(top_n);
79 }
80
81 ProjectResult { projects }
82}
83
84pub fn project_display_name(name: &str) -> String {
88 if let Some(rest) = name.strip_prefix("-Users-") {
91 if let Some(after_user) = rest.find('-') {
93 let path_part = &rest[after_user..];
94 let display = path_part.replace('-', "/");
96 return format!("~{display}");
97 }
98 }
99
100 name.to_string()
101}
102
103struct ProjectAccumulator {
104 name: String,
105 session_count: usize,
106 total_turns: usize,
107 agent_turns: usize,
108 tokens: AggregatedTokens,
109 cost: f64,
110 model_counts: HashMap<String, usize>,
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[test]
118 fn test_project_display_name() {
119 assert_eq!(project_display_name("-Users-testuser-cc-web3"), "~/cc/web3");
120 assert_eq!(
121 project_display_name("-Users-alice-projects-my-app"),
122 "~/projects/my/app"
123 );
124 assert_eq!(project_display_name("simple-project"), "simple-project");
125 assert_eq!(project_display_name("-Users-bob"), "-Users-bob");
126 }
127}