vct-core 2.3.1

Vibe Coding Tracker core library - parse local AI coding assistant session data into CodeAnalysis results
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
//! Normalized, cross-provider analysis result types.
//!
//! These structs are the analyzer's output shape: every provider parser in
//! `src/session/` produces a [`CodeAnalysis`] regardless of the source
//! assistant, and the `analysis` / `usage` roll-up layers consume them. The
//! `serde` attributes here also define the JSON layout of the golden fixtures,
//! `vct analysis FILE`, and each element of the batch `vct analysis --json`
//! array.

use crate::constants::FastHashMap;
use serde::{Deserialize, Serialize, Serializer, ser::SerializeMap};

/// Serializes a model-keyed usage map in lexical key order.
///
/// [`FastHashMap`] intentionally randomizes iteration order, but analysis JSON
/// is a persisted public format. Sorting only at this boundary keeps the hot
/// parser path fast while making repeated serialization byte-for-byte stable.
fn serialize_conversation_usage<S>(
    usage: &FastHashMap<String, serde_json::Value>,
    serializer: S,
) -> Result<S::Ok, S::Error>
where
    S: Serializer,
{
    let mut entries: Vec<_> = usage.iter().collect();
    entries.sort_unstable_by_key(|(model, _)| *model);

    let mut map = serializer.serialize_map(Some(entries.len()))?;
    for (model, value) in entries {
        map.serialize_entry(model, value)?;
    }
    map.end()
}

/// Fields shared by every per-operation detail record.
///
/// `line_count` and `character_count` are measured on the *trimmed* payload,
/// and `character_count` counts Unicode scalar values (`str::chars`), not
/// bytes. Serialized with camelCase keys (`filePath`, `lineCount`, …).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CodeAnalysisDetailBase {
    /// Absolute path of the file the operation targeted.
    pub file_path: String,
    /// Number of lines in the operation payload.
    pub line_count: usize,
    /// Number of Unicode scalar values in the operation payload.
    pub character_count: usize,
    /// Unix epoch timestamp (milliseconds) when the operation occurred.
    pub timestamp: i64,
}

/// A single file-write operation, including the full written content.
///
/// The `base` fields are flattened into the same JSON object (no nested
/// `base` key), so the record serializes as `{filePath, lineCount, …, content}`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CodeAnalysisWriteDetail {
    /// Shared path / line / character / timestamp metadata, flattened inline.
    #[serde(flatten)]
    pub base: CodeAnalysisDetailBase,
    /// Full content written to the file.
    pub content: String,
}

/// A single file-read operation.
///
/// Carries only the shared [`CodeAnalysisDetailBase`] metadata; the file body
/// itself is not retained.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CodeAnalysisReadDetail {
    /// Shared path / line / character / timestamp metadata, flattened inline.
    #[serde(flatten)]
    pub base: CodeAnalysisDetailBase,
}

/// A single file-edit operation, recording the before/after strings.
///
/// `line_count` / `character_count` in `base` describe the new (replacement)
/// text. Serializes with the `base` fields flattened inline.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CodeAnalysisApplyDiffDetail {
    /// Shared path / line / character / timestamp metadata, flattened inline.
    #[serde(flatten)]
    pub base: CodeAnalysisDetailBase,
    /// Text that was replaced.
    pub old_string: String,
    /// Text that replaced `old_string`.
    pub new_string: String,
}

/// A single shell-command execution.
///
/// For run-command records `base.file_path` holds the session's working
/// directory (there is no single target file) and `base.line_count` is `0`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CodeAnalysisRunCommandDetail {
    /// Shared metadata; `file_path` is the working directory, `line_count` is 0.
    #[serde(flatten)]
    pub base: CodeAnalysisDetailBase,
    /// The command line that was executed.
    pub command: String,
    /// Human-readable description of the command, when the assistant supplied one.
    pub description: String,
}

/// Per-session counters for each tool the analyzer tracks.
///
/// Serialized with PascalCase keys (`Read`, `Write`, `Edit`, `TodoWrite`,
/// `Bash`).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct CodeAnalysisToolCalls {
    /// Number of file-read tool calls.
    pub read: usize,
    /// Number of file-write tool calls.
    pub write: usize,
    /// Number of file-edit tool calls.
    pub edit: usize,
    /// Number of todo-list update tool calls.
    pub todo_write: usize,
    /// Number of shell-command tool calls.
    pub bash: usize,
}

/// Aggregated metrics and per-operation details for a single coding session.
///
/// One record corresponds to one session file. When parsed in
/// `ParseMode::UsageOnly` the `*_file_details` / `run_command_details` vectors
/// are left empty to avoid allocating large bodies, while the `total_*`
/// counters and `conversation_usage` are still populated.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CodeAnalysisRecord {
    /// Count of distinct files touched (read, written, or edited) in the session.
    pub total_unique_files: usize,
    /// Sum of lines written across all write operations.
    pub total_write_lines: usize,
    /// Sum of lines read across all read operations.
    pub total_read_lines: usize,
    /// Sum of replacement lines across all edit operations.
    pub total_edit_lines: usize,
    /// Sum of characters written across all write operations.
    pub total_write_characters: usize,
    /// Sum of characters read across all read operations.
    pub total_read_characters: usize,
    /// Sum of replacement characters across all edit operations.
    pub total_edit_characters: usize,
    /// Per-operation write records (empty in `ParseMode::UsageOnly`).
    pub write_file_details: Vec<CodeAnalysisWriteDetail>,
    /// Per-operation read records (empty in `ParseMode::UsageOnly`).
    pub read_file_details: Vec<CodeAnalysisReadDetail>,
    /// Per-operation edit records (empty in `ParseMode::UsageOnly`).
    pub edit_file_details: Vec<CodeAnalysisApplyDiffDetail>,
    /// Per-command shell execution records (empty in `ParseMode::UsageOnly`).
    pub run_command_details: Vec<CodeAnalysisRunCommandDetail>,
    /// Tool-call counters for the session.
    pub tool_call_counts: CodeAnalysisToolCalls,
    /// Token-usage payloads keyed by model name; shape varies by provider
    /// (see [`crate::models::UsageResult`]).
    #[serde(serialize_with = "serialize_conversation_usage")]
    pub conversation_usage: FastHashMap<String, serde_json::Value>,
    /// Token usage from Claude Code `advisor_message` iterations, keyed by the
    /// advisor's own model. Kept **out** of `conversation_usage` on purpose:
    /// the `analysis` aggregator attributes a record's file-operation / tool
    /// counts to every model in `conversation_usage`, but an advisor model
    /// never executes tools, so adding it there would mis-attribute the main
    /// model's metrics to the advisor. The `usage` path merges this in (priced
    /// at the advisor's own rate); `analysis` ignores it. Not serialized, so
    /// the `analysis` JSON / golden output is unaffected.
    #[serde(skip)]
    pub advisor_usage: FastHashMap<String, serde_json::Value>,
    /// Session / task identifier from the source log.
    pub task_id: String,
    /// Unix epoch timestamp (milliseconds) of the session's last activity.
    pub timestamp: i64,
    /// Working directory the session ran in.
    pub folder_path: String,
    /// Git remote URL of the project, when one was detected.
    pub git_remote_url: String,
}

/// Top-level analysis result: environment metadata plus one record per session.
///
/// This is the shape returned by `parse_session_file_typed`, printed directly
/// by `vct analysis FILE`, and used for every element of the batch
/// `vct analysis --json` array. The `insights_version`, `machine_id`, and
/// `user` fields are environment-specific and are deliberately ignored by the
/// golden fixture tests.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CodeAnalysis {
    /// OS user name that owned the session.
    pub user: String,
    /// Source assistant label (e.g. `Claude-Code`), as produced by
    /// [`ExtensionType`]'s [`std::fmt::Display`].
    pub extension_name: String,
    /// Version string of the analyzer that produced this result.
    pub insights_version: String,
    /// Stable machine identifier for the host.
    pub machine_id: String,
    /// One [`CodeAnalysisRecord`] per parsed session.
    pub records: Vec<CodeAnalysisRecord>,
}

/// Single row of aggregated file-operation metrics for one model.
///
/// Counts are summed across every session that used the model in the active
/// time range. The `*_lines` fields total the lines touched by edit/read/write
/// operations; the `*_count` fields total how many times each tool was called.
/// Serializes with camelCase field names for library callers that persist a
/// compact projection. The CLI's `analysis --json` output uses the full
/// `CodeAnalysis` shape instead.
///
/// This is a neutral data type shared by the `analysis` roll-up and the
/// process-local scan cache, so it lives in `models` rather than either
/// feature.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AggregatedAnalysisRow {
    /// Model name the metrics are grouped under.
    pub model: String,
    /// Total lines changed by `Edit`/`MultiEdit` operations.
    pub edit_lines: usize,
    /// Total lines returned by `Read` operations.
    pub read_lines: usize,
    /// Total lines emitted by `Write` operations.
    pub write_lines: usize,
    /// Number of `Bash` tool calls.
    pub bash_count: usize,
    /// Number of `Edit` tool calls.
    pub edit_count: usize,
    /// Number of `Read` tool calls.
    pub read_count: usize,
    /// Number of `TodoWrite` tool calls.
    pub todo_write_count: usize,
    /// Number of `Write` tool calls.
    pub write_count: usize,
}

/// The AI coding assistant a session came from.
///
/// Distinct from [`crate::models::Provider`]: `ExtensionType` is the
/// concrete assistants the detector resolves a session file to (there is no
/// `Unknown` variant), and its [`std::fmt::Display`] produces the hyphenated
/// `extension_name` strings stored in [`CodeAnalysis`].
///
/// # Examples
///
/// ```
/// use vct_core::models::ExtensionType;
///
/// assert_eq!(ExtensionType::Copilot.to_string(), "Copilot-CLI");
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ExtensionType {
    /// Anthropic Claude Code.
    ClaudeCode,
    /// OpenAI Codex CLI.
    Codex,
    /// GitHub Copilot CLI.
    Copilot,
    /// Google Gemini CLI.
    Gemini,
    /// OpenCode.
    OpenCode,
    /// Cursor CLI / IDE.
    Cursor,
    /// Hermes.
    Hermes,
    /// xAI Grok CLI.
    Grok,
}

impl ExtensionType {
    /// Stable scan / display order shared by every provider fan-out.
    ///
    /// This is the single source of truth for "provider order": diagnostics
    /// sorting, cached-scan ranking, and the descriptor table all defer to it,
    /// so a new provider only has to be slotted in here.
    pub fn scan_rank(self) -> u8 {
        match self {
            ExtensionType::ClaudeCode => 0,
            ExtensionType::Codex => 1,
            ExtensionType::Copilot => 2,
            ExtensionType::Gemini => 3,
            ExtensionType::Grok => 4,
            ExtensionType::OpenCode => 5,
            ExtensionType::Cursor => 6,
            ExtensionType::Hermes => 7,
        }
    }
}

impl std::fmt::Display for ExtensionType {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ExtensionType::ClaudeCode => write!(f, "Claude-Code"),
            ExtensionType::Codex => write!(f, "Codex"),
            ExtensionType::Copilot => write!(f, "Copilot-CLI"),
            ExtensionType::Gemini => write!(f, "Gemini"),
            ExtensionType::OpenCode => write!(f, "OpenCode"),
            ExtensionType::Cursor => write!(f, "Cursor"),
            ExtensionType::Hermes => write!(f, "Hermes"),
            ExtensionType::Grok => write!(f, "Grok"),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn code_analysis_literal_has_no_parser_diagnostic_field() {
        let analysis = CodeAnalysis {
            user: String::new(),
            extension_name: String::new(),
            insights_version: String::new(),
            machine_id: String::new(),
            records: Vec::new(),
        };

        assert_eq!(
            serde_json::to_value(analysis).unwrap()["records"],
            serde_json::json!([])
        );
    }

    #[test]
    fn test_code_analysis_tool_calls_serialization() {
        // Test serializing CodeAnalysisToolCalls
        let tool_calls = CodeAnalysisToolCalls {
            read: 10,
            write: 5,
            edit: 3,
            todo_write: 2,
            bash: 1,
        };

        let json = serde_json::to_string(&tool_calls).unwrap();
        let deserialized: CodeAnalysisToolCalls = serde_json::from_str(&json).unwrap();

        assert_eq!(deserialized.read, 10);
        assert_eq!(deserialized.write, 5);
        assert_eq!(deserialized.edit, 3);
        assert_eq!(deserialized.todo_write, 2);
        assert_eq!(deserialized.bash, 1);
    }

    #[test]
    fn test_code_analysis_tool_calls_default() {
        // Test default values
        let tool_calls = CodeAnalysisToolCalls::default();

        assert_eq!(tool_calls.read, 0);
        assert_eq!(tool_calls.write, 0);
        assert_eq!(tool_calls.edit, 0);
        assert_eq!(tool_calls.todo_write, 0);
        assert_eq!(tool_calls.bash, 0);
    }

    #[test]
    fn test_code_analysis_detail_base_serialization() {
        // Test serializing CodeAnalysisDetailBase
        let detail = CodeAnalysisDetailBase {
            file_path: "/path/to/file.rs".to_string(),
            line_count: 100,
            character_count: 2500,
            timestamp: 1234567890,
        };

        let json = serde_json::to_string(&detail).unwrap();
        let deserialized: CodeAnalysisDetailBase = serde_json::from_str(&json).unwrap();

        assert_eq!(deserialized.file_path, "/path/to/file.rs");
        assert_eq!(deserialized.line_count, 100);
        assert_eq!(deserialized.character_count, 2500);
        assert_eq!(deserialized.timestamp, 1234567890);
    }

    #[test]
    fn test_code_analysis_write_detail_serialization() {
        // Test serializing CodeAnalysisWriteDetail
        let write_detail = CodeAnalysisWriteDetail {
            base: CodeAnalysisDetailBase {
                file_path: "/test/file.rs".to_string(),
                line_count: 10,
                character_count: 250,
                timestamp: 1234567890,
            },
            content: "fn main() {\n    println!(\"Hello\");\n}".to_string(),
        };

        let json = serde_json::to_string(&write_detail).unwrap();
        let deserialized: CodeAnalysisWriteDetail = serde_json::from_str(&json).unwrap();

        assert_eq!(deserialized.base.file_path, "/test/file.rs");
        assert_eq!(deserialized.base.line_count, 10);
        assert!(deserialized.content.contains("main"));
    }

    #[test]
    fn test_code_analysis_read_detail_serialization() {
        // Test serializing CodeAnalysisReadDetail
        let read_detail = CodeAnalysisReadDetail {
            base: CodeAnalysisDetailBase {
                file_path: "/test/input.txt".to_string(),
                line_count: 50,
                character_count: 1200,
                timestamp: 1234567890,
            },
        };

        let json = serde_json::to_string(&read_detail).unwrap();
        let deserialized: CodeAnalysisReadDetail = serde_json::from_str(&json).unwrap();

        assert_eq!(deserialized.base.file_path, "/test/input.txt");
        assert_eq!(deserialized.base.line_count, 50);
        assert_eq!(deserialized.base.character_count, 1200);
    }

    #[test]
    fn test_code_analysis_apply_diff_detail_serialization() {
        // Test serializing CodeAnalysisApplyDiffDetail
        let edit_detail = CodeAnalysisApplyDiffDetail {
            base: CodeAnalysisDetailBase {
                file_path: "/test/edit.rs".to_string(),
                line_count: 5,
                character_count: 100,
                timestamp: 1234567890,
            },
            old_string: "old content".to_string(),
            new_string: "new content".to_string(),
        };

        let json = serde_json::to_string(&edit_detail).unwrap();
        let deserialized: CodeAnalysisApplyDiffDetail = serde_json::from_str(&json).unwrap();

        assert_eq!(deserialized.base.file_path, "/test/edit.rs");
        assert_eq!(deserialized.old_string, "old content");
        assert_eq!(deserialized.new_string, "new content");
    }

    #[test]
    fn test_code_analysis_run_command_detail_serialization() {
        // Test serializing CodeAnalysisRunCommandDetail
        let run_detail = CodeAnalysisRunCommandDetail {
            base: CodeAnalysisDetailBase {
                file_path: "/workspace".to_string(),
                line_count: 0,
                character_count: 10,
                timestamp: 1234567890,
            },
            command: "cargo test".to_string(),
            description: "Running tests".to_string(),
        };

        let json = serde_json::to_string(&run_detail).unwrap();
        let deserialized: CodeAnalysisRunCommandDetail = serde_json::from_str(&json).unwrap();

        assert_eq!(deserialized.command, "cargo test");
        assert_eq!(deserialized.description, "Running tests");
    }

    #[test]
    fn test_extension_type_equality() {
        // Test ExtensionType equality
        assert_eq!(ExtensionType::ClaudeCode, ExtensionType::ClaudeCode);
        assert_eq!(ExtensionType::Codex, ExtensionType::Codex);
        assert_eq!(ExtensionType::Copilot, ExtensionType::Copilot);
        assert_eq!(ExtensionType::Gemini, ExtensionType::Gemini);
        assert_eq!(ExtensionType::Grok.to_string(), "Grok");

        assert_ne!(ExtensionType::ClaudeCode, ExtensionType::Codex);
        assert_ne!(ExtensionType::Copilot, ExtensionType::Gemini);
    }

    #[test]
    fn test_extension_type_clone() {
        // Test ExtensionType can be cloned
        let ext1 = ExtensionType::ClaudeCode;
        let ext2 = ext1;

        assert_eq!(ext1, ext2);
    }

    #[test]
    fn test_extension_type_debug() {
        // Test ExtensionType debug formatting
        let ext = ExtensionType::ClaudeCode;
        let debug_str = format!("{:?}", ext);

        assert!(debug_str.contains("ClaudeCode"));
    }

    #[test]
    fn test_code_analysis_tool_calls_clone() {
        // Test cloning CodeAnalysisToolCalls
        let tool_calls1 = CodeAnalysisToolCalls {
            read: 5,
            write: 3,
            edit: 2,
            todo_write: 1,
            bash: 4,
        };

        let tool_calls2 = tool_calls1.clone();

        assert_eq!(tool_calls1.read, tool_calls2.read);
        assert_eq!(tool_calls1.write, tool_calls2.write);
    }

    #[test]
    fn test_camel_case_serialization() {
        // Test that serialization uses camelCase
        let detail = CodeAnalysisDetailBase {
            file_path: "/test".to_string(),
            line_count: 10,
            character_count: 100,
            timestamp: 123,
        };

        let json = serde_json::to_value(&detail).unwrap();

        // Should have camelCase keys
        assert!(json["filePath"].is_string());
        assert!(json["lineCount"].is_number());
        assert!(json["characterCount"].is_number());
        assert!(json["timestamp"].is_number());
    }

    #[test]
    fn test_pascal_case_tool_calls() {
        // Test that tool calls use PascalCase
        let tool_calls = CodeAnalysisToolCalls {
            read: 1,
            write: 2,
            edit: 3,
            todo_write: 4,
            bash: 5,
        };

        let json = serde_json::to_value(&tool_calls).unwrap();

        // Should have PascalCase keys (first letter capitalized)
        assert!(json["Read"].is_number());
        assert!(json["Write"].is_number());
        assert!(json["Edit"].is_number());
    }

    #[test]
    fn test_code_analysis_record_serialization() {
        // Test serializing full CodeAnalysisRecord
        let record = CodeAnalysisRecord {
            total_unique_files: 5,
            total_write_lines: 100,
            total_read_lines: 200,
            total_edit_lines: 50,
            total_write_characters: 2500,
            total_read_characters: 5000,
            total_edit_characters: 1250,
            write_file_details: vec![],
            read_file_details: vec![],
            edit_file_details: vec![],
            run_command_details: vec![],
            tool_call_counts: CodeAnalysisToolCalls::default(),
            conversation_usage: FastHashMap::default(),
            advisor_usage: FastHashMap::default(),
            task_id: "task-123".to_string(),
            timestamp: 1234567890,
            folder_path: "/workspace".to_string(),
            git_remote_url: "https://github.com/test/repo".to_string(),
        };

        let json = serde_json::to_string(&record).unwrap();
        let deserialized: CodeAnalysisRecord = serde_json::from_str(&json).unwrap();

        assert_eq!(deserialized.total_unique_files, 5);
        assert_eq!(deserialized.total_write_lines, 100);
        assert_eq!(deserialized.task_id, "task-123");
        assert_eq!(deserialized.folder_path, "/workspace");
    }

    #[test]
    fn test_empty_details_serialization() {
        // Test serializing empty detail vectors
        let record = CodeAnalysisRecord {
            total_unique_files: 0,
            total_write_lines: 0,
            total_read_lines: 0,
            total_edit_lines: 0,
            total_write_characters: 0,
            total_read_characters: 0,
            total_edit_characters: 0,
            write_file_details: vec![],
            read_file_details: vec![],
            edit_file_details: vec![],
            run_command_details: vec![],
            tool_call_counts: CodeAnalysisToolCalls::default(),
            conversation_usage: FastHashMap::default(),
            advisor_usage: FastHashMap::default(),
            task_id: String::new(),
            timestamp: 0,
            folder_path: String::new(),
            git_remote_url: String::new(),
        };

        let json = serde_json::to_string(&record).unwrap();
        let deserialized: CodeAnalysisRecord = serde_json::from_str(&json).unwrap();

        assert_eq!(deserialized.write_file_details.len(), 0);
        assert_eq!(deserialized.read_file_details.len(), 0);
        assert_eq!(deserialized.edit_file_details.len(), 0);
        assert_eq!(deserialized.run_command_details.len(), 0);
    }
}