Skip to main content

vct_core/models/
analysis.rs

1//! Normalized, cross-provider analysis result types.
2//!
3//! These structs are the analyzer's output shape: every provider parser in
4//! `src/session/` produces a [`CodeAnalysis`] regardless of the source
5//! assistant, and the `analysis` / `usage` roll-up layers consume them. The
6//! `serde` attributes here also define the JSON layout of the golden fixtures,
7//! `vct analysis FILE`, and each element of the batch `vct analysis --json`
8//! array.
9
10use crate::constants::FastHashMap;
11use serde::{Deserialize, Serialize, Serializer, ser::SerializeMap};
12
13/// Serializes a model-keyed usage map in lexical key order.
14///
15/// [`FastHashMap`] intentionally randomizes iteration order, but analysis JSON
16/// is a persisted public format. Sorting only at this boundary keeps the hot
17/// parser path fast while making repeated serialization byte-for-byte stable.
18fn serialize_conversation_usage<S>(
19    usage: &FastHashMap<String, serde_json::Value>,
20    serializer: S,
21) -> Result<S::Ok, S::Error>
22where
23    S: Serializer,
24{
25    let mut entries: Vec<_> = usage.iter().collect();
26    entries.sort_unstable_by_key(|(model, _)| *model);
27
28    let mut map = serializer.serialize_map(Some(entries.len()))?;
29    for (model, value) in entries {
30        map.serialize_entry(model, value)?;
31    }
32    map.end()
33}
34
35/// Fields shared by every per-operation detail record.
36///
37/// `line_count` and `character_count` are measured on the *trimmed* payload,
38/// and `character_count` counts Unicode scalar values (`str::chars`), not
39/// bytes. Serialized with camelCase keys (`filePath`, `lineCount`, …).
40#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct CodeAnalysisDetailBase {
43    /// Absolute path of the file the operation targeted.
44    pub file_path: String,
45    /// Number of lines in the operation payload.
46    pub line_count: usize,
47    /// Number of Unicode scalar values in the operation payload.
48    pub character_count: usize,
49    /// Unix epoch timestamp (milliseconds) when the operation occurred.
50    pub timestamp: i64,
51}
52
53/// A single file-write operation, including the full written content.
54///
55/// The `base` fields are flattened into the same JSON object (no nested
56/// `base` key), so the record serializes as `{filePath, lineCount, …, content}`.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58#[serde(rename_all = "camelCase")]
59pub struct CodeAnalysisWriteDetail {
60    /// Shared path / line / character / timestamp metadata, flattened inline.
61    #[serde(flatten)]
62    pub base: CodeAnalysisDetailBase,
63    /// Full content written to the file.
64    pub content: String,
65}
66
67/// A single file-read operation.
68///
69/// Carries only the shared [`CodeAnalysisDetailBase`] metadata; the file body
70/// itself is not retained.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct CodeAnalysisReadDetail {
74    /// Shared path / line / character / timestamp metadata, flattened inline.
75    #[serde(flatten)]
76    pub base: CodeAnalysisDetailBase,
77}
78
79/// A single file-edit operation, recording the before/after strings.
80///
81/// `line_count` / `character_count` in `base` describe the new (replacement)
82/// text. Serializes with the `base` fields flattened inline.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct CodeAnalysisApplyDiffDetail {
86    /// Shared path / line / character / timestamp metadata, flattened inline.
87    #[serde(flatten)]
88    pub base: CodeAnalysisDetailBase,
89    /// Text that was replaced.
90    pub old_string: String,
91    /// Text that replaced `old_string`.
92    pub new_string: String,
93}
94
95/// A single shell-command execution.
96///
97/// For run-command records `base.file_path` holds the session's working
98/// directory (there is no single target file) and `base.line_count` is `0`.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100#[serde(rename_all = "camelCase")]
101pub struct CodeAnalysisRunCommandDetail {
102    /// Shared metadata; `file_path` is the working directory, `line_count` is 0.
103    #[serde(flatten)]
104    pub base: CodeAnalysisDetailBase,
105    /// The command line that was executed.
106    pub command: String,
107    /// Human-readable description of the command, when the assistant supplied one.
108    pub description: String,
109}
110
111/// Per-session counters for each tool the analyzer tracks.
112///
113/// Serialized with PascalCase keys (`Read`, `Write`, `Edit`, `TodoWrite`,
114/// `Bash`).
115#[derive(Debug, Clone, Default, Serialize, Deserialize)]
116#[serde(rename_all = "PascalCase")]
117pub struct CodeAnalysisToolCalls {
118    /// Number of file-read tool calls.
119    pub read: usize,
120    /// Number of file-write tool calls.
121    pub write: usize,
122    /// Number of file-edit tool calls.
123    pub edit: usize,
124    /// Number of todo-list update tool calls.
125    pub todo_write: usize,
126    /// Number of shell-command tool calls.
127    pub bash: usize,
128}
129
130/// Aggregated metrics and per-operation details for a single coding session.
131///
132/// One record corresponds to one session file. When parsed in
133/// `ParseMode::UsageOnly` the `*_file_details` / `run_command_details` vectors
134/// are left empty to avoid allocating large bodies, while the `total_*`
135/// counters and `conversation_usage` are still populated.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137#[serde(rename_all = "camelCase")]
138pub struct CodeAnalysisRecord {
139    /// Count of distinct files touched (read, written, or edited) in the session.
140    pub total_unique_files: usize,
141    /// Sum of lines written across all write operations.
142    pub total_write_lines: usize,
143    /// Sum of lines read across all read operations.
144    pub total_read_lines: usize,
145    /// Sum of replacement lines across all edit operations.
146    pub total_edit_lines: usize,
147    /// Sum of characters written across all write operations.
148    pub total_write_characters: usize,
149    /// Sum of characters read across all read operations.
150    pub total_read_characters: usize,
151    /// Sum of replacement characters across all edit operations.
152    pub total_edit_characters: usize,
153    /// Per-operation write records (empty in `ParseMode::UsageOnly`).
154    pub write_file_details: Vec<CodeAnalysisWriteDetail>,
155    /// Per-operation read records (empty in `ParseMode::UsageOnly`).
156    pub read_file_details: Vec<CodeAnalysisReadDetail>,
157    /// Per-operation edit records (empty in `ParseMode::UsageOnly`).
158    pub edit_file_details: Vec<CodeAnalysisApplyDiffDetail>,
159    /// Per-command shell execution records (empty in `ParseMode::UsageOnly`).
160    pub run_command_details: Vec<CodeAnalysisRunCommandDetail>,
161    /// Tool-call counters for the session.
162    pub tool_call_counts: CodeAnalysisToolCalls,
163    /// Token-usage payloads keyed by model name; shape varies by provider
164    /// (see [`crate::models::UsageResult`]).
165    #[serde(serialize_with = "serialize_conversation_usage")]
166    pub conversation_usage: FastHashMap<String, serde_json::Value>,
167    /// Token usage from Claude Code `advisor_message` iterations, keyed by the
168    /// advisor's own model. Kept **out** of `conversation_usage` on purpose:
169    /// the `analysis` aggregator attributes a record's file-operation / tool
170    /// counts to every model in `conversation_usage`, but an advisor model
171    /// never executes tools, so adding it there would mis-attribute the main
172    /// model's metrics to the advisor. The `usage` path merges this in (priced
173    /// at the advisor's own rate); `analysis` ignores it. Not serialized, so
174    /// the `analysis` JSON / golden output is unaffected.
175    #[serde(skip)]
176    pub advisor_usage: FastHashMap<String, serde_json::Value>,
177    /// Session / task identifier from the source log.
178    pub task_id: String,
179    /// Unix epoch timestamp (milliseconds) of the session's last activity.
180    pub timestamp: i64,
181    /// Working directory the session ran in.
182    pub folder_path: String,
183    /// Git remote URL of the project, when one was detected.
184    pub git_remote_url: String,
185}
186
187/// Top-level analysis result: environment metadata plus one record per session.
188///
189/// This is the shape returned by `parse_session_file_typed`, printed directly
190/// by `vct analysis FILE`, and used for every element of the batch
191/// `vct analysis --json` array. The `insights_version`, `machine_id`, and
192/// `user` fields are environment-specific and are deliberately ignored by the
193/// golden fixture tests.
194#[derive(Debug, Clone, Serialize, Deserialize)]
195#[serde(rename_all = "camelCase")]
196pub struct CodeAnalysis {
197    /// OS user name that owned the session.
198    pub user: String,
199    /// Source assistant label (e.g. `Claude-Code`), as produced by
200    /// [`ExtensionType`]'s [`std::fmt::Display`].
201    pub extension_name: String,
202    /// Version string of the analyzer that produced this result.
203    pub insights_version: String,
204    /// Stable machine identifier for the host.
205    pub machine_id: String,
206    /// One [`CodeAnalysisRecord`] per parsed session.
207    pub records: Vec<CodeAnalysisRecord>,
208}
209
210/// Single row of aggregated file-operation metrics for one model.
211///
212/// Counts are summed across every session that used the model in the active
213/// time range. The `*_lines` fields total the lines touched by edit/read/write
214/// operations; the `*_count` fields total how many times each tool was called.
215/// Serializes with camelCase field names for library callers that persist a
216/// compact projection. The CLI's `analysis --json` output uses the full
217/// `CodeAnalysis` shape instead.
218///
219/// This is a neutral data type shared by the `analysis` roll-up and the
220/// process-local scan cache, so it lives in `models` rather than either
221/// feature.
222#[derive(Debug, Clone, Serialize, Deserialize)]
223#[serde(rename_all = "camelCase")]
224pub struct AggregatedAnalysisRow {
225    /// Model name the metrics are grouped under.
226    pub model: String,
227    /// Total lines changed by `Edit`/`MultiEdit` operations.
228    pub edit_lines: usize,
229    /// Total lines returned by `Read` operations.
230    pub read_lines: usize,
231    /// Total lines emitted by `Write` operations.
232    pub write_lines: usize,
233    /// Number of `Bash` tool calls.
234    pub bash_count: usize,
235    /// Number of `Edit` tool calls.
236    pub edit_count: usize,
237    /// Number of `Read` tool calls.
238    pub read_count: usize,
239    /// Number of `TodoWrite` tool calls.
240    pub todo_write_count: usize,
241    /// Number of `Write` tool calls.
242    pub write_count: usize,
243}
244
245/// The AI coding assistant a session came from.
246///
247/// Distinct from [`crate::models::Provider`]: `ExtensionType` is the
248/// concrete assistants the detector resolves a session file to (there is no
249/// `Unknown` variant), and its [`std::fmt::Display`] produces the hyphenated
250/// `extension_name` strings stored in [`CodeAnalysis`].
251///
252/// # Examples
253///
254/// ```
255/// use vct_core::models::ExtensionType;
256///
257/// assert_eq!(ExtensionType::Copilot.to_string(), "Copilot-CLI");
258/// ```
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
260pub enum ExtensionType {
261    /// Anthropic Claude Code.
262    ClaudeCode,
263    /// OpenAI Codex CLI.
264    Codex,
265    /// GitHub Copilot CLI.
266    Copilot,
267    /// Google Gemini CLI.
268    Gemini,
269    /// OpenCode.
270    OpenCode,
271    /// Cursor CLI / IDE.
272    Cursor,
273    /// Hermes.
274    Hermes,
275    /// xAI Grok CLI.
276    Grok,
277}
278
279impl ExtensionType {
280    /// Stable scan / display order shared by every provider fan-out.
281    ///
282    /// This is the single source of truth for "provider order": diagnostics
283    /// sorting, cached-scan ranking, and the descriptor table all defer to it,
284    /// so a new provider only has to be slotted in here.
285    pub fn scan_rank(self) -> u8 {
286        match self {
287            ExtensionType::ClaudeCode => 0,
288            ExtensionType::Codex => 1,
289            ExtensionType::Copilot => 2,
290            ExtensionType::Gemini => 3,
291            ExtensionType::Grok => 4,
292            ExtensionType::OpenCode => 5,
293            ExtensionType::Cursor => 6,
294            ExtensionType::Hermes => 7,
295        }
296    }
297}
298
299impl std::fmt::Display for ExtensionType {
300    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301        match self {
302            ExtensionType::ClaudeCode => write!(f, "Claude-Code"),
303            ExtensionType::Codex => write!(f, "Codex"),
304            ExtensionType::Copilot => write!(f, "Copilot-CLI"),
305            ExtensionType::Gemini => write!(f, "Gemini"),
306            ExtensionType::OpenCode => write!(f, "OpenCode"),
307            ExtensionType::Cursor => write!(f, "Cursor"),
308            ExtensionType::Hermes => write!(f, "Hermes"),
309            ExtensionType::Grok => write!(f, "Grok"),
310        }
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn code_analysis_literal_has_no_parser_diagnostic_field() {
320        let analysis = CodeAnalysis {
321            user: String::new(),
322            extension_name: String::new(),
323            insights_version: String::new(),
324            machine_id: String::new(),
325            records: Vec::new(),
326        };
327
328        assert_eq!(
329            serde_json::to_value(analysis).unwrap()["records"],
330            serde_json::json!([])
331        );
332    }
333
334    #[test]
335    fn test_code_analysis_tool_calls_serialization() {
336        // Test serializing CodeAnalysisToolCalls
337        let tool_calls = CodeAnalysisToolCalls {
338            read: 10,
339            write: 5,
340            edit: 3,
341            todo_write: 2,
342            bash: 1,
343        };
344
345        let json = serde_json::to_string(&tool_calls).unwrap();
346        let deserialized: CodeAnalysisToolCalls = serde_json::from_str(&json).unwrap();
347
348        assert_eq!(deserialized.read, 10);
349        assert_eq!(deserialized.write, 5);
350        assert_eq!(deserialized.edit, 3);
351        assert_eq!(deserialized.todo_write, 2);
352        assert_eq!(deserialized.bash, 1);
353    }
354
355    #[test]
356    fn test_code_analysis_tool_calls_default() {
357        // Test default values
358        let tool_calls = CodeAnalysisToolCalls::default();
359
360        assert_eq!(tool_calls.read, 0);
361        assert_eq!(tool_calls.write, 0);
362        assert_eq!(tool_calls.edit, 0);
363        assert_eq!(tool_calls.todo_write, 0);
364        assert_eq!(tool_calls.bash, 0);
365    }
366
367    #[test]
368    fn test_code_analysis_detail_base_serialization() {
369        // Test serializing CodeAnalysisDetailBase
370        let detail = CodeAnalysisDetailBase {
371            file_path: "/path/to/file.rs".to_string(),
372            line_count: 100,
373            character_count: 2500,
374            timestamp: 1234567890,
375        };
376
377        let json = serde_json::to_string(&detail).unwrap();
378        let deserialized: CodeAnalysisDetailBase = serde_json::from_str(&json).unwrap();
379
380        assert_eq!(deserialized.file_path, "/path/to/file.rs");
381        assert_eq!(deserialized.line_count, 100);
382        assert_eq!(deserialized.character_count, 2500);
383        assert_eq!(deserialized.timestamp, 1234567890);
384    }
385
386    #[test]
387    fn test_code_analysis_write_detail_serialization() {
388        // Test serializing CodeAnalysisWriteDetail
389        let write_detail = CodeAnalysisWriteDetail {
390            base: CodeAnalysisDetailBase {
391                file_path: "/test/file.rs".to_string(),
392                line_count: 10,
393                character_count: 250,
394                timestamp: 1234567890,
395            },
396            content: "fn main() {\n    println!(\"Hello\");\n}".to_string(),
397        };
398
399        let json = serde_json::to_string(&write_detail).unwrap();
400        let deserialized: CodeAnalysisWriteDetail = serde_json::from_str(&json).unwrap();
401
402        assert_eq!(deserialized.base.file_path, "/test/file.rs");
403        assert_eq!(deserialized.base.line_count, 10);
404        assert!(deserialized.content.contains("main"));
405    }
406
407    #[test]
408    fn test_code_analysis_read_detail_serialization() {
409        // Test serializing CodeAnalysisReadDetail
410        let read_detail = CodeAnalysisReadDetail {
411            base: CodeAnalysisDetailBase {
412                file_path: "/test/input.txt".to_string(),
413                line_count: 50,
414                character_count: 1200,
415                timestamp: 1234567890,
416            },
417        };
418
419        let json = serde_json::to_string(&read_detail).unwrap();
420        let deserialized: CodeAnalysisReadDetail = serde_json::from_str(&json).unwrap();
421
422        assert_eq!(deserialized.base.file_path, "/test/input.txt");
423        assert_eq!(deserialized.base.line_count, 50);
424        assert_eq!(deserialized.base.character_count, 1200);
425    }
426
427    #[test]
428    fn test_code_analysis_apply_diff_detail_serialization() {
429        // Test serializing CodeAnalysisApplyDiffDetail
430        let edit_detail = CodeAnalysisApplyDiffDetail {
431            base: CodeAnalysisDetailBase {
432                file_path: "/test/edit.rs".to_string(),
433                line_count: 5,
434                character_count: 100,
435                timestamp: 1234567890,
436            },
437            old_string: "old content".to_string(),
438            new_string: "new content".to_string(),
439        };
440
441        let json = serde_json::to_string(&edit_detail).unwrap();
442        let deserialized: CodeAnalysisApplyDiffDetail = serde_json::from_str(&json).unwrap();
443
444        assert_eq!(deserialized.base.file_path, "/test/edit.rs");
445        assert_eq!(deserialized.old_string, "old content");
446        assert_eq!(deserialized.new_string, "new content");
447    }
448
449    #[test]
450    fn test_code_analysis_run_command_detail_serialization() {
451        // Test serializing CodeAnalysisRunCommandDetail
452        let run_detail = CodeAnalysisRunCommandDetail {
453            base: CodeAnalysisDetailBase {
454                file_path: "/workspace".to_string(),
455                line_count: 0,
456                character_count: 10,
457                timestamp: 1234567890,
458            },
459            command: "cargo test".to_string(),
460            description: "Running tests".to_string(),
461        };
462
463        let json = serde_json::to_string(&run_detail).unwrap();
464        let deserialized: CodeAnalysisRunCommandDetail = serde_json::from_str(&json).unwrap();
465
466        assert_eq!(deserialized.command, "cargo test");
467        assert_eq!(deserialized.description, "Running tests");
468    }
469
470    #[test]
471    fn test_extension_type_equality() {
472        // Test ExtensionType equality
473        assert_eq!(ExtensionType::ClaudeCode, ExtensionType::ClaudeCode);
474        assert_eq!(ExtensionType::Codex, ExtensionType::Codex);
475        assert_eq!(ExtensionType::Copilot, ExtensionType::Copilot);
476        assert_eq!(ExtensionType::Gemini, ExtensionType::Gemini);
477        assert_eq!(ExtensionType::Grok.to_string(), "Grok");
478
479        assert_ne!(ExtensionType::ClaudeCode, ExtensionType::Codex);
480        assert_ne!(ExtensionType::Copilot, ExtensionType::Gemini);
481    }
482
483    #[test]
484    fn test_extension_type_clone() {
485        // Test ExtensionType can be cloned
486        let ext1 = ExtensionType::ClaudeCode;
487        let ext2 = ext1;
488
489        assert_eq!(ext1, ext2);
490    }
491
492    #[test]
493    fn test_extension_type_debug() {
494        // Test ExtensionType debug formatting
495        let ext = ExtensionType::ClaudeCode;
496        let debug_str = format!("{:?}", ext);
497
498        assert!(debug_str.contains("ClaudeCode"));
499    }
500
501    #[test]
502    fn test_code_analysis_tool_calls_clone() {
503        // Test cloning CodeAnalysisToolCalls
504        let tool_calls1 = CodeAnalysisToolCalls {
505            read: 5,
506            write: 3,
507            edit: 2,
508            todo_write: 1,
509            bash: 4,
510        };
511
512        let tool_calls2 = tool_calls1.clone();
513
514        assert_eq!(tool_calls1.read, tool_calls2.read);
515        assert_eq!(tool_calls1.write, tool_calls2.write);
516    }
517
518    #[test]
519    fn test_camel_case_serialization() {
520        // Test that serialization uses camelCase
521        let detail = CodeAnalysisDetailBase {
522            file_path: "/test".to_string(),
523            line_count: 10,
524            character_count: 100,
525            timestamp: 123,
526        };
527
528        let json = serde_json::to_value(&detail).unwrap();
529
530        // Should have camelCase keys
531        assert!(json["filePath"].is_string());
532        assert!(json["lineCount"].is_number());
533        assert!(json["characterCount"].is_number());
534        assert!(json["timestamp"].is_number());
535    }
536
537    #[test]
538    fn test_pascal_case_tool_calls() {
539        // Test that tool calls use PascalCase
540        let tool_calls = CodeAnalysisToolCalls {
541            read: 1,
542            write: 2,
543            edit: 3,
544            todo_write: 4,
545            bash: 5,
546        };
547
548        let json = serde_json::to_value(&tool_calls).unwrap();
549
550        // Should have PascalCase keys (first letter capitalized)
551        assert!(json["Read"].is_number());
552        assert!(json["Write"].is_number());
553        assert!(json["Edit"].is_number());
554    }
555
556    #[test]
557    fn test_code_analysis_record_serialization() {
558        // Test serializing full CodeAnalysisRecord
559        let record = CodeAnalysisRecord {
560            total_unique_files: 5,
561            total_write_lines: 100,
562            total_read_lines: 200,
563            total_edit_lines: 50,
564            total_write_characters: 2500,
565            total_read_characters: 5000,
566            total_edit_characters: 1250,
567            write_file_details: vec![],
568            read_file_details: vec![],
569            edit_file_details: vec![],
570            run_command_details: vec![],
571            tool_call_counts: CodeAnalysisToolCalls::default(),
572            conversation_usage: FastHashMap::default(),
573            advisor_usage: FastHashMap::default(),
574            task_id: "task-123".to_string(),
575            timestamp: 1234567890,
576            folder_path: "/workspace".to_string(),
577            git_remote_url: "https://github.com/test/repo".to_string(),
578        };
579
580        let json = serde_json::to_string(&record).unwrap();
581        let deserialized: CodeAnalysisRecord = serde_json::from_str(&json).unwrap();
582
583        assert_eq!(deserialized.total_unique_files, 5);
584        assert_eq!(deserialized.total_write_lines, 100);
585        assert_eq!(deserialized.task_id, "task-123");
586        assert_eq!(deserialized.folder_path, "/workspace");
587    }
588
589    #[test]
590    fn test_empty_details_serialization() {
591        // Test serializing empty detail vectors
592        let record = CodeAnalysisRecord {
593            total_unique_files: 0,
594            total_write_lines: 0,
595            total_read_lines: 0,
596            total_edit_lines: 0,
597            total_write_characters: 0,
598            total_read_characters: 0,
599            total_edit_characters: 0,
600            write_file_details: vec![],
601            read_file_details: vec![],
602            edit_file_details: vec![],
603            run_command_details: vec![],
604            tool_call_counts: CodeAnalysisToolCalls::default(),
605            conversation_usage: FastHashMap::default(),
606            advisor_usage: FastHashMap::default(),
607            task_id: String::new(),
608            timestamp: 0,
609            folder_path: String::new(),
610            git_remote_url: String::new(),
611        };
612
613        let json = serde_json::to_string(&record).unwrap();
614        let deserialized: CodeAnalysisRecord = serde_json::from_str(&json).unwrap();
615
616        assert_eq!(deserialized.write_file_details.len(), 0);
617        assert_eq!(deserialized.read_file_details.len(), 0);
618        assert_eq!(deserialized.edit_file_details.len(), 0);
619        assert_eq!(deserialized.run_command_details.len(), 0);
620    }
621}