Skip to main content

vct_core/analysis/
summary.rs

1//! Aggregated per-provider `analysis` summary shared by every frontend.
2//!
3//! Folds the roll-up's per-provider model rows into per-provider file-operation
4//! and tool-call totals. Ratatui-free business logic, so a non-CLI backend
5//! (e.g. a future GUI) builds the same totals without depending on `display`.
6
7use crate::analysis::{AggregatedAnalysisRow, PerProviderAnalysisRows};
8use crate::models::ProviderActiveDays;
9
10/// Display-side copy of one model's analysis metrics.
11///
12/// Mirrors [`AggregatedAnalysisRow`] but is decoupled from the (de)serializable
13/// aggregator type so the renderers can also use it as a mutable `TOTAL`
14/// accumulator. Construct via [`convert_to_analysis_rows`] or `Default`.
15#[derive(Default)]
16pub struct AnalysisRow {
17    /// Model name the metrics are grouped under.
18    pub model: String,
19    /// Total lines changed by `Edit`/`MultiEdit` operations.
20    pub edit_lines: usize,
21    /// Total lines returned by `Read` operations.
22    pub read_lines: usize,
23    /// Total lines emitted by `Write` operations.
24    pub write_lines: usize,
25    /// Number of `Bash` tool calls.
26    pub bash_count: usize,
27    /// Number of `Edit` tool calls.
28    pub edit_count: usize,
29    /// Number of `Read` tool calls.
30    pub read_count: usize,
31    /// Number of `TodoWrite` tool calls.
32    pub todo_write_count: usize,
33    /// Number of `Write` tool calls.
34    pub write_count: usize,
35}
36
37/// Per-provider totals for analysis. `days_count` records how many distinct
38/// days contributed to the totals so the display layer can show the spread
39/// without computing a rate.
40#[derive(Default, Clone)]
41pub struct AnalysisProviderStats {
42    /// Sum of `Edit` lines across the provider's models.
43    pub total_edit_lines: usize,
44    /// Sum of `Read` lines across the provider's models.
45    pub total_read_lines: usize,
46    /// Sum of `Write` lines across the provider's models.
47    pub total_write_lines: usize,
48    /// Sum of `Bash` tool calls across the provider's models.
49    pub total_bash_count: usize,
50    /// Sum of `Edit` tool calls across the provider's models.
51    pub total_edit_count: usize,
52    /// Sum of `Read` tool calls across the provider's models.
53    pub total_read_count: usize,
54    /// Sum of `TodoWrite` tool calls across the provider's models.
55    pub total_todo_write_count: usize,
56    /// Sum of `Write` tool calls across the provider's models.
57    pub total_write_count: usize,
58    /// Number of distinct days that contributed to these totals.
59    pub days_count: usize,
60}
61
62impl AnalysisProviderStats {
63    /// Adds one model row's metrics into the running provider totals.
64    fn accumulate_row(&mut self, row: &AnalysisRow) {
65        self.total_edit_lines += row.edit_lines;
66        self.total_read_lines += row.read_lines;
67        self.total_write_lines += row.write_lines;
68        self.total_bash_count += row.bash_count;
69        self.total_edit_count += row.edit_count;
70        self.total_read_count += row.read_count;
71        self.total_todo_write_count += row.todo_write_count;
72        self.total_write_count += row.write_count;
73    }
74}
75
76/// Type alias for analysis totals grouped by provider.
77pub type AnalysisProviderTotals = crate::models::ProviderTotals<AnalysisProviderStats>;
78
79/// Calculate per-provider analysis totals using **source-directory**
80/// attribution, matching the usage command's approach.
81///
82/// Consuming `PerProviderAnalysisRows` directly means same-named models
83/// that appear in multiple providers (e.g. `claude-sonnet-4-6` recorded
84/// both by Claude Code and Copilot CLI after the recent Copilot refactor)
85/// are attributed correctly to each source directory rather than being
86/// lumped under whichever provider the model name happens to look like.
87pub fn calculate_analysis_provider_totals_from_per_provider(
88    per_provider: &PerProviderAnalysisRows,
89    provider_days: &ProviderActiveDays,
90) -> AnalysisProviderTotals {
91    let mut totals = AnalysisProviderTotals::default();
92
93    totals.claude.days_count = provider_days.claude;
94    totals.codex.days_count = provider_days.codex;
95    totals.copilot.days_count = provider_days.copilot;
96    totals.gemini.days_count = provider_days.gemini;
97    totals.grok.days_count = provider_days.grok;
98    totals.opencode.days_count = provider_days.opencode;
99    totals.cursor.days_count = provider_days.cursor;
100    totals.overall.days_count = provider_days.total;
101
102    accumulate_analysis_provider(&mut totals.claude, &per_provider.claude);
103    accumulate_analysis_provider(&mut totals.codex, &per_provider.codex);
104    accumulate_analysis_provider(&mut totals.copilot, &per_provider.copilot);
105    accumulate_analysis_provider(&mut totals.gemini, &per_provider.gemini);
106    accumulate_analysis_provider(&mut totals.grok, &per_provider.grok);
107    accumulate_analysis_provider(&mut totals.opencode, &per_provider.opencode);
108    accumulate_analysis_provider(&mut totals.cursor, &per_provider.cursor);
109
110    // "All Providers" row is the sum of every provider's totals, matching
111    // the usage command. Summing per-provider stats keeps the overall
112    // total == Σ providers even when a model appears under more than one
113    // provider.
114    totals.overall.total_edit_lines = totals.claude.total_edit_lines
115        + totals.codex.total_edit_lines
116        + totals.copilot.total_edit_lines
117        + totals.gemini.total_edit_lines
118        + totals.grok.total_edit_lines
119        + totals.opencode.total_edit_lines
120        + totals.cursor.total_edit_lines;
121    totals.overall.total_read_lines = totals.claude.total_read_lines
122        + totals.codex.total_read_lines
123        + totals.copilot.total_read_lines
124        + totals.gemini.total_read_lines
125        + totals.grok.total_read_lines
126        + totals.opencode.total_read_lines
127        + totals.cursor.total_read_lines;
128    totals.overall.total_write_lines = totals.claude.total_write_lines
129        + totals.codex.total_write_lines
130        + totals.copilot.total_write_lines
131        + totals.gemini.total_write_lines
132        + totals.grok.total_write_lines
133        + totals.opencode.total_write_lines
134        + totals.cursor.total_write_lines;
135    totals.overall.total_bash_count = totals.claude.total_bash_count
136        + totals.codex.total_bash_count
137        + totals.copilot.total_bash_count
138        + totals.gemini.total_bash_count
139        + totals.grok.total_bash_count
140        + totals.opencode.total_bash_count
141        + totals.cursor.total_bash_count;
142    totals.overall.total_edit_count = totals.claude.total_edit_count
143        + totals.codex.total_edit_count
144        + totals.copilot.total_edit_count
145        + totals.gemini.total_edit_count
146        + totals.grok.total_edit_count
147        + totals.opencode.total_edit_count
148        + totals.cursor.total_edit_count;
149    totals.overall.total_read_count = totals.claude.total_read_count
150        + totals.codex.total_read_count
151        + totals.copilot.total_read_count
152        + totals.gemini.total_read_count
153        + totals.grok.total_read_count
154        + totals.opencode.total_read_count
155        + totals.cursor.total_read_count;
156    totals.overall.total_todo_write_count = totals.claude.total_todo_write_count
157        + totals.codex.total_todo_write_count
158        + totals.copilot.total_todo_write_count
159        + totals.gemini.total_todo_write_count
160        + totals.grok.total_todo_write_count
161        + totals.opencode.total_todo_write_count
162        + totals.cursor.total_todo_write_count;
163    totals.overall.total_write_count = totals.claude.total_write_count
164        + totals.codex.total_write_count
165        + totals.copilot.total_write_count
166        + totals.gemini.total_write_count
167        + totals.grok.total_write_count
168        + totals.opencode.total_write_count
169        + totals.cursor.total_write_count;
170
171    totals
172}
173
174/// Folds every aggregated row for one provider into its `stats` totals.
175fn accumulate_analysis_provider(stats: &mut AnalysisProviderStats, rows: &[AggregatedAnalysisRow]) {
176    let analysis_rows = convert_to_analysis_rows(rows);
177    for row in &analysis_rows {
178        stats.accumulate_row(row);
179    }
180}
181
182/// Convert aggregator rows into the renderers' [`AnalysisRow`] shape.
183///
184/// A field-for-field copy that decouples the display state from the
185/// (de)serializable [`AggregatedAnalysisRow`]; `model` is cloned, all counters
186/// are carried over verbatim.
187///
188/// # Examples
189///
190/// ```
191/// use vct_core::analysis::AggregatedAnalysisRow;
192/// use vct_core::analysis::convert_to_analysis_rows;
193///
194/// let aggregated = vec![AggregatedAnalysisRow {
195///     model: "claude-sonnet-4-6".to_string(),
196///     edit_lines: 12,
197///     read_lines: 34,
198///     write_lines: 5,
199///     bash_count: 2,
200///     edit_count: 3,
201///     read_count: 4,
202///     todo_write_count: 1,
203///     write_count: 1,
204/// }];
205///
206/// let rows = convert_to_analysis_rows(&aggregated);
207/// assert_eq!(rows.len(), 1);
208/// assert_eq!(rows[0].model, "claude-sonnet-4-6");
209/// assert_eq!(rows[0].edit_lines, 12);
210/// ```
211pub fn convert_to_analysis_rows(data: &[AggregatedAnalysisRow]) -> Vec<AnalysisRow> {
212    data.iter()
213        .map(|row| AnalysisRow {
214            model: row.model.clone(),
215            edit_lines: row.edit_lines,
216            read_lines: row.read_lines,
217            write_lines: row.write_lines,
218            bash_count: row.bash_count,
219            edit_count: row.edit_count,
220            read_count: row.read_count,
221            todo_write_count: row.todo_write_count,
222            write_count: row.write_count,
223        })
224        .collect()
225}