Skip to main content

vct_core/session/
opencode.rs

1//! OpenCode session reader (SQLite, not JSONL).
2//!
3//! Unlike the five file-based providers, OpenCode stores every session in a
4//! single SQLite database at `~/.local/share/opencode/opencode.db` (WAL mode).
5//! This module owns the "SQLite rows -> typed [`CodeAnalysis`]" boundary, so
6//! both the `usage` and `analysis` aggregators consume the same shape the
7//! file-based providers produce.
8//!
9//! Two entry points keep the work proportional to what each command needs:
10//!
11//! - [`read_opencode_usage`] reads assistant messages for per-model tokens and
12//!   cost, with an older `session`-table fallback.
13//! - [`read_opencode_analysis`] additionally folds the `part` table's tool
14//!   calls (`read`, `edit`, `write`, `bash`, `todowrite`) into
15//!   per-message file-operation metrics.
16//!
17//! Token columns map onto the Claude-style flat usage shape so the existing
18//! `merge_usage_values` / `extract_token_counts` / LiteLLM cost path works
19//! unchanged. Assistant messages carry their own `providerID` + `modelID`, so
20//! sessions that switch model mid-stream are split before aggregation.
21
22use 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
40/// Reads per-session token usage from the OpenCode database.
41///
42/// Each returned tuple is `(local YYYY-MM-DD date, CodeAnalysis, stored_cost)`
43/// where the `CodeAnalysis` holds one assistant message's
44/// `conversation_usage`, keyed by that message's provider-qualified model id,
45/// and `stored_cost` is OpenCode's own cost for that message. The date comes
46/// from the assistant message timestamp and is filtered by `time_range`,
47/// matching the file-walker semantics.
48///
49/// # Errors
50///
51/// Returns an error if the database cannot be opened or queried.
52pub 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
78/// Reads compact OpenCode usage rows for the summary aggregation path.
79pub(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
88/// Reads per-session file-operation metrics from the OpenCode database.
89///
90/// Like [`read_opencode_usage`], but also folds each session's tool calls from
91/// the `part` table into `tool_call_counts` and the `total_*` line/character
92/// counts. `mode` controls whether the heavy per-operation detail bodies are
93/// retained ([`ParseMode::Full`]) or skipped ([`ParseMode::UsageOnly`]); the
94/// aggregated `analysis` view uses `UsageOnly`. Message/session metadata and
95/// tool parts are read inside one transaction so a concurrent OpenCode commit
96/// cannot split the result across two SQLite snapshots.
97///
98/// # Errors
99///
100/// Returns an error if the database cannot be opened or queried.
101pub 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
130/// OpenCode rows plus record-recognition diagnostics for batch collection.
131pub(crate) struct OpenCodeAnalysisRead {
132    /// Successfully normalized assistant/session rows.
133    pub rows: Vec<DatabaseAnalysisRow>,
134    /// Rows selected by the provider's current role/time predicates.
135    pub expected_records: usize,
136    /// Selected rows that produced a normalized `CodeAnalysis`.
137    pub parsed_records: usize,
138    /// Completed known tool parts with unsupported analyzer fields.
139    pub failed_tool_parts: usize,
140}
141
142/// Reads OpenCode analysis while retaining unsupported-record counts.
143pub(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
163/// Parsed usage for one OpenCode assistant message.
164struct MessageUsage {
165    model_id: String,
166    usage: UsageTokenContribution,
167    cost: f64,
168    timestamp: Option<i64>,
169}
170
171/// Per-record accumulator used while folding tool parts.
172struct 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
184/// Counts rows that should be understood by the current analysis reader.
185fn 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
227/// Collects the `usage` view from assistant messages when available.
228fn 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
236/// Collects the `usage` view from the legacy `session` columns.
237fn 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
298/// Collects the `usage` view from assistant messages.
299fn 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
362/// Collects the `analysis` view from assistant messages + parts when available.
363fn 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
375/// Collects the legacy `analysis` view from `session` + `part`.
376fn 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    // 1. Load session metadata and seed one parse state per session.
387    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    // 2. Fold tool parts into their owning session's parse state.
445    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    // 3. Convert each session into a CodeAnalysis, honouring the time filter.
490    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
511/// Collects the `analysis` view from assistant messages and their parts.
512fn 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/// Dispatches a single `part` (type `tool`) onto the session parse state.
655///
656/// Only the tools the analyzer tracks across providers are folded in
657/// (`read`, `edit`, `write`, `bash`, `todowrite`, `apply_patch`); auxiliary
658/// tools such as `task`, `grep`, `glob`, `webfetch`, and `question` are ignored
659/// to stay consistent with the other providers' tool-count semantics.
660#[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                // Directory listings (and empty reads) still count as a read
710                // invocation even though they contribute no file lines.
711                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
761/// Folds an OpenCode `apply_patch` tool input into file-operation counts.
762fn 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            // Updates target existing files, so insert-only hunks stay edits.
770            _ => state.add_edit_detail_raw(&patch.file_path, &old_str, &new_str, ts),
771        }
772    }
773}
774
775/// One file hunk extracted from an OpenCode `apply_patch` tool call.
776struct OpenCodePatch {
777    action: String,
778    file_path: String,
779    lines: Vec<String>,
780}
781
782/// Parses the `*** Begin Patch` format carried by `state.input.patchText`.
783fn 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            // Rename/move marker: attribute the hunk to the destination path.
836            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
850/// Splits diff lines into old and new text, skipping hunk headers.
851fn 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
880/// Builds the Claude-style flat usage value from a session's token columns.
881///
882/// OpenCode records non-cached input separately from cache reads (input is
883/// disjoint from cache, matching the Claude convention), so the columns map
884/// straight onto the field names `extract_token_counts` understands.
885fn 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
901/// Resolves the model name from the `session.model` column.
902///
903/// Modern OpenCode stores it as a JSON object `{"id", "providerID", ...}`. When
904/// `providerID` is present, the returned key is `providerID/id` so same-named
905/// models from different backends do not merge. Older builds may store a bare
906/// model string, which is used verbatim. Returns `None` when no usable model
907/// name is present.
908fn 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        // Not valid JSON: treat the column as a plain model name.
931        _ => Some(raw.to_string()),
932    }
933}
934
935/// Parses one assistant `message.data` payload into usage and cost.
936fn 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
999/// Resolves the model from an assistant message payload.
1000fn 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
1028/// Keeps OpenCode model keys provider-qualified when the payload has provider metadata.
1029fn 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
1040/// Converts a millisecond epoch timestamp into a local `YYYY-MM-DD` date.
1041fn 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
1049/// Pre-computes the `YYYY-MM-DD` cutoff for `time_range`, if any.
1050fn 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
1056/// Converts the inclusive local-date cutoff into an epoch-millis lower bound.
1057fn 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
1068/// Returns `true` when `date` is strictly before the cutoff (should be skipped).
1069fn is_before_cutoff(date: &str, cutoff: &Option<String>) -> bool {
1070    matches!(cutoff, Some(c) if date < c.as_str())
1071}
1072
1073/// Returns whether a table exists in the OpenCode database.
1074fn 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
1083/// Extracts the file body from an OpenCode `read` tool output.
1084fn 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
1098/// Extracts current OpenCode plain read output: `path\nfile\n\n1: line`.
1099fn 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
1154/// Returns whether a line starts with OpenCode's `N: ` read-output prefix.
1155fn 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
1164/// Strips a leading `"<digits>: "` line-number prefix, if present.
1165fn 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
1176/// Wraps a single record into a fully-populated [`CodeAnalysis`].
1177fn 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        // Directory listing has no <content> block.
1375        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        // One session today, one well in the past.
1552        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        // grep / text parts are not tracked.
1788        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        // An insert-only update hunk targets an existing file: it must stay an
1878        // edit instead of being reclassified as a write.
1879        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}