use super::utils::open_readonly_sqlite;
use super::{normalize_workspace_key, workspace_label_from_key, UnifiedMessage};
use crate::{pricing, provider_identity, TokenBreakdown};
use rusqlite::Connection;
use std::collections::HashSet;
use std::path::Path;
pub fn parse_antigravity_cli_file(path: &Path) -> Vec<UnifiedMessage> {
let Some(conn) = open_readonly_sqlite(path) else {
return Vec::new();
};
let session_id = path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("unknown")
.to_string();
let (timestamp, workspace_key, workspace_label) = read_trajectory_meta(&conn, path);
let mut stmt = match conn.prepare("SELECT data FROM gen_metadata ORDER BY idx") {
Ok(stmt) => stmt,
Err(_) => return Vec::new(),
};
let rows = match stmt.query_map([], |row| row.get::<_, Vec<u8>>(0)) {
Ok(rows) => rows,
Err(_) => return Vec::new(),
};
let mut messages = Vec::new();
let mut seen_response_ids: HashSet<String> = HashSet::new();
for blob in rows.flatten() {
if let Some(mut message) =
parse_gen_metadata(&blob, &session_id, timestamp, &mut seen_response_ids)
{
if workspace_key.is_some() {
message.set_workspace(workspace_key.clone(), workspace_label.clone());
}
messages.push(message);
}
}
messages
}
fn parse_gen_metadata(
blob: &[u8],
session_id: &str,
session_timestamp: i64,
seen_response_ids: &mut HashSet<String>,
) -> Option<UnifiedMessage> {
let chat_model = message_field(blob, 1)?;
let usage = message_field(chat_model, 4)?;
let timestamp = message_field(chat_model, 9)
.and_then(|gen| message_field(gen, 4))
.and_then(proto_timestamp_ms)
.filter(|&ms| ms > 0)
.unwrap_or(session_timestamp);
let to_i64 = |v: u64| i64::try_from(v).unwrap_or(i64::MAX);
let input = to_i64(varint_field(usage, 1).unwrap_or(0))
.saturating_add(to_i64(varint_field(usage, 2).unwrap_or(0)));
let cache_read = to_i64(varint_field(usage, 5).unwrap_or(0));
let output = to_i64(varint_field(usage, 9).unwrap_or(0));
let reasoning = to_i64(varint_field(usage, 10).unwrap_or(0));
if input == 0 && output == 0 && cache_read == 0 && reasoning == 0 {
return None;
}
let dedup_key = string_field(usage, 11)
.filter(|text| !text.trim().is_empty())
.map(|text| text.to_string());
if let Some(key) = &dedup_key {
if !seen_response_ids.insert(key.clone()) {
return None;
}
}
let model_raw = string_field(chat_model, 19)
.filter(|text| !text.trim().is_empty())
.unwrap_or("unknown");
let model_id = pricing::aliases::resolve_alias(model_raw)
.unwrap_or(model_raw)
.to_string();
let provider_id = provider_identity::inferred_provider_from_model(&model_id)
.unwrap_or("antigravity")
.to_string();
Some(UnifiedMessage::new_with_dedup(
"antigravity-cli",
model_id,
provider_id,
session_id,
timestamp,
TokenBreakdown {
input,
output,
cache_read,
cache_write: 0,
reasoning,
},
0.0,
dedup_key,
))
}
fn read_trajectory_meta(conn: &Connection, path: &Path) -> (i64, Option<String>, Option<String>) {
let blob: Option<Vec<u8>> = conn
.query_row(
"SELECT data FROM trajectory_metadata_blob LIMIT 1",
[],
|row| row.get::<_, Vec<u8>>(0),
)
.ok();
let mut timestamp = None;
let mut workspace_key = None;
let mut workspace_label = None;
if let Some(blob) = &blob {
timestamp = session_created_ms(blob).filter(|&ms| ms > 0);
if let Some(uri) = message_field(blob, 1).and_then(|folder| string_field(folder, 1)) {
if let Some(path_str) = file_uri_to_path(uri) {
workspace_key = normalize_workspace_key(&path_str);
workspace_label = workspace_key.as_deref().and_then(workspace_label_from_key);
}
}
}
let timestamp = timestamp.unwrap_or_else(|| file_modified_ms(path));
(timestamp, workspace_key, workspace_label)
}
fn session_created_ms(blob: &[u8]) -> Option<i64> {
proto_timestamp_ms(message_field(blob, 2)?)
}
fn proto_timestamp_ms(ts: &[u8]) -> Option<i64> {
let seconds = varint_field(ts, 1)? as i64;
let nanos = i64::try_from(varint_field(ts, 2).unwrap_or(0)).ok()?;
if !(0..=999_999_999).contains(&nanos) {
return None;
}
seconds.checked_mul(1000)?.checked_add(nanos / 1_000_000)
}
fn file_modified_ms(path: &Path) -> i64 {
std::fs::metadata(path)
.and_then(|metadata| metadata.modified())
.ok()
.map(|time| chrono::DateTime::<chrono::Utc>::from(time).timestamp_millis())
.unwrap_or(0)
}
fn file_uri_to_path(uri: &str) -> Option<String> {
let decoded = percent_decode(uri.strip_prefix("file://")?);
let bytes = decoded.as_bytes();
let path = if bytes.first() == Some(&b'/') {
if bytes.len() >= 3 && bytes[2] == b':' {
decoded[1..].to_string()
} else {
decoded
}
} else {
format!("//{decoded}")
};
Some(path)
}
fn percent_decode(input: &str) -> String {
let bytes = input.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(hi), Some(lo)) = (hex_value(bytes[i + 1]), hex_value(bytes[i + 2])) {
out.push((hi << 4) | lo);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn hex_value(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
enum Wire<'a> {
Varint(u64),
Len(&'a [u8]),
Fixed64,
Fixed32,
}
struct ProtoReader<'a> {
buf: &'a [u8],
pos: usize,
}
impl<'a> ProtoReader<'a> {
fn new(buf: &'a [u8]) -> Self {
Self { buf, pos: 0 }
}
fn read_varint(&mut self) -> Option<u64> {
let mut result: u64 = 0;
let mut shift = 0u32;
loop {
let byte = *self.buf.get(self.pos)?;
self.pos += 1;
result |= u64::from(byte & 0x7f) << shift;
if byte & 0x80 == 0 {
return Some(result);
}
shift += 7;
if shift >= 64 {
return None;
}
}
}
fn next_field(&mut self) -> Option<(u64, Wire<'a>)> {
if self.pos >= self.buf.len() {
return None;
}
let tag = self.read_varint()?;
let field = tag >> 3;
let wire = match tag & 0x7 {
0 => Wire::Varint(self.read_varint()?),
1 => {
self.pos = self.pos.checked_add(8).filter(|&p| p <= self.buf.len())?;
Wire::Fixed64
}
2 => {
let len = self.read_varint()? as usize;
let end = self.pos.checked_add(len).filter(|&p| p <= self.buf.len())?;
let bytes = &self.buf[self.pos..end];
self.pos = end;
Wire::Len(bytes)
}
5 => {
self.pos = self.pos.checked_add(4).filter(|&p| p <= self.buf.len())?;
Wire::Fixed32
}
_ => return None,
};
Some((field, wire))
}
}
fn message_field(buf: &[u8], field: u64) -> Option<&[u8]> {
let mut reader = ProtoReader::new(buf);
while let Some((found, wire)) = reader.next_field() {
if found == field {
if let Wire::Len(bytes) = wire {
return Some(bytes);
}
}
}
None
}
fn varint_field(buf: &[u8], field: u64) -> Option<u64> {
let mut reader = ProtoReader::new(buf);
while let Some((found, wire)) = reader.next_field() {
if found == field {
if let Wire::Varint(value) = wire {
return Some(value);
}
}
}
None
}
fn string_field(buf: &[u8], field: u64) -> Option<&str> {
message_field(buf, field).and_then(|bytes| std::str::from_utf8(bytes).ok())
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::{params, Connection};
fn enc_varint(field: u64, value: u64) -> Vec<u8> {
let mut out = encode_varint(field << 3);
out.extend(encode_varint(value));
out
}
fn enc_len(field: u64, payload: &[u8]) -> Vec<u8> {
let mut out = encode_varint((field << 3) | 2);
out.extend(encode_varint(payload.len() as u64));
out.extend_from_slice(payload);
out
}
fn encode_varint(mut value: u64) -> Vec<u8> {
let mut out = Vec::new();
loop {
let mut byte = (value & 0x7f) as u8;
value >>= 7;
if value != 0 {
byte |= 0x80;
}
out.push(byte);
if value == 0 {
break;
}
}
out
}
fn build_gen_metadata() -> Vec<u8> {
build_gen_metadata_with_model("gemini-3-flash-a")
}
fn build_gen_metadata_with_model(model: &str) -> Vec<u8> {
let mut usage = Vec::new();
usage.extend(enc_varint(1, 1132)); usage.extend(enc_varint(2, 500)); usage.extend(enc_varint(5, 16000)); usage.extend(enc_varint(9, 300)); usage.extend(enc_varint(10, 40)); usage.extend(enc_len(11, b"resp-1"));
let mut chat_model = Vec::new();
chat_model.extend(enc_len(4, &usage));
chat_model.extend(enc_len(19, model.as_bytes()));
enc_len(1, &chat_model)
}
fn build_trajectory_meta() -> Vec<u8> {
let workspace = enc_len(1, b"file:///C:/Users/Frank/obsidian-vault");
let created = {
let mut created = Vec::new();
created.extend(enc_varint(1, 1_781_502_653)); created.extend(enc_varint(2, 0)); created
};
let mut blob = Vec::new();
blob.extend(enc_len(1, &workspace));
blob.extend(enc_len(2, &created));
blob
}
#[test]
fn overlarge_varint_token_counts_are_clamped_not_wrapped() {
let mut usage = Vec::new();
usage.extend(enc_varint(1, u64::MAX)); usage.extend(enc_varint(2, 10)); usage.extend(enc_varint(9, u64::MAX)); usage.extend(enc_len(11, b"resp-overflow"));
let mut chat_model = Vec::new();
chat_model.extend(enc_len(4, &usage));
chat_model.extend(enc_len(19, b"gemini-3-flash-a"));
let blob = enc_len(1, &chat_model);
let mut seen = HashSet::new();
let msg = parse_gen_metadata(&blob, "s", 1_000, &mut seen).expect("parses");
assert_eq!(msg.tokens.output, i64::MAX);
assert_eq!(msg.tokens.input, i64::MAX); assert!(msg.tokens.input >= 0 && msg.tokens.output >= 0);
}
#[test]
fn parses_tokens_model_and_workspace_from_db() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("session-test.db");
{
let conn = Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE gen_metadata (idx integer, data blob, size integer);
CREATE TABLE trajectory_metadata_blob (id text, data blob);",
)
.unwrap();
conn.execute(
"INSERT INTO gen_metadata (idx, data, size) VALUES (0, ?1, 0)",
params![build_gen_metadata()],
)
.unwrap();
conn.execute(
"INSERT INTO trajectory_metadata_blob (id, data) VALUES ('main', ?1)",
params![build_trajectory_meta()],
)
.unwrap();
}
let messages = parse_antigravity_cli_file(&path);
assert_eq!(messages.len(), 1);
let message = &messages[0];
assert_eq!(message.client, "antigravity-cli");
assert_eq!(message.model_id, "gemini-3.5-flash-high");
assert_eq!(message.provider_id, "google");
assert_eq!(message.session_id, "session-test");
assert_eq!(message.tokens.input, 1632); assert_eq!(message.tokens.cache_read, 16000);
assert_eq!(message.tokens.output, 300);
assert_eq!(message.tokens.reasoning, 40);
assert_eq!(message.dedup_key.as_deref(), Some("resp-1"));
assert_eq!(message.timestamp, 1_781_502_653_000);
assert_eq!(
message.workspace_key.as_deref(),
Some("C:/Users/Frank/obsidian-vault")
);
assert_eq!(message.workspace_label.as_deref(), Some("obsidian-vault"));
}
#[test]
fn resolves_current_antigravity_cli_response_model() {
let blob = build_gen_metadata_with_model("gemini-3-flash-agent");
let mut seen = HashSet::new();
let message = parse_gen_metadata(&blob, "session", 1_000, &mut seen).unwrap();
assert_eq!(message.model_id, "gemini-3.5-flash-high");
assert_eq!(message.provider_id, "google");
}
#[test]
fn per_generation_timestamp_overrides_session_fallback() {
let session_fallback = 111_000_i64;
let mut usage = Vec::new();
usage.extend(enc_varint(2, 500)); usage.extend(enc_varint(9, 300)); usage.extend(enc_len(11, b"with-time"));
let mut gen_time = Vec::new();
gen_time.extend(enc_varint(1, 1_781_000_000)); gen_time.extend(enc_varint(2, 250_000_000)); let gen9 = enc_len(4, &gen_time);
let mut chat_model = Vec::new();
chat_model.extend(enc_len(4, &usage));
chat_model.extend(enc_len(9, &gen9));
chat_model.extend(enc_len(19, b"gemini-3-flash-a"));
let blob = enc_len(1, &chat_model);
let mut seen = HashSet::new();
let message = parse_gen_metadata(&blob, "s", session_fallback, &mut seen).unwrap();
assert_eq!(
message.timestamp,
1_781_000_000 * 1000 + 250,
"per-generation #9.#4 timestamp must override the session fallback"
);
let mut seen2 = HashSet::new();
let fallback_msg =
parse_gen_metadata(&build_gen_metadata(), "s", session_fallback, &mut seen2).unwrap();
assert_eq!(
fallback_msg.timestamp, session_fallback,
"a row without #9.#4 must use the session-created fallback"
);
}
#[test]
fn dedupes_repeated_response_ids_and_skips_zero_usage() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("dupes.db");
let mut zero_usage = Vec::new();
zero_usage.extend(enc_len(11, b"zero"));
let mut zero_chat = Vec::new();
zero_chat.extend(enc_len(4, &zero_usage));
zero_chat.extend(enc_len(19, b"gemini-3-flash-a"));
let zero_blob = enc_len(1, &zero_chat);
{
let conn = Connection::open(&path).unwrap();
conn.execute_batch("CREATE TABLE gen_metadata (idx integer, data blob, size integer);")
.unwrap();
for (idx, blob) in [
(0, build_gen_metadata()),
(1, build_gen_metadata()),
(2, zero_blob),
] {
conn.execute(
"INSERT INTO gen_metadata (idx, data, size) VALUES (?1, ?2, 0)",
params![idx, blob],
)
.unwrap();
}
}
let messages = parse_antigravity_cli_file(&path);
assert_eq!(messages.len(), 1);
assert!(messages[0].timestamp > 0);
}
#[test]
fn emitted_model_string_resolves_to_priced_alias() {
assert_eq!(
pricing::aliases::resolve_alias("gemini-3-flash-a"),
Some("gemini-3.5-flash-high")
);
}
#[test]
fn output_and_thinking_map_to_fields_9_and_10() {
let output = 300u64;
let thinking = 40u64;
let total_output = output + thinking;
let mut usage = Vec::new();
usage.extend(enc_varint(1, 1132)); usage.extend(enc_varint(2, 500)); usage.extend(enc_varint(3, total_output)); usage.extend(enc_varint(9, output)); usage.extend(enc_varint(10, thinking)); usage.extend(enc_len(11, b"invariant-1"));
let mut chat_model = Vec::new();
chat_model.extend(enc_len(4, &usage));
chat_model.extend(enc_len(19, b"gemini-3-flash-a"));
let blob = enc_len(1, &chat_model);
let mut seen = HashSet::new();
let message = parse_gen_metadata(&blob, "session", 0, &mut seen).unwrap();
assert_eq!(message.tokens.output, output as i64);
assert_eq!(message.tokens.reasoning, thinking as i64);
assert_eq!(
(message.tokens.output + message.tokens.reasoning) as u64,
total_output
);
}
#[test]
fn malformed_blob_returns_none_without_panic() {
let mut seen = HashSet::new();
assert!(parse_gen_metadata(&[], "s", 0, &mut seen).is_none());
assert!(parse_gen_metadata(&[0xff, 0xff, 0xff, 0xff], "s", 0, &mut seen).is_none());
let truncated = [(1u8 << 3) | 2, 0x7f, 0x01, 0x02];
assert!(parse_gen_metadata(&truncated, "s", 0, &mut seen).is_none());
let inner = [(4u8 << 3) | 2, 0x40, 0x00];
let mut outer = vec![(1u8 << 3) | 2, inner.len() as u8];
outer.extend_from_slice(&inner);
assert!(parse_gen_metadata(&outer, "s", 0, &mut seen).is_none());
}
#[test]
fn proto_timestamp_ms_overflow_returns_none_without_panic() {
let mut overflow = Vec::new();
overflow.extend(enc_varint(1, i64::MAX as u64)); overflow.extend(enc_varint(2, 0)); assert_eq!(proto_timestamp_ms(&overflow), None);
let ok_seconds = i64::MAX / 1000;
let mut ok = Vec::new();
ok.extend(enc_varint(1, ok_seconds as u64));
ok.extend(enc_varint(2, 0));
assert_eq!(proto_timestamp_ms(&ok), Some(ok_seconds * 1000));
let mut normal = Vec::new();
normal.extend(enc_varint(1, 1_781_000_000));
normal.extend(enc_varint(2, 250_000_000)); assert_eq!(
proto_timestamp_ms(&normal),
Some(1_781_000_000 * 1000 + 250)
);
}
#[test]
fn proto_timestamp_ms_rejects_out_of_range_nanos() {
let mut bad_nanos = Vec::new();
bad_nanos.extend(enc_varint(1, 1_781_000_000)); bad_nanos.extend(enc_varint(2, 1_000_000_000)); assert_eq!(proto_timestamp_ms(&bad_nanos), None);
let mut huge_nanos = Vec::new();
huge_nanos.extend(enc_varint(1, 1_781_000_000));
huge_nanos.extend(enc_varint(2, u64::MAX));
assert_eq!(proto_timestamp_ms(&huge_nanos), None);
let mut max_nanos = Vec::new();
max_nanos.extend(enc_varint(1, 1_781_000_000));
max_nanos.extend(enc_varint(2, 999_999_999));
assert_eq!(
proto_timestamp_ms(&max_nanos),
Some(1_781_000_000 * 1000 + 999)
);
let session_fallback = 222_000_i64;
let mut usage = Vec::new();
usage.extend(enc_varint(2, 500)); usage.extend(enc_varint(9, 300)); usage.extend(enc_len(11, b"bad-nanos"));
let mut gen_time = Vec::new();
gen_time.extend(enc_varint(1, 1_781_000_000)); gen_time.extend(enc_varint(2, 1_000_000_000)); let gen9 = enc_len(4, &gen_time);
let mut chat_model = Vec::new();
chat_model.extend(enc_len(4, &usage));
chat_model.extend(enc_len(9, &gen9));
chat_model.extend(enc_len(19, b"gemini-3-flash-a"));
let blob = enc_len(1, &chat_model);
let mut seen = HashSet::new();
let message = parse_gen_metadata(&blob, "s", session_fallback, &mut seen).unwrap();
assert_eq!(
message.timestamp, session_fallback,
"out-of-range per-generation nanos must fall back to the session timestamp"
);
}
#[test]
fn file_uri_to_path_handles_windows_posix_and_unc() {
assert_eq!(
file_uri_to_path("file:///C:/Users/Frank/obsidian-vault").as_deref(),
Some("C:/Users/Frank/obsidian-vault")
);
assert_eq!(
file_uri_to_path("file:///home/frank/project").as_deref(),
Some("/home/frank/project")
);
assert_eq!(
file_uri_to_path("file://server/share/code").as_deref(),
Some("//server/share/code")
);
assert_eq!(
file_uri_to_path("file:///D:/%E6%88%91%E7%9A%84").as_deref(),
Some("D:/我的")
);
assert_eq!(file_uri_to_path("not-a-file-uri"), None);
}
}