1use crate::VERSION;
23use crate::constants::FastHashMap;
24use crate::models::TimeRange;
25use crate::models::{CodeAnalysis, CodeAnalysisRecord, ExtensionType};
26use crate::session::diagnostics::{
27 DatabaseAnalysisRow, DatabaseUsageRead, UsageContribution, UsageTokenContribution,
28};
29use crate::session::sqlite::with_readonly_connection;
30use crate::session::state::{ParseMode, SessionParseState};
31use crate::utils::{get_current_user, get_machine_id};
32use anyhow::{Result, anyhow};
33use rusqlite::Connection;
34use serde_json::Value;
35#[cfg(test)]
36use serde_json::json;
37use std::collections::HashMap;
38use std::path::Path;
39
40pub fn read_opencode_usage(
53 db_path: &Path,
54 time_range: TimeRange,
55) -> Result<Vec<(String, CodeAnalysis, f64)>> {
56 let user = get_current_user();
57 let machine = get_machine_id().to_string();
58 let read = read_opencode_usage_contributions(db_path, time_range)?;
59 if read.expected_records > 0 && read.parsed_records == 0 {
60 return Err(anyhow!(
61 "none of {} OpenCode usage records used a recognized schema",
62 read.expected_records
63 ));
64 }
65 if read.failed_records() > 0 {
66 log::warn!(
67 "{} OpenCode usage records used an unsupported schema",
68 read.failed_records()
69 );
70 }
71 Ok(read
72 .rows
73 .into_iter()
74 .map(|row| row.into_public_row(ExtensionType::OpenCode, &user, &machine))
75 .collect())
76}
77
78pub(crate) fn read_opencode_usage_contributions(
80 db_path: &Path,
81 time_range: TimeRange,
82) -> Result<DatabaseUsageRead> {
83 with_readonly_connection(db_path, "session", "vct-opencode-", "OpenCode", |conn| {
84 collect_usage(conn, time_range)
85 })
86}
87
88pub fn read_opencode_analysis(
102 db_path: &Path,
103 time_range: TimeRange,
104 mode: ParseMode,
105) -> Result<Vec<(String, CodeAnalysis)>> {
106 let result = read_opencode_analysis_with_diagnostics(db_path, time_range, mode)?;
107 if result.expected_records > 0 && result.parsed_records == 0 {
108 return Err(anyhow!(
109 "none of {} OpenCode analysis records used a recognized schema",
110 result.expected_records
111 ));
112 }
113 let failed_records = result
114 .expected_records
115 .saturating_sub(result.parsed_records)
116 + result.failed_tool_parts;
117 if failed_records > 0 {
118 log::warn!(
119 "skipped {} OpenCode analysis records with unsupported schema",
120 failed_records
121 );
122 }
123 Ok(result
124 .rows
125 .into_iter()
126 .map(|row| (row.date, row.analysis))
127 .collect())
128}
129
130pub(crate) struct OpenCodeAnalysisRead {
132 pub rows: Vec<DatabaseAnalysisRow>,
134 pub expected_records: usize,
136 pub parsed_records: usize,
138 pub failed_tool_parts: usize,
140}
141
142pub(crate) fn read_opencode_analysis_with_diagnostics(
144 db_path: &Path,
145 time_range: TimeRange,
146 mode: ParseMode,
147) -> Result<OpenCodeAnalysisRead> {
148 with_readonly_connection(db_path, "session", "vct-opencode-", "OpenCode", |conn| {
149 let transaction = conn.unchecked_transaction()?;
150 let expected_records = count_analysis_candidates(&transaction, time_range)?;
151 let collected = collect_analysis(&transaction, time_range, mode)?;
152 let parsed_records = collected.rows.len();
153 transaction.commit()?;
154 Ok(OpenCodeAnalysisRead {
155 rows: collected.rows,
156 expected_records,
157 parsed_records,
158 failed_tool_parts: collected.failed_tool_parts,
159 })
160 })
161}
162
163struct MessageUsage {
165 model_id: String,
166 usage: UsageTokenContribution,
167 cost: f64,
168 timestamp: Option<i64>,
169}
170
171struct AnalysisAccum {
173 model_id: String,
174 date: String,
175 usage: Value,
176 state: SessionParseState,
177}
178
179struct CollectedAnalysis {
180 rows: Vec<DatabaseAnalysisRow>,
181 failed_tool_parts: usize,
182}
183
184fn count_analysis_candidates(conn: &Connection, time_range: TimeRange) -> Result<usize> {
186 let cutoff_ms = cutoff_millis(time_range);
187 let (sql, parameterized) = if table_exists(conn, "message")? {
188 match cutoff_ms {
189 Some(_) => (
190 "SELECT COUNT(*) FROM message \
191 JOIN session ON session.id = message.session_id \
192 WHERE json_extract(message.data, '$.role') = 'assistant' \
193 AND COALESCE( \
194 json_extract(message.data, '$.time.completed'), \
195 json_extract(message.data, '$.time.created'), \
196 session.time_updated \
197 ) >= ?1",
198 true,
199 ),
200 None => (
201 "SELECT COUNT(*) FROM message \
202 WHERE json_extract(message.data, '$.role') = 'assistant'",
203 false,
204 ),
205 }
206 } else {
207 match cutoff_ms {
208 Some(_) => (
209 "SELECT COUNT(*) FROM session \
210 WHERE model IS NOT NULL AND model != '' AND time_updated >= ?1",
211 true,
212 ),
213 None => (
214 "SELECT COUNT(*) FROM session WHERE model IS NOT NULL AND model != ''",
215 false,
216 ),
217 }
218 };
219 let count: i64 = if parameterized {
220 conn.query_row(sql, [cutoff_ms.unwrap_or_default()], |row| row.get(0))?
221 } else {
222 conn.query_row(sql, [], |row| row.get(0))?
223 };
224 Ok(usize::try_from(count).unwrap_or_default())
225}
226
227fn collect_usage(conn: &Connection, time_range: TimeRange) -> Result<DatabaseUsageRead> {
229 if table_exists(conn, "message")? {
230 return collect_message_usage(conn, time_range);
231 }
232
233 collect_session_usage(conn, time_range)
234}
235
236fn collect_session_usage(conn: &Connection, time_range: TimeRange) -> Result<DatabaseUsageRead> {
238 let cutoff = cutoff_string(time_range);
239 let cutoff_ms = cutoff_millis(time_range);
240
241 let sql = match cutoff_ms {
242 Some(_) => {
243 "SELECT model, tokens_input, tokens_output, tokens_reasoning, \
244 tokens_cache_read, tokens_cache_write, time_updated, cost \
245 FROM session WHERE model IS NOT NULL AND model != '' AND time_updated >= ?1"
246 }
247 None => {
248 "SELECT model, tokens_input, tokens_output, tokens_reasoning, \
249 tokens_cache_read, tokens_cache_write, time_updated, cost \
250 FROM session WHERE model IS NOT NULL AND model != ''"
251 }
252 };
253 let mut stmt = conn.prepare(sql)?;
254 let mut rows = match cutoff_ms {
255 Some(cutoff_ms) => stmt.query([cutoff_ms])?,
256 None => stmt.query([])?,
257 };
258
259 let mut out = Vec::new();
260 let mut expected_records = 0usize;
261 while let Some(row) = rows.next()? {
262 expected_records += 1;
263 let model = row.get::<_, String>(0)?;
264 let input = row.get::<_, i64>(1)?;
265 let output = row.get::<_, i64>(2)?;
266 let reasoning = row.get::<_, i64>(3)?;
267 let cache_read = row.get::<_, i64>(4)?;
268 let cache_write = row.get::<_, i64>(5)?;
269 let time_updated = row.get::<_, i64>(6)?;
270 let cost = row.get::<_, f64>(7)?;
271 let Some(model_id) = parse_model_id(&model) else {
272 continue;
273 };
274 let Some(date) = ms_to_local_date(time_updated) else {
275 continue;
276 };
277 if is_before_cutoff(&date, &cutoff) {
278 continue;
279 }
280
281 out.push(UsageContribution::single_model(
282 date,
283 time_updated,
284 model_id,
285 session_usage_value(input, output, reasoning, cache_read, cache_write),
286 cost,
287 ));
288 }
289
290 let parsed_records = out.len();
291 Ok(DatabaseUsageRead {
292 rows: out,
293 expected_records,
294 parsed_records,
295 })
296}
297
298fn collect_message_usage(conn: &Connection, time_range: TimeRange) -> Result<DatabaseUsageRead> {
300 let cutoff = cutoff_string(time_range);
301 let cutoff_ms = cutoff_millis(time_range);
302
303 let sql = match cutoff_ms {
304 Some(_) => {
305 "SELECT session.time_updated, message.data \
306 FROM message \
307 JOIN session ON session.id = message.session_id \
308 WHERE json_extract(message.data, '$.role') = 'assistant' \
309 AND COALESCE( \
310 json_extract(message.data, '$.time.completed'), \
311 json_extract(message.data, '$.time.created'), \
312 session.time_updated \
313 ) >= ?1"
314 }
315 None => {
316 "SELECT session.time_updated, message.data \
317 FROM message \
318 JOIN session ON session.id = message.session_id \
319 WHERE json_extract(message.data, '$.role') = 'assistant'"
320 }
321 };
322 let mut stmt = conn.prepare(sql)?;
323 let mut rows = match cutoff_ms {
324 Some(cutoff_ms) => stmt.query([cutoff_ms])?,
325 None => stmt.query([])?,
326 };
327
328 let mut out = Vec::new();
329 let mut expected_records = 0usize;
330 while let Some(row) = rows.next()? {
331 expected_records += 1;
332 let session_ts = row.get::<_, i64>(0)?;
333 let data_text = row.get::<_, String>(1)?;
334 let Some(message) = parse_message_usage(&data_text) else {
335 continue;
336 };
337 let message_ts = message.timestamp.unwrap_or(session_ts);
338 let Some(date) = ms_to_local_date(message_ts) else {
339 continue;
340 };
341 if is_before_cutoff(&date, &cutoff) {
342 continue;
343 }
344
345 out.push(UsageContribution::single_model(
346 date,
347 message_ts,
348 message.model_id,
349 message.usage,
350 message.cost,
351 ));
352 }
353
354 let parsed_records = out.len();
355 Ok(DatabaseUsageRead {
356 rows: out,
357 expected_records,
358 parsed_records,
359 })
360}
361
362fn collect_analysis(
364 conn: &Connection,
365 time_range: TimeRange,
366 mode: ParseMode,
367) -> Result<CollectedAnalysis> {
368 if table_exists(conn, "message")? {
369 return collect_message_analysis(conn, time_range, mode);
370 }
371
372 collect_session_analysis(conn, time_range, mode)
373}
374
375fn collect_session_analysis(
377 conn: &Connection,
378 time_range: TimeRange,
379 mode: ParseMode,
380) -> Result<CollectedAnalysis> {
381 let user = get_current_user();
382 let machine = get_machine_id().to_string();
383 let cutoff = cutoff_string(time_range);
384 let cutoff_ms = cutoff_millis(time_range);
385
386 let mut sessions: HashMap<String, AnalysisAccum> = HashMap::new();
388 {
389 let sql = match cutoff_ms {
390 Some(_) => {
391 "SELECT id, model, directory, time_updated, tokens_input, tokens_output, \
392 tokens_reasoning, tokens_cache_read, tokens_cache_write \
393 FROM session \
394 WHERE model IS NOT NULL AND model != '' AND time_updated >= ?1"
395 }
396 None => {
397 "SELECT id, model, directory, time_updated, tokens_input, tokens_output, \
398 tokens_reasoning, tokens_cache_read, tokens_cache_write \
399 FROM session WHERE model IS NOT NULL AND model != ''"
400 }
401 };
402 let mut stmt = conn.prepare(sql)?;
403 let mut rows = match cutoff_ms {
404 Some(cutoff_ms) => stmt.query([cutoff_ms])?,
405 None => stmt.query([])?,
406 };
407
408 while let Some(row) = rows.next()? {
409 let id = row.get::<_, String>(0)?;
410 let model = row.get::<_, String>(1)?;
411 let directory = row.get::<_, String>(2)?;
412 let ts = row.get::<_, i64>(3)?;
413 let input = row.get::<_, i64>(4)?;
414 let output = row.get::<_, i64>(5)?;
415 let reasoning = row.get::<_, i64>(6)?;
416 let cache_read = row.get::<_, i64>(7)?;
417 let cache_write = row.get::<_, i64>(8)?;
418 let Some(model_id) = parse_model_id(&model) else {
419 continue;
420 };
421 let Some(date) = ms_to_local_date(ts) else {
422 continue;
423 };
424
425 let usage =
426 session_usage_value(input, output, reasoning, cache_read, cache_write).into_value();
427 let mut state = SessionParseState::with_mode(mode);
428 state.folder_path = directory;
429 state.task_id = id.clone();
430 state.last_ts = ts;
431
432 sessions.insert(
433 id,
434 AnalysisAccum {
435 model_id,
436 date,
437 usage,
438 state,
439 },
440 );
441 }
442 }
443
444 let mut failed_tool_parts = 0usize;
446 {
447 let sql = match cutoff_ms {
448 Some(_) => {
449 "SELECT part.session_id, part.data \
450 FROM part \
451 JOIN session ON session.id = part.session_id \
452 WHERE json_extract(part.data, '$.type') = 'tool' \
453 AND session.model IS NOT NULL \
454 AND session.model != '' \
455 AND session.time_updated >= ?1 \
456 ORDER BY part.id"
457 }
458 None => {
459 "SELECT session_id, data \
460 FROM part \
461 WHERE json_extract(data, '$.type') = 'tool' \
462 ORDER BY id"
463 }
464 };
465 let mut stmt = conn.prepare(sql)?;
466 let mut rows = match cutoff_ms {
467 Some(cutoff_ms) => stmt.query([cutoff_ms])?,
468 None => stmt.query([])?,
469 };
470
471 while let Some(row) = rows.next()? {
472 let session_id = row.get::<_, String>(0)?;
473 let data_text = row.get::<_, String>(1)?;
474 let Some(accum) = sessions.get_mut(&session_id) else {
475 continue;
476 };
477 let Ok(data) = serde_json::from_str::<Value>(&data_text) else {
478 failed_tool_parts += 1;
479 continue;
480 };
481 if let Some("tool") = data.get("type").and_then(|v| v.as_str())
482 && apply_tool_part(&mut accum.state, &data) == ToolPartOutcome::Unsupported
483 {
484 failed_tool_parts += 1;
485 }
486 }
487 }
488
489 let mut out = Vec::with_capacity(sessions.len());
491 for (id, accum) in sessions {
492 if is_before_cutoff(&accum.date, &cutoff) {
493 continue;
494 }
495 let mut usage_map = FastHashMap::default();
496 usage_map.insert(accum.model_id, accum.usage);
497 let record = accum.state.into_record(usage_map);
498 out.push(DatabaseAnalysisRow {
499 source_id: id,
500 date: accum.date,
501 analysis: wrap_record(record, &user, &machine),
502 });
503 }
504
505 Ok(CollectedAnalysis {
506 rows: out,
507 failed_tool_parts,
508 })
509}
510
511fn collect_message_analysis(
513 conn: &Connection,
514 time_range: TimeRange,
515 mode: ParseMode,
516) -> Result<CollectedAnalysis> {
517 let user = get_current_user();
518 let machine = get_machine_id().to_string();
519 let cutoff = cutoff_string(time_range);
520 let cutoff_ms = cutoff_millis(time_range);
521
522 let mut messages: HashMap<String, AnalysisAccum> = HashMap::new();
523 {
524 let sql = match cutoff_ms {
525 Some(_) => {
526 "SELECT message.id, message.session_id, message.data, session.directory, session.time_updated \
527 FROM message \
528 JOIN session ON session.id = message.session_id \
529 WHERE json_extract(message.data, '$.role') = 'assistant' \
530 AND COALESCE( \
531 json_extract(message.data, '$.time.completed'), \
532 json_extract(message.data, '$.time.created'), \
533 session.time_updated \
534 ) >= ?1"
535 }
536 None => {
537 "SELECT message.id, message.session_id, message.data, session.directory, session.time_updated \
538 FROM message \
539 JOIN session ON session.id = message.session_id \
540 WHERE json_extract(message.data, '$.role') = 'assistant'"
541 }
542 };
543 let mut stmt = conn.prepare(sql)?;
544 let mut rows = match cutoff_ms {
545 Some(cutoff_ms) => stmt.query([cutoff_ms])?,
546 None => stmt.query([])?,
547 };
548
549 while let Some(row) = rows.next()? {
550 let message_id = row.get::<_, String>(0)?;
551 let session_id = row.get::<_, String>(1)?;
552 let data_text = row.get::<_, String>(2)?;
553 let directory = row.get::<_, String>(3)?;
554 let session_ts = row.get::<_, i64>(4)?;
555 let Some(message) = parse_message_usage(&data_text) else {
556 continue;
557 };
558 let message_ts = message.timestamp.unwrap_or(session_ts);
559 let Some(date) = ms_to_local_date(message_ts) else {
560 continue;
561 };
562 if is_before_cutoff(&date, &cutoff) {
563 continue;
564 }
565
566 let mut state = SessionParseState::with_mode(mode);
567 state.folder_path = directory;
568 state.task_id = session_id;
569 state.last_ts = message_ts;
570
571 messages.insert(
572 message_id,
573 AnalysisAccum {
574 model_id: message.model_id,
575 date,
576 usage: message.usage.into_value(),
577 state,
578 },
579 );
580 }
581 }
582
583 let mut failed_tool_parts = 0usize;
584 {
585 let sql = match cutoff_ms {
586 Some(_) => {
587 "SELECT part.message_id, part.data \
588 FROM part \
589 JOIN message ON message.id = part.message_id \
590 JOIN session ON session.id = part.session_id \
591 WHERE json_extract(message.data, '$.role') = 'assistant' \
592 AND json_extract(part.data, '$.type') = 'tool' \
593 AND COALESCE( \
594 json_extract(message.data, '$.time.completed'), \
595 json_extract(message.data, '$.time.created'), \
596 session.time_updated \
597 ) >= ?1 \
598 ORDER BY part.id"
599 }
600 None => {
601 "SELECT part.message_id, part.data \
602 FROM part \
603 JOIN message ON message.id = part.message_id \
604 WHERE json_extract(message.data, '$.role') = 'assistant' \
605 AND json_extract(part.data, '$.type') = 'tool' \
606 ORDER BY part.id"
607 }
608 };
609 let mut stmt = conn.prepare(sql)?;
610 let mut rows = match cutoff_ms {
611 Some(cutoff_ms) => stmt.query([cutoff_ms])?,
612 None => stmt.query([])?,
613 };
614
615 while let Some(row) = rows.next()? {
616 let message_id = row.get::<_, String>(0)?;
617 let data_text = row.get::<_, String>(1)?;
618 let Some(accum) = messages.get_mut(&message_id) else {
619 continue;
620 };
621 let Ok(data) = serde_json::from_str::<Value>(&data_text) else {
622 failed_tool_parts += 1;
623 continue;
624 };
625 if let Some("tool") = data.get("type").and_then(|v| v.as_str())
626 && apply_tool_part(&mut accum.state, &data) == ToolPartOutcome::Unsupported
627 {
628 failed_tool_parts += 1;
629 }
630 }
631 }
632
633 let mut out = Vec::with_capacity(messages.len());
634 for (id, accum) in messages {
635 if is_before_cutoff(&accum.date, &cutoff) {
636 continue;
637 }
638 let mut usage_map = FastHashMap::default();
639 usage_map.insert(accum.model_id, accum.usage);
640 let record = accum.state.into_record(usage_map);
641 out.push(DatabaseAnalysisRow {
642 source_id: id,
643 date: accum.date,
644 analysis: wrap_record(record, &user, &machine),
645 });
646 }
647
648 Ok(CollectedAnalysis {
649 rows: out,
650 failed_tool_parts,
651 })
652}
653
654#[derive(Clone, Copy, PartialEq, Eq)]
661enum ToolPartOutcome {
662 Irrelevant,
663 Normalized,
664 Unsupported,
665}
666
667fn apply_tool_part(state: &mut SessionParseState, data: &Value) -> ToolPartOutcome {
668 let tool = data.get("tool").and_then(|v| v.as_str()).unwrap_or("");
669 if !matches!(
670 tool,
671 "read" | "edit" | "write" | "bash" | "todowrite" | "apply_patch"
672 ) {
673 return ToolPartOutcome::Irrelevant;
674 }
675
676 let Some(st) = data.get("state").and_then(Value::as_object) else {
677 return ToolPartOutcome::Unsupported;
678 };
679 let Some(status) = st.get("status").and_then(Value::as_str) else {
680 return ToolPartOutcome::Unsupported;
681 };
682 match status {
683 "completed" => {}
684 "pending" | "running" | "error" => return ToolPartOutcome::Irrelevant,
685 _ => return ToolPartOutcome::Unsupported,
686 }
687
688 let input = st.get("input").and_then(Value::as_object);
689 let ts = st
690 .get("time")
691 .and_then(|t| t.get("start"))
692 .and_then(|v| v.as_i64())
693 .unwrap_or(state.last_ts);
694
695 let str_in = |key: &str| -> Option<&str> { input?.get(key)?.as_str() };
696
697 match tool {
698 "read" => {
699 let (Some(path), Some(output)) = (
700 str_in("filePath").filter(|path| !path.is_empty()),
701 st.get("output").and_then(Value::as_str),
702 ) else {
703 return ToolPartOutcome::Unsupported;
704 };
705 let content = extract_read_content(output);
706 if !content.is_empty() {
707 state.add_read_detail(path, &content, ts);
708 } else {
709 state.tool_counts.read += 1;
712 }
713 }
714 "edit" => {
715 let (Some(path), Some(old), Some(new)) = (
716 str_in("filePath").filter(|path| !path.is_empty()),
717 str_in("oldString"),
718 str_in("newString"),
719 ) else {
720 return ToolPartOutcome::Unsupported;
721 };
722 state.add_edit_detail(path, old, new, ts);
723 }
724 "write" => {
725 let (Some(path), Some(content)) = (
726 str_in("filePath").filter(|path| !path.is_empty()),
727 str_in("content"),
728 ) else {
729 return ToolPartOutcome::Unsupported;
730 };
731 state.add_write_detail(path, content, ts);
732 }
733 "bash" => {
734 let Some(command) = str_in("command").filter(|command| !command.trim().is_empty())
735 else {
736 return ToolPartOutcome::Unsupported;
737 };
738 state.add_run_command(command, str_in("description").unwrap_or(""), ts);
739 }
740 "todowrite" => {
741 state.tool_counts.todo_write += 1;
742 }
743 "apply_patch" => {
744 let Some(patch_text) = str_in("patchText") else {
745 return ToolPartOutcome::Unsupported;
746 };
747 if !parse_apply_patch_text(patch_text)
748 .iter()
749 .any(|patch| !patch.file_path.is_empty())
750 {
751 return ToolPartOutcome::Unsupported;
752 }
753 apply_patch_text(state, patch_text, ts);
754 }
755 _ => unreachable!("tracked OpenCode tool was filtered above"),
756 }
757
758 ToolPartOutcome::Normalized
759}
760
761fn apply_patch_text(state: &mut SessionParseState, patch_text: &str, ts: i64) {
763 for patch in parse_apply_patch_text(patch_text) {
764 let (old_str, new_str) = extract_patch_strings(&patch.lines);
765
766 match patch.action.as_str() {
767 "add" => state.add_write_detail(&patch.file_path, &new_str, ts),
768 "delete" => state.add_edit_detail(&patch.file_path, &old_str, "", ts),
769 _ => state.add_edit_detail_raw(&patch.file_path, &old_str, &new_str, ts),
771 }
772 }
773}
774
775struct OpenCodePatch {
777 action: String,
778 file_path: String,
779 lines: Vec<String>,
780}
781
782fn parse_apply_patch_text(patch_text: &str) -> Vec<OpenCodePatch> {
784 let start = match patch_text.find("*** Begin Patch") {
785 Some(idx) => idx,
786 None => return Vec::new(),
787 };
788
789 let mut patches = Vec::with_capacity(3);
790 let mut current: Option<OpenCodePatch> = None;
791 for line in patch_text[start..].lines() {
792 let line = line.trim_end_matches('\r');
793
794 if line.starts_with("*** End Patch") {
795 if let Some(patch) = current.take() {
796 patches.push(patch);
797 }
798 break;
799 } else if line.starts_with("*** Begin Patch") {
800 continue;
801 } else if line.starts_with("*** Update File:") {
802 if let Some(patch) = current.take() {
803 patches.push(patch);
804 }
805 current = Some(OpenCodePatch {
806 action: "update".to_string(),
807 file_path: line
808 .trim_start_matches("*** Update File:")
809 .trim()
810 .to_string(),
811 lines: Vec::with_capacity(20),
812 });
813 } else if line.starts_with("*** Add File:") {
814 if let Some(patch) = current.take() {
815 patches.push(patch);
816 }
817 current = Some(OpenCodePatch {
818 action: "add".to_string(),
819 file_path: line.trim_start_matches("*** Add File:").trim().to_string(),
820 lines: Vec::with_capacity(20),
821 });
822 } else if line.starts_with("*** Delete File:") {
823 if let Some(patch) = current.take() {
824 patches.push(patch);
825 }
826 current = Some(OpenCodePatch {
827 action: "delete".to_string(),
828 file_path: line
829 .trim_start_matches("*** Delete File:")
830 .trim()
831 .to_string(),
832 lines: Vec::with_capacity(20),
833 });
834 } else if line.starts_with("*** Move to:") {
835 if let Some(ref mut patch) = current {
837 patch.file_path = line.trim_start_matches("*** Move to:").trim().to_string();
838 }
839 } else if let Some(ref mut patch) = current {
840 patch.lines.push(line.to_string());
841 }
842 }
843
844 if let Some(patch) = current {
845 patches.push(patch);
846 }
847 patches
848}
849
850fn extract_patch_strings(lines: &[String]) -> (String, String) {
852 let estimated_size = lines.iter().map(|l| l.len()).sum::<usize>();
853 let mut old_str = String::with_capacity(estimated_size / 2);
854 let mut new_str = String::with_capacity(estimated_size / 2);
855
856 for line in lines {
857 if line.is_empty() || line.starts_with("@@") {
858 continue;
859 }
860
861 match line.as_bytes()[0] {
862 b'+' => {
863 new_str.push_str(&line[1..]);
864 new_str.push('\n');
865 }
866 b'-' => {
867 old_str.push_str(&line[1..]);
868 old_str.push('\n');
869 }
870 b'\\' => continue,
871 _ => {}
872 }
873 }
874
875 old_str.truncate(old_str.trim_end_matches('\n').len());
876 new_str.truncate(new_str.trim_end_matches('\n').len());
877 (old_str, new_str)
878}
879
880fn session_usage_value(
886 input: i64,
887 output: i64,
888 reasoning: i64,
889 cache_read: i64,
890 cache_write: i64,
891) -> UsageTokenContribution {
892 UsageTokenContribution {
893 input_tokens: input,
894 output_tokens: output,
895 reasoning_tokens: reasoning,
896 cache_read_tokens: cache_read,
897 cache_creation_tokens: cache_write,
898 }
899}
900
901fn parse_model_id(raw: &str) -> Option<String> {
909 let raw = raw.trim();
910 if raw.is_empty() {
911 return None;
912 }
913 match serde_json::from_str::<Value>(raw) {
914 Ok(Value::Object(map)) => {
915 let model = map.get("id").and_then(|v| v.as_str())?.trim();
916 if model.is_empty() {
917 return None;
918 }
919 Some(provider_qualified_model_id(
920 map.get("providerID")
921 .or_else(|| map.get("provider_id"))
922 .and_then(|v| v.as_str()),
923 model,
924 ))
925 }
926 Ok(Value::String(s)) => {
927 let s = s.trim();
928 (!s.is_empty()).then(|| s.to_string())
929 }
930 _ => Some(raw.to_string()),
932 }
933}
934
935fn parse_message_usage(raw: &str) -> Option<MessageUsage> {
937 let data = serde_json::from_str::<Value>(raw).ok()?;
938 if data.get("role").and_then(|v| v.as_str()) != Some("assistant") {
939 return None;
940 }
941
942 let model_id = message_model_id(&data)?;
943 let usage = parse_message_tokens(data.get("tokens")?)?;
944 let cost = data.get("cost").and_then(|v| v.as_f64()).unwrap_or(0.0);
945 let timestamp = data
946 .get("time")
947 .and_then(|v| v.get("completed").or_else(|| v.get("created")))
948 .and_then(|v| v.as_i64());
949
950 Some(MessageUsage {
951 model_id,
952 usage,
953 cost,
954 timestamp,
955 })
956}
957
958fn parse_message_tokens(tokens: &Value) -> Option<UsageTokenContribution> {
959 let tokens = tokens.as_object()?;
960 let read_i64 = |object: &serde_json::Map<String, Value>, key: &str| -> Option<i64> {
961 match object.get(key) {
962 Some(value) => value.as_i64(),
963 None => Some(0),
964 }
965 };
966
967 let has_flat_key = ["input", "output", "reasoning"]
968 .iter()
969 .any(|key| tokens.contains_key(*key));
970 let input = read_i64(tokens, "input")?;
971 let output = read_i64(tokens, "output")?;
972 let reasoning = read_i64(tokens, "reasoning")?;
973
974 let (cache_read, cache_write, has_cache_key) = match tokens.get("cache") {
975 Some(cache) => {
976 let cache = cache.as_object()?;
977 let has_known_cache_key = ["read", "write"].iter().any(|key| cache.contains_key(*key));
978 if !cache.is_empty() && !has_known_cache_key {
979 return None;
980 }
981 (read_i64(cache, "read")?, read_i64(cache, "write")?, true)
982 }
983 None => (0, 0, false),
984 };
985
986 if !tokens.is_empty() && !has_flat_key && !has_cache_key {
987 return None;
988 }
989
990 Some(session_usage_value(
991 input,
992 output,
993 reasoning,
994 cache_read,
995 cache_write,
996 ))
997}
998
999fn message_model_id(data: &Value) -> Option<String> {
1001 let model = data
1002 .get("modelID")
1003 .or_else(|| data.get("model_id"))
1004 .and_then(|v| v.as_str())
1005 .or_else(|| {
1006 data.get("model")
1007 .and_then(|v| v.get("modelID").or_else(|| v.get("id")))
1008 .and_then(|v| v.as_str())
1009 })?
1010 .trim();
1011 if model.is_empty() {
1012 return None;
1013 }
1014
1015 Some(provider_qualified_model_id(
1016 data.get("providerID")
1017 .or_else(|| data.get("provider_id"))
1018 .and_then(|v| v.as_str())
1019 .or_else(|| {
1020 data.get("model")
1021 .and_then(|v| v.get("providerID").or_else(|| v.get("provider_id")))
1022 .and_then(|v| v.as_str())
1023 }),
1024 model,
1025 ))
1026}
1027
1028fn provider_qualified_model_id(provider: Option<&str>, model: &str) -> String {
1030 let Some(provider) = provider.map(str::trim).filter(|s| !s.is_empty()) else {
1031 return model.to_string();
1032 };
1033 if model.starts_with(&format!("{provider}/")) {
1034 model.to_string()
1035 } else {
1036 format!("{provider}/{model}")
1037 }
1038}
1039
1040fn ms_to_local_date(ms: i64) -> Option<String> {
1042 chrono::DateTime::from_timestamp_millis(ms).map(|dt| {
1043 dt.with_timezone(&chrono::Local)
1044 .format("%Y-%m-%d")
1045 .to_string()
1046 })
1047}
1048
1049fn cutoff_string(time_range: TimeRange) -> Option<String> {
1051 time_range
1052 .cutoff_date()
1053 .map(|d| d.format("%Y-%m-%d").to_string())
1054}
1055
1056fn cutoff_millis(time_range: TimeRange) -> Option<i64> {
1058 use chrono::{Datelike, TimeZone};
1059
1060 time_range.cutoff_date().and_then(|date| {
1061 chrono::Local
1062 .with_ymd_and_hms(date.year(), date.month(), date.day(), 0, 0, 0)
1063 .earliest()
1064 .map(|dt| dt.timestamp_millis())
1065 })
1066}
1067
1068fn is_before_cutoff(date: &str, cutoff: &Option<String>) -> bool {
1070 matches!(cutoff, Some(c) if date < c.as_str())
1071}
1072
1073fn table_exists(conn: &Connection, table: &str) -> Result<bool> {
1075 conn.query_row(
1076 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
1077 [table],
1078 |row| row.get(0),
1079 )
1080 .map_err(Into::into)
1081}
1082
1083fn extract_read_content(output: &str) -> String {
1085 let Some(start) = output.find("<content>") else {
1086 return extract_plain_read_content(output);
1087 };
1088 let after = &output[start + "<content>".len()..];
1089 let inner = match after.find("</content>") {
1090 Some(end) => &after[..end],
1091 None => after,
1092 };
1093 let inner = inner.strip_prefix('\n').unwrap_or(inner);
1094
1095 strip_numbered_content_lines(inner)
1096}
1097
1098fn extract_plain_read_content(output: &str) -> String {
1100 let mut lines = output.split('\n');
1101 let Some(_path) = lines.next() else {
1102 return String::new();
1103 };
1104 let Some(kind) = lines.next() else {
1105 return String::new();
1106 };
1107 if kind.trim_end_matches('\r') != "file" {
1108 return String::new();
1109 }
1110
1111 let mut content = Vec::new();
1112 let mut saw_separator = false;
1113 let mut saw_content = false;
1114 for line in lines {
1115 if !saw_separator {
1116 if line.trim_end_matches('\r').is_empty() {
1117 saw_separator = true;
1118 }
1119 continue;
1120 }
1121 let line = line.strip_suffix('\r').unwrap_or(line);
1122 if has_line_number_prefix(line) {
1123 content.push(line);
1124 saw_content = true;
1125 } else if saw_content {
1126 break;
1127 }
1128 }
1129
1130 if saw_separator {
1131 strip_numbered_content_lines_from_iter(content)
1132 } else {
1133 String::new()
1134 }
1135}
1136
1137fn strip_numbered_content_lines(inner: &str) -> String {
1138 strip_numbered_content_lines_from_iter(inner.split('\n').collect())
1139}
1140
1141fn strip_numbered_content_lines_from_iter(lines: Vec<&str>) -> String {
1142 let mut lines: Vec<&str> = lines
1143 .into_iter()
1144 .map(|line| line.strip_suffix('\r').unwrap_or(line))
1145 .map(strip_line_number_prefix)
1146 .collect();
1147
1148 if lines.last().is_some_and(|l| l.is_empty()) {
1149 lines.pop();
1150 }
1151 lines.join("\n")
1152}
1153
1154fn has_line_number_prefix(line: &str) -> bool {
1156 let bytes = line.as_bytes();
1157 let mut i = 0;
1158 while i < bytes.len() && bytes[i].is_ascii_digit() {
1159 i += 1;
1160 }
1161 i > 0 && i + 1 < bytes.len() && bytes[i] == b':' && bytes[i + 1] == b' '
1162}
1163
1164fn strip_line_number_prefix(line: &str) -> &str {
1166 let Some((prefix, content)) = line.split_once(": ") else {
1167 return line;
1168 };
1169 if !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit()) {
1170 content
1171 } else {
1172 line
1173 }
1174}
1175
1176fn wrap_record(record: CodeAnalysisRecord, user: &str, machine: &str) -> CodeAnalysis {
1178 CodeAnalysis {
1179 user: user.to_string(),
1180 extension_name: ExtensionType::OpenCode.to_string(),
1181 insights_version: VERSION.to_string(),
1182 machine_id: machine.to_string(),
1183 records: vec![record],
1184 }
1185}
1186
1187#[cfg(test)]
1188mod tests {
1189 use super::*;
1190 use std::path::PathBuf;
1191
1192 const DEFAULT_MESSAGE_TS: i64 = 1780757089000;
1193
1194 fn make_db() -> (tempfile::TempDir, PathBuf) {
1195 let dir = tempfile::tempdir().unwrap();
1196 let db_path = dir.path().join("opencode.db");
1197 let conn = Connection::open(&db_path).unwrap();
1198 conn.execute_batch(
1199 "CREATE TABLE session (
1200 id TEXT PRIMARY KEY,
1201 model TEXT,
1202 directory TEXT,
1203 time_updated INTEGER NOT NULL,
1204 cost REAL NOT NULL DEFAULT 0,
1205 tokens_input INTEGER NOT NULL DEFAULT 0,
1206 tokens_output INTEGER NOT NULL DEFAULT 0,
1207 tokens_reasoning INTEGER NOT NULL DEFAULT 0,
1208 tokens_cache_read INTEGER NOT NULL DEFAULT 0,
1209 tokens_cache_write INTEGER NOT NULL DEFAULT 0
1210 );
1211 CREATE TABLE message (
1212 id TEXT PRIMARY KEY,
1213 session_id TEXT NOT NULL,
1214 time_created INTEGER NOT NULL DEFAULT 0,
1215 time_updated INTEGER NOT NULL DEFAULT 0,
1216 data TEXT NOT NULL
1217 );
1218 CREATE TABLE part (
1219 id TEXT PRIMARY KEY,
1220 message_id TEXT NOT NULL DEFAULT '',
1221 session_id TEXT NOT NULL,
1222 time_updated INTEGER NOT NULL DEFAULT 0,
1223 data TEXT NOT NULL
1224 );",
1225 )
1226 .unwrap();
1227 (dir, db_path)
1228 }
1229
1230 fn assistant_message(
1231 model: &str,
1232 input: i64,
1233 output: i64,
1234 reasoning: i64,
1235 cache_read: i64,
1236 cache_write: i64,
1237 cost: f64,
1238 ) -> String {
1239 assistant_message_with_provider(
1240 model,
1241 None,
1242 input,
1243 output,
1244 reasoning,
1245 cache_read,
1246 cache_write,
1247 cost,
1248 )
1249 }
1250
1251 #[allow(clippy::too_many_arguments)]
1252 fn assistant_message_at(
1253 model: &str,
1254 timestamp: i64,
1255 input: i64,
1256 output: i64,
1257 reasoning: i64,
1258 cache_read: i64,
1259 cache_write: i64,
1260 cost: f64,
1261 ) -> String {
1262 assistant_message_with_provider_at(
1263 model,
1264 None,
1265 timestamp,
1266 input,
1267 output,
1268 reasoning,
1269 cache_read,
1270 cache_write,
1271 cost,
1272 )
1273 }
1274
1275 #[allow(clippy::too_many_arguments)]
1276 fn assistant_message_with_provider(
1277 model: &str,
1278 provider: Option<&str>,
1279 input: i64,
1280 output: i64,
1281 reasoning: i64,
1282 cache_read: i64,
1283 cache_write: i64,
1284 cost: f64,
1285 ) -> String {
1286 assistant_message_with_provider_at(
1287 model,
1288 provider,
1289 DEFAULT_MESSAGE_TS,
1290 input,
1291 output,
1292 reasoning,
1293 cache_read,
1294 cache_write,
1295 cost,
1296 )
1297 }
1298
1299 #[allow(clippy::too_many_arguments)]
1300 fn assistant_message_with_provider_at(
1301 model: &str,
1302 provider: Option<&str>,
1303 timestamp: i64,
1304 input: i64,
1305 output: i64,
1306 reasoning: i64,
1307 cache_read: i64,
1308 cache_write: i64,
1309 cost: f64,
1310 ) -> String {
1311 let mut message = serde_json::json!({
1312 "role": "assistant",
1313 "modelID": model,
1314 "cost": cost,
1315 "tokens": {
1316 "input": input,
1317 "output": output,
1318 "reasoning": reasoning,
1319 "cache": {
1320 "read": cache_read,
1321 "write": cache_write,
1322 },
1323 },
1324 "time": {
1325 "created": timestamp.saturating_sub(1000),
1326 "completed": timestamp,
1327 },
1328 });
1329 if let Some(provider) = provider {
1330 message["providerID"] = serde_json::Value::String(provider.to_string());
1331 }
1332 message.to_string()
1333 }
1334
1335 #[test]
1336 fn test_parse_model_id() {
1337 assert_eq!(
1338 parse_model_id(r#"{"id":"deepseek-v4-pro","providerID":"deepseek"}"#).as_deref(),
1339 Some("deepseek/deepseek-v4-pro")
1340 );
1341 assert_eq!(
1342 parse_model_id("gemini-3.5-flash").as_deref(),
1343 Some("gemini-3.5-flash")
1344 );
1345 assert_eq!(parse_model_id(r#"{"providerID":"x"}"#), None);
1346 assert_eq!(parse_model_id(" "), None);
1347 }
1348
1349 #[test]
1350 fn test_message_model_id_preserves_provider() {
1351 let message = serde_json::json!({
1352 "role": "assistant",
1353 "modelID": "gpt-4.1",
1354 "providerID": "azure",
1355 });
1356 assert_eq!(message_model_id(&message).as_deref(), Some("azure/gpt-4.1"));
1357 }
1358
1359 #[test]
1360 fn test_extract_read_content() {
1361 let output = "<path>/a/b.py</path>\n<type>file</type>\n<content>\n1: import os\n2: \n3: print(1)\n</content>";
1362 assert_eq!(extract_read_content(output), "import os\n\nprint(1)");
1363
1364 let plain_output = "/a/b.py\nfile\n\n1: import os\n2: \n3: print(1)";
1365 assert_eq!(extract_read_content(plain_output), "import os\n\nprint(1)");
1366
1367 let plain_output_with_footer =
1368 "/a/b.py\nfile\n\n1: import os\n2: \n3: print(1)\n\n(End of file - total 3 lines)";
1369 assert_eq!(
1370 extract_read_content(plain_output_with_footer),
1371 "import os\n\nprint(1)"
1372 );
1373
1374 let dir_output = "<path>/a</path>\n<type>directory</type>\n<entries>\nx.py\n</entries>";
1376 assert_eq!(extract_read_content(dir_output), "");
1377
1378 let plain_dir_output = "/a\ndirectory\n\nx.py";
1379 assert_eq!(extract_read_content(plain_dir_output), "");
1380 }
1381
1382 #[test]
1383 fn test_strip_line_number_prefix() {
1384 assert_eq!(strip_line_number_prefix("12: hello"), "hello");
1385 assert_eq!(strip_line_number_prefix("1: 2: nested"), "2: nested");
1386 assert_eq!(strip_line_number_prefix("no prefix"), "no prefix");
1387 }
1388
1389 #[test]
1390 fn test_read_usage_maps_tokens() {
1391 let (_dir, db_path) = make_db();
1392 let conn = Connection::open(&db_path).unwrap();
1393 conn.execute(
1394 "INSERT INTO session (id, model, directory, time_updated, cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write)
1395 VALUES ('s1', '{\"id\":\"deepseek-v4-pro\"}', '/repo', 1780757088080, 0.0375, 100, 50, 7, 200, 25)",
1396 [],
1397 )
1398 .unwrap();
1399 conn.execute(
1400 "INSERT INTO message (id, session_id, data) VALUES ('m1', 's1', ?1)",
1401 [assistant_message_with_provider(
1402 "deepseek-v4-pro",
1403 Some("deepseek"),
1404 100,
1405 50,
1406 7,
1407 200,
1408 25,
1409 0.0375,
1410 )],
1411 )
1412 .unwrap();
1413 drop(conn);
1414
1415 let rows = read_opencode_usage(&db_path, TimeRange::All).unwrap();
1416 assert_eq!(rows.len(), 1);
1417 let (_date, analysis, cost) = &rows[0];
1418 assert_eq!(analysis.extension_name, "OpenCode");
1419 assert!((cost - 0.0375).abs() < 1e-9);
1420 let usage = &analysis.records[0].conversation_usage["deepseek/deepseek-v4-pro"];
1421 assert_eq!(usage["input_tokens"], 100);
1422 assert_eq!(usage["output_tokens"], 50);
1423 assert_eq!(usage["reasoning_output_tokens"], 7);
1424 assert_eq!(usage["cache_read_input_tokens"], 200);
1425 assert_eq!(usage["cache_creation_input_tokens"], 25);
1426 }
1427
1428 #[test]
1429 fn analysis_rejects_assistant_rows_with_completely_unknown_schema() {
1430 let (_dir, db_path) = make_db();
1431 let conn = Connection::open(&db_path).unwrap();
1432 conn.execute(
1433 "INSERT INTO session (id, directory, time_updated) VALUES ('s1', '/repo', 1780757089000)",
1434 [],
1435 )
1436 .unwrap();
1437 conn.execute(
1438 "INSERT INTO message (id, session_id, data) VALUES ('m1', 's1', ?1)",
1439 [r#"{"role":"assistant","futureUsage":{"input":10}}"#],
1440 )
1441 .unwrap();
1442 drop(conn);
1443
1444 let result =
1445 read_opencode_analysis_with_diagnostics(&db_path, TimeRange::All, ParseMode::Full)
1446 .unwrap();
1447 assert_eq!(result.expected_records, 1);
1448 assert_eq!(result.parsed_records, 0);
1449 assert!(result.rows.is_empty());
1450
1451 let error = read_opencode_analysis(&db_path, TimeRange::All, ParseMode::Full).unwrap_err();
1452 assert!(
1453 error
1454 .to_string()
1455 .contains("none of 1 OpenCode analysis records")
1456 );
1457 }
1458
1459 #[test]
1460 fn analysis_rejects_unknown_only_and_wrong_type_token_objects() {
1461 for tokens in [
1462 json!({ "prompt": 10, "completion": 2 }),
1463 json!({ "input": "10" }),
1464 json!({ "cache": { "futureRead": 10 } }),
1465 ] {
1466 let (_dir, db_path) = make_db();
1467 let conn = Connection::open(&db_path).unwrap();
1468 conn.execute(
1469 "INSERT INTO session (id, directory, time_updated) VALUES ('s1', '/repo', 1780757089000)",
1470 [],
1471 )
1472 .unwrap();
1473 let message = json!({
1474 "role": "assistant",
1475 "modelID": "model",
1476 "tokens": tokens,
1477 })
1478 .to_string();
1479 conn.execute(
1480 "INSERT INTO message (id, session_id, data) VALUES ('m1', 's1', ?1)",
1481 [message],
1482 )
1483 .unwrap();
1484 drop(conn);
1485
1486 let result =
1487 read_opencode_analysis_with_diagnostics(&db_path, TimeRange::All, ParseMode::Full)
1488 .unwrap();
1489 assert_eq!(result.expected_records, 1);
1490 assert_eq!(result.parsed_records, 0);
1491 assert!(result.rows.is_empty());
1492 }
1493 }
1494
1495 #[test]
1496 fn analysis_accepts_zero_and_partial_known_token_objects() {
1497 for tokens in [json!({}), json!({ "input": 0 }), json!({ "cache": {} })] {
1498 let raw = json!({
1499 "role": "assistant",
1500 "modelID": "model",
1501 "tokens": tokens,
1502 })
1503 .to_string();
1504 let parsed = parse_message_usage(&raw).expect("known zero token shape");
1505 assert_eq!(parsed.usage.input_tokens, 0);
1506 assert_eq!(parsed.usage.output_tokens, 0);
1507 }
1508 }
1509
1510 #[test]
1511 fn analysis_reports_known_tool_part_schema_drift() {
1512 let (_dir, db_path) = make_db();
1513 let conn = Connection::open(&db_path).unwrap();
1514 conn.execute(
1515 "INSERT INTO session (id, directory, time_updated) VALUES ('s1', '/repo', 1780757089000)",
1516 [],
1517 )
1518 .unwrap();
1519 conn.execute(
1520 "INSERT INTO message (id, session_id, data) VALUES ('m1', 's1', ?1)",
1521 [assistant_message("model", 0, 0, 0, 0, 0, 0.0)],
1522 )
1523 .unwrap();
1524 conn.execute(
1525 "INSERT INTO part (id, message_id, session_id, data) VALUES ('p1', 'm1', 's1', ?1)",
1526 [r#"{"type":"tool","tool":"write","state":{"status":"completed","input":{"futurePath":"/repo/a","futureContent":"text"}}}"#],
1527 )
1528 .unwrap();
1529 conn.execute(
1530 "INSERT INTO part (id, message_id, session_id, data) VALUES ('p2', 'm1', 's1', ?1)",
1531 [r#"{"type":"tool","tool":"write","state":{"status":"success","input":{"filePath":"/repo/a","content":"text"}}}"#],
1532 )
1533 .unwrap();
1534 drop(conn);
1535
1536 let result =
1537 read_opencode_analysis_with_diagnostics(&db_path, TimeRange::All, ParseMode::Full)
1538 .unwrap();
1539 assert_eq!(result.expected_records, 1);
1540 assert_eq!(result.parsed_records, 1);
1541 assert_eq!(result.failed_tool_parts, 2);
1542 assert_eq!(result.rows.len(), 1);
1543 assert_eq!(result.rows[0].analysis.records[0].tool_call_counts.write, 0);
1544 }
1545
1546 #[test]
1547 fn test_time_range_filters_old_sessions() {
1548 let (_dir, db_path) = make_db();
1549 let conn = Connection::open(&db_path).unwrap();
1550 let now_ms = chrono::Local::now().timestamp_millis();
1551 conn.execute(
1553 "INSERT INTO session (id, model, directory, time_updated, tokens_input) VALUES ('recent', '{\"id\":\"m1\"}', '/repo', ?1, 10)",
1554 rusqlite::params![now_ms],
1555 )
1556 .unwrap();
1557 conn.execute(
1558 "INSERT INTO message (id, session_id, data) VALUES ('recent-msg', 'recent', ?1)",
1559 [assistant_message_at("m1", now_ms, 10, 0, 0, 0, 0, 0.01)],
1560 )
1561 .unwrap();
1562 conn.execute(
1563 "INSERT INTO session (id, model, directory, time_updated, tokens_input) VALUES ('old', '{\"id\":\"m1\"}', '/repo', 1000000000000, 10)",
1564 [],
1565 )
1566 .unwrap();
1567 conn.execute(
1568 "INSERT INTO message (id, session_id, data) VALUES ('old-msg', 'old', ?1)",
1569 [assistant_message_at(
1570 "m1",
1571 1000000000000,
1572 10,
1573 0,
1574 0,
1575 0,
1576 0,
1577 0.01,
1578 )],
1579 )
1580 .unwrap();
1581 drop(conn);
1582
1583 let all = read_opencode_usage(&db_path, TimeRange::All).unwrap();
1584 assert_eq!(all.len(), 2);
1585
1586 let daily = read_opencode_usage(&db_path, TimeRange::Daily).unwrap();
1587 assert_eq!(daily.len(), 1);
1588 }
1589
1590 #[test]
1591 fn test_message_time_range_filters_resumed_sessions() {
1592 let (_dir, db_path) = make_db();
1593 let conn = Connection::open(&db_path).unwrap();
1594 let now_ms = chrono::Local::now().timestamp_millis();
1595 conn.execute(
1596 "INSERT INTO session (id, model, directory, time_updated, tokens_input) VALUES ('resumed', '{\"id\":\"m1\"}', '/repo', ?1, 10)",
1597 rusqlite::params![now_ms],
1598 )
1599 .unwrap();
1600 conn.execute(
1601 "INSERT INTO message (id, session_id, data) VALUES ('old-msg', 'resumed', ?1)",
1602 [assistant_message_at(
1603 "old-model",
1604 1000000000000,
1605 10,
1606 0,
1607 0,
1608 0,
1609 0,
1610 0.01,
1611 )],
1612 )
1613 .unwrap();
1614 conn.execute(
1615 "INSERT INTO message (id, session_id, data) VALUES ('recent-msg', 'resumed', ?1)",
1616 [assistant_message_at(
1617 "recent-model",
1618 now_ms,
1619 20,
1620 0,
1621 0,
1622 0,
1623 0,
1624 0.02,
1625 )],
1626 )
1627 .unwrap();
1628 conn.execute(
1629 "INSERT INTO part (id, message_id, session_id, data) VALUES ('old-part', 'old-msg', 'resumed', ?1)",
1630 [r#"{"type":"tool","tool":"read","state":{"status":"completed","input":{"filePath":"/repo/old.py"},"output":"<content>\n1: old\n</content>"}}"#],
1631 )
1632 .unwrap();
1633 conn.execute(
1634 "INSERT INTO part (id, message_id, session_id, data) VALUES ('recent-part', 'recent-msg', 'resumed', ?1)",
1635 [r#"{"type":"tool","tool":"read","state":{"status":"completed","input":{"filePath":"/repo/recent.py"},"output":"<content>\n1: recent\n</content>"}}"#],
1636 )
1637 .unwrap();
1638 drop(conn);
1639
1640 let usage_rows = read_opencode_usage(&db_path, TimeRange::Daily).unwrap();
1641 assert_eq!(usage_rows.len(), 1);
1642 assert!(
1643 usage_rows[0].1.records[0]
1644 .conversation_usage
1645 .contains_key("recent-model")
1646 );
1647
1648 let analysis_rows =
1649 read_opencode_analysis(&db_path, TimeRange::Daily, ParseMode::UsageOnly).unwrap();
1650 assert_eq!(analysis_rows.len(), 1);
1651 let record = &analysis_rows[0].1.records[0];
1652 assert!(record.conversation_usage.contains_key("recent-model"));
1653 assert_eq!(record.tool_call_counts.read, 1);
1654 assert_eq!(record.total_read_lines, 1);
1655 }
1656
1657 #[test]
1658 fn test_legacy_session_usage_filters_old_sessions() {
1659 let (_dir, db_path) = make_db();
1660 let conn = Connection::open(&db_path).unwrap();
1661 conn.execute("DROP TABLE message", []).unwrap();
1662 conn.execute("DROP TABLE part", []).unwrap();
1663
1664 let now_ms = chrono::Local::now().timestamp_millis();
1665 conn.execute(
1666 "INSERT INTO session (id, model, directory, time_updated, cost, tokens_input) VALUES ('recent', '{\"id\":\"m1\"}', '/repo', ?1, 0.01, 10)",
1667 rusqlite::params![now_ms],
1668 )
1669 .unwrap();
1670 conn.execute(
1671 "INSERT INTO session (id, model, directory, time_updated, cost, tokens_input) VALUES ('old', '{\"id\":\"m2\"}', '/repo', 1000000000000, 0.02, 20)",
1672 [],
1673 )
1674 .unwrap();
1675 drop(conn);
1676
1677 let daily = read_opencode_usage(&db_path, TimeRange::Daily).unwrap();
1678 assert_eq!(daily.len(), 1);
1679 assert!(daily[0].1.records[0].conversation_usage.contains_key("m1"));
1680 }
1681
1682 #[test]
1683 fn test_messages_split_usage_and_analysis_by_model() {
1684 let (_dir, db_path) = make_db();
1685 let conn = Connection::open(&db_path).unwrap();
1686 conn.execute(
1687 "INSERT INTO session (id, model, directory, time_updated) VALUES ('s1', '{\"id\":\"m2\"}', '/repo', 1780757088080)",
1688 [],
1689 )
1690 .unwrap();
1691 conn.execute(
1692 "INSERT INTO message (id, session_id, data) VALUES ('m1', 's1', ?1)",
1693 [assistant_message("m1", 10, 2, 0, 3, 4, 0.01)],
1694 )
1695 .unwrap();
1696 conn.execute(
1697 "INSERT INTO message (id, session_id, data) VALUES ('m2', 's1', ?1)",
1698 [assistant_message("m2", 20, 4, 1, 6, 8, 0.02)],
1699 )
1700 .unwrap();
1701 conn.execute(
1702 "INSERT INTO part (id, message_id, session_id, data) VALUES ('p1', 'm1', 's1', ?1)",
1703 [r#"{"type":"tool","tool":"read","state":{"status":"completed","input":{"filePath":"/repo/a.py"},"output":"<content>\n1: one\n</content>"}}"#],
1704 )
1705 .unwrap();
1706 conn.execute(
1707 "INSERT INTO part (id, message_id, session_id, data) VALUES ('p2', 'm2', 's1', ?1)",
1708 [r#"{"type":"tool","tool":"edit","state":{"status":"completed","input":{"filePath":"/repo/b.py","oldString":"old","newString":"new\nline"}}}"#],
1709 )
1710 .unwrap();
1711 drop(conn);
1712
1713 let usage_rows = read_opencode_usage(&db_path, TimeRange::All).unwrap();
1714 assert_eq!(usage_rows.len(), 2);
1715 let mut usage_by_model: HashMap<String, (i64, f64)> = HashMap::new();
1716 for (_date, analysis, cost) in usage_rows {
1717 let (model, usage) = analysis.records[0]
1718 .conversation_usage
1719 .iter()
1720 .next()
1721 .unwrap();
1722 usage_by_model.insert(
1723 model.clone(),
1724 (usage["input_tokens"].as_i64().unwrap(), cost),
1725 );
1726 }
1727 assert_eq!(usage_by_model["m1"], (10, 0.01));
1728 assert_eq!(usage_by_model["m2"], (20, 0.02));
1729
1730 let analysis_rows =
1731 read_opencode_analysis(&db_path, TimeRange::All, ParseMode::UsageOnly).unwrap();
1732 assert_eq!(analysis_rows.len(), 2);
1733 let mut counts_by_model: HashMap<String, (usize, usize)> = HashMap::new();
1734 for (_date, analysis) in analysis_rows {
1735 let record = &analysis.records[0];
1736 let model = record.conversation_usage.keys().next().unwrap().clone();
1737 counts_by_model.insert(
1738 model,
1739 (record.tool_call_counts.read, record.tool_call_counts.edit),
1740 );
1741 }
1742 assert_eq!(counts_by_model["m1"], (1, 0));
1743 assert_eq!(counts_by_model["m2"], (0, 1));
1744 }
1745
1746 #[test]
1747 fn test_read_analysis_counts_tools() {
1748 let (_dir, db_path) = make_db();
1749 let conn = Connection::open(&db_path).unwrap();
1750 conn.execute(
1751 "INSERT INTO session (id, model, directory, time_updated) VALUES ('s1', '{\"id\":\"m1\"}', '/repo', 1780757088080)",
1752 [],
1753 )
1754 .unwrap();
1755 conn.execute(
1756 "INSERT INTO message (id, session_id, data) VALUES ('m1', 's1', ?1)",
1757 [assistant_message("m1", 1, 1, 0, 0, 0, 0.01)],
1758 )
1759 .unwrap();
1760 let parts = [
1761 r#"{"type":"tool","tool":"read","state":{"status":"completed","input":{"filePath":"/repo/a.py"},"output":"<path>/repo/a.py</path>\n<type>file</type>\n<content>\n1: one\n2: two\n</content>"}}"#,
1762 r#"{"type":"tool","tool":"edit","state":{"status":"completed","input":{"filePath":"/repo/a.py","oldString":"one","newString":"uno\ndos"}}}"#,
1763 r#"{"type":"tool","tool":"bash","state":{"status":"completed","input":{"command":"ls -la","description":"list"}}}"#,
1764 r#"{"type":"tool","tool":"todowrite","state":{"status":"completed","input":{"todos":[]}}}"#,
1765 r#"{"type":"tool","tool":"edit","state":{"status":"error","input":{"filePath":"/repo/a.py","oldString":"one","newString":"failed\nchange"}}}"#,
1766 r#"{"type":"tool","tool":"grep","state":{"status":"completed","input":{"pattern":"x"}}}"#,
1767 r#"{"type":"text","text":"ignored"}"#,
1768 ];
1769 for (i, p) in parts.iter().enumerate() {
1770 conn.execute(
1771 "INSERT INTO part (id, message_id, session_id, data) VALUES (?1, 'm1', 's1', ?2)",
1772 rusqlite::params![format!("p{i}"), p],
1773 )
1774 .unwrap();
1775 }
1776 drop(conn);
1777
1778 let rows = read_opencode_analysis(&db_path, TimeRange::All, ParseMode::UsageOnly).unwrap();
1779 assert_eq!(rows.len(), 1);
1780 let record = &rows[0].1.records[0];
1781 assert_eq!(record.tool_call_counts.read, 1);
1782 assert_eq!(record.tool_call_counts.edit, 1);
1783 assert_eq!(record.tool_call_counts.bash, 1);
1784 assert_eq!(record.tool_call_counts.todo_write, 1);
1785 assert_eq!(record.total_read_lines, 2);
1786 assert_eq!(record.total_edit_lines, 2);
1787 assert_eq!(record.tool_call_counts.write, 0);
1789 }
1790
1791 #[test]
1792 fn test_read_analysis_orders_details_by_part_id() {
1793 let (_dir, db_path) = make_db();
1794 let conn = Connection::open(&db_path).unwrap();
1795 conn.execute(
1796 "INSERT INTO session (id, model, directory, time_updated) VALUES ('s1', '{\"id\":\"m1\"}', '/repo', 1780757088080)",
1797 [],
1798 )
1799 .unwrap();
1800 conn.execute(
1801 "INSERT INTO message (id, session_id, data) VALUES ('m1', 's1', ?1)",
1802 [assistant_message("m1", 1, 1, 0, 0, 0, 0.01)],
1803 )
1804 .unwrap();
1805 conn.execute(
1806 "INSERT INTO part (id, message_id, session_id, data) VALUES ('z', 'm1', 's1', ?1)",
1807 [r#"{"type":"tool","tool":"write","state":{"status":"completed","input":{"filePath":"/repo/z.rs","content":"z"}}}"#],
1808 )
1809 .unwrap();
1810 conn.execute(
1811 "INSERT INTO part (id, message_id, session_id, data) VALUES ('a', 'm1', 's1', ?1)",
1812 [r#"{"type":"tool","tool":"write","state":{"status":"completed","input":{"filePath":"/repo/a.rs","content":"a"}}}"#],
1813 )
1814 .unwrap();
1815 drop(conn);
1816
1817 let rows = read_opencode_analysis(&db_path, TimeRange::All, ParseMode::Full).unwrap();
1818 let details = &rows[0].1.records[0].write_file_details;
1819 assert_eq!(details[0].base.file_path, "/repo/a.rs");
1820 assert_eq!(details[1].base.file_path, "/repo/z.rs");
1821 }
1822
1823 #[test]
1824 fn test_read_analysis_counts_apply_patch_tool() {
1825 let (_dir, db_path) = make_db();
1826 let conn = Connection::open(&db_path).unwrap();
1827 conn.execute(
1828 "INSERT INTO session (id, model, directory, time_updated) VALUES ('s1', '{\"id\":\"m1\"}', '/repo', 1780757088080)",
1829 [],
1830 )
1831 .unwrap();
1832 conn.execute(
1833 "INSERT INTO message (id, session_id, data) VALUES ('m1', 's1', ?1)",
1834 [assistant_message("m1", 1, 1, 0, 0, 0, 0.01)],
1835 )
1836 .unwrap();
1837 conn.execute(
1838 "INSERT INTO part (id, message_id, session_id, data) VALUES ('p1', 'm1', 's1', ?1)",
1839 [r#"{"type":"tool","tool":"apply_patch","state":{"status":"completed","input":{"patchText":"*** Begin Patch\n*** Update File: src/main.rs\n@@\n-old\n+new\n+line\n*** Add File: src/new.rs\n+created\n*** End Patch"}}}"#],
1840 )
1841 .unwrap();
1842 drop(conn);
1843
1844 let rows = read_opencode_analysis(&db_path, TimeRange::All, ParseMode::UsageOnly).unwrap();
1845 assert_eq!(rows.len(), 1);
1846 let record = &rows[0].1.records[0];
1847 assert_eq!(record.tool_call_counts.edit, 1);
1848 assert_eq!(record.tool_call_counts.write, 1);
1849 assert_eq!(record.total_edit_lines, 2);
1850 assert_eq!(record.total_write_lines, 1);
1851 }
1852
1853 #[test]
1854 fn test_read_analysis_keeps_patch_insertions_as_edits() {
1855 let (_dir, db_path) = make_db();
1856 let conn = Connection::open(&db_path).unwrap();
1857 conn.execute(
1858 "INSERT INTO session (id, model, directory, time_updated) VALUES ('s1', '{\"id\":\"m1\"}', '/repo', 1780757088080)",
1859 [],
1860 )
1861 .unwrap();
1862 conn.execute(
1863 "INSERT INTO message (id, session_id, data) VALUES ('m1', 's1', ?1)",
1864 [assistant_message("m1", 1, 1, 0, 0, 0, 0.01)],
1865 )
1866 .unwrap();
1867 conn.execute(
1868 "INSERT INTO part (id, message_id, session_id, data) VALUES ('p1', 'm1', 's1', ?1)",
1869 [r#"{"type":"tool","tool":"apply_patch","state":{"status":"completed","input":{"patchText":"*** Begin Patch\n*** Update File: src/main.rs\n@@\n+inserted\n+lines\n*** End Patch"}}}"#],
1870 )
1871 .unwrap();
1872 drop(conn);
1873
1874 let rows = read_opencode_analysis(&db_path, TimeRange::All, ParseMode::UsageOnly).unwrap();
1875 assert_eq!(rows.len(), 1);
1876 let record = &rows[0].1.records[0];
1877 assert_eq!(record.tool_call_counts.edit, 1);
1880 assert_eq!(record.tool_call_counts.write, 0);
1881 assert_eq!(record.total_edit_lines, 2);
1882 assert_eq!(record.total_write_lines, 0);
1883 }
1884
1885 #[test]
1886 fn test_parse_apply_patch_text_handles_move_marker() {
1887 let patches = parse_apply_patch_text(
1888 "*** Begin Patch\n*** Update File: src/old.rs\n*** Move to: src/new.rs\n@@\n-a\n+b\n*** End Patch",
1889 );
1890 assert_eq!(patches.len(), 1);
1891 assert_eq!(patches[0].action, "update");
1892 assert_eq!(patches[0].file_path, "src/new.rs");
1893 let (old_str, new_str) = extract_patch_strings(&patches[0].lines);
1894 assert_eq!(old_str, "a");
1895 assert_eq!(new_str, "b");
1896 }
1897
1898 #[test]
1899 fn test_read_analysis_ignores_patch_snapshots() {
1900 let (_dir, db_path) = make_db();
1901 let conn = Connection::open(&db_path).unwrap();
1902 conn.execute(
1903 "INSERT INTO session (id, model, directory, time_updated) VALUES ('s1', '{\"id\":\"m1\"}', '/repo', 1780757088080)",
1904 [],
1905 )
1906 .unwrap();
1907 conn.execute(
1908 "INSERT INTO message (id, session_id, data) VALUES ('m1', 's1', ?1)",
1909 [assistant_message("m1", 1, 1, 0, 0, 0, 0.01)],
1910 )
1911 .unwrap();
1912 conn.execute(
1913 "INSERT INTO part (id, message_id, session_id, time_updated, data) VALUES ('p1', 'm1', 's1', 1780757089000, ?1)",
1914 [r#"{"type":"patch","files":["/repo/a.py","/repo/b.py"]}"#],
1915 )
1916 .unwrap();
1917 drop(conn);
1918
1919 let rows = read_opencode_analysis(&db_path, TimeRange::All, ParseMode::UsageOnly).unwrap();
1920 assert_eq!(rows.len(), 1);
1921 let record = &rows[0].1.records[0];
1922 assert_eq!(record.tool_call_counts.edit, 0);
1923 assert_eq!(record.total_edit_lines, 0);
1924 }
1925}