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            });
31
32        acc.session_count += 1;
33
34        for turn in &session.turns {
35            acc.tokens.add_usage(&turn.usage);
36            acc.total_turns += 1;
37            let cost = calc.calculate_turn_cost(&turn.model, &turn.usage);
38            acc.cost += cost.total;
39        }
40
41        for turn in &session.agent_turns {
42            acc.tokens.add_usage(&turn.usage);
43            acc.total_turns += 1;
44            acc.agent_turns += 1;
45            let cost = calc.calculate_turn_cost(&turn.model, &turn.usage);
46            acc.cost += cost.total;
47        }
48    }
49
50    let mut projects: Vec<ProjectSummary> = project_map
51        .into_values()
52        .map(|acc| ProjectSummary {
53            display_name: project_display_name(&acc.name),
54            name: acc.name,
55            session_count: acc.session_count,
56            total_turns: acc.total_turns,
57            agent_turns: acc.agent_turns,
58            tokens: acc.tokens,
59            cost: acc.cost,
60        })
61        .collect();
62
63    // Sort by cost descending
64    projects.sort_by(|a, b| b.cost.partial_cmp(&a.cost).unwrap_or(std::cmp::Ordering::Equal));
65
66    // Take top_n (0 means no limit)
67    if top_n > 0 {
68        projects.truncate(top_n);
69    }
70
71    ProjectResult { projects }
72}
73
74/// Convert internal project path to a human-readable display name.
75///
76/// `-Users-testuser-cc-web3` -> `~/cc/web3`
77pub fn project_display_name(name: &str) -> String {
78    // Pattern: -Users-<username>-<rest>
79    // Try to find the "-Users-" prefix and convert
80    if let Some(rest) = name.strip_prefix("-Users-") {
81        // Skip the username segment (everything up to the next '-')
82        if let Some(after_user) = rest.find('-') {
83            let path_part = &rest[after_user..];
84            // Replace '-' with '/'
85            let display = path_part.replace('-', "/");
86            return format!("~{display}");
87        }
88    }
89
90    name.to_string()
91}
92
93struct ProjectAccumulator {
94    name: String,
95    session_count: usize,
96    total_turns: usize,
97    agent_turns: usize,
98    tokens: AggregatedTokens,
99    cost: f64,
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn test_project_display_name() {
108        assert_eq!(
109            project_display_name("-Users-testuser-cc-web3"),
110            "~/cc/web3"
111        );
112        assert_eq!(
113            project_display_name("-Users-alice-projects-my-app"),
114            "~/projects/my/app"
115        );
116        assert_eq!(project_display_name("simple-project"), "simple-project");
117        assert_eq!(project_display_name("-Users-bob"), "-Users-bob");
118    }
119}