Skip to main content

cc_token_usage/analysis/
project.rs

1use 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    // Sort by cost descending
70    projects.sort_by(|a, b| {
71        b.cost
72            .partial_cmp(&a.cost)
73            .unwrap_or(std::cmp::Ordering::Equal)
74    });
75
76    // Take top_n (0 means no limit)
77    if top_n > 0 {
78        projects.truncate(top_n);
79    }
80
81    ProjectResult { projects }
82}
83
84/// Convert internal project path to a human-readable display name.
85///
86/// `-Users-testuser-cc-web3` -> `~/cc/web3`
87pub fn project_display_name(name: &str) -> String {
88    // Pattern: -Users-<username>-<rest>
89    // Try to find the "-Users-" prefix and convert
90    if let Some(rest) = name.strip_prefix("-Users-") {
91        // Skip the username segment (everything up to the next '-')
92        if let Some(after_user) = rest.find('-') {
93            let path_part = &rest[after_user..];
94            // Replace '-' with '/'
95            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}