use crate::TokenBreakdown;
use rusqlite::{Connection, OpenFlags};
use serde::de::DeserializeOwned;
use serde::Deserialize;
use serde_json::Value;
use std::borrow::Cow;
use std::io::BufRead;
use std::ops::ControlFlow;
use std::path::Path;
use std::time::SystemTime;
use tracing::warn;
pub(crate) fn lossy_lines<R: BufRead>(reader: R) -> LossyLines<R> {
LossyLines {
reader,
buf: Vec::new(),
at_start: true,
}
}
pub(crate) struct LossyLines<R> {
reader: R,
buf: Vec<u8>,
at_start: bool,
}
fn read_line_payload<R: BufRead>(
reader: &mut R,
buf: &mut Vec<u8>,
at_start: &mut bool,
) -> Option<usize> {
buf.clear();
match reader.read_until(b'\n', buf) {
Ok(0) => None,
Ok(_) => {
if buf.last() == Some(&b'\n') {
buf.pop();
if buf.last() == Some(&b'\r') {
buf.pop();
}
}
let bom = "\u{feff}".as_bytes();
if std::mem::take(at_start) && buf.starts_with(bom) {
Some(bom.len())
} else {
Some(0)
}
}
Err(_) => None,
}
}
fn read_lossy_line<R: BufRead>(
reader: &mut R,
buf: &mut Vec<u8>,
at_start: &mut bool,
) -> Option<String> {
let start = read_line_payload(reader, buf, at_start)?;
Some(String::from_utf8_lossy(&buf[start..]).into_owned())
}
pub(crate) fn for_each_json_line(path: &Path, sink: &mut dyn FnMut(usize, &str)) {
let Ok(file) = std::fs::File::open(path) else {
return;
};
let mut reader = std::io::BufReader::new(file);
let mut buf = Vec::new();
let mut at_start = true;
let mut index = 0usize;
while let Some(start) = read_line_payload(&mut reader, &mut buf, &mut at_start) {
let text = String::from_utf8_lossy(&buf[start..]);
let trimmed = text.trim();
if !trimmed.is_empty() {
sink(index, trimmed);
}
index += 1;
}
}
pub(crate) struct JsonLineBytes<'a> {
pub(crate) bytes: &'a [u8],
pub(crate) trimmed: &'a str,
pub(crate) valid_utf8: bool,
}
pub(crate) fn for_each_json_line_with_bytes(
path: &Path,
sink: &mut dyn FnMut(JsonLineBytes<'_>) -> ControlFlow<()>,
) {
let Ok(file) = std::fs::File::open(path) else {
return;
};
let mut reader = std::io::BufReader::new(file);
let mut buf = Vec::new();
let mut at_start = true;
while let Some(start) = read_line_payload(&mut reader, &mut buf, &mut at_start) {
let bytes = &buf[start..];
let text = String::from_utf8_lossy(bytes);
let trimmed = text.trim();
if trimmed.is_empty() {
continue;
}
let line = JsonLineBytes {
bytes,
trimmed,
valid_utf8: matches!(text, Cow::Borrowed(_)),
};
if sink(line).is_break() {
break;
}
}
}
impl<R: BufRead> Iterator for LossyLines<R> {
type Item = String;
fn next(&mut self) -> Option<Self::Item> {
read_lossy_line(&mut self.reader, &mut self.buf, &mut self.at_start)
}
}
pub(crate) fn extract_i64(value: Option<&Value>) -> Option<i64> {
value.and_then(|val| {
val.as_i64()
.or_else(|| val.as_u64().map(|v| v.min(i64::MAX as u64) as i64))
.or_else(|| val.as_str().and_then(|s| s.parse::<i64>().ok()))
})
}
pub(crate) fn extract_string(value: Option<&Value>) -> Option<String> {
value.and_then(|val| val.as_str().map(|s| s.to_string()))
}
pub(crate) fn parse_timestamp_value(value: &Value) -> Option<i64> {
if let Some(ts) = value.as_str() {
return parse_timestamp_str(ts);
}
let numeric = value
.as_i64()
.or_else(|| value.as_u64().map(|v| v as i64))?;
if numeric <= 0 {
return None;
}
if numeric >= 1_000_000_000_000 {
Some(numeric)
} else {
Some(numeric.saturating_mul(1000))
}
}
pub(crate) fn parse_timestamp_str(value: &str) -> Option<i64> {
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(value) {
return Some(dt.timestamp_millis());
}
for format in [
"%Y-%m-%dT%H:%M:%S%.f",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%d %H:%M:%S%.f",
"%Y-%m-%d %H:%M:%S",
] {
if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(value, format) {
return Some(naive.and_utc().timestamp_millis());
}
}
if let Ok(numeric) = value.parse::<i64>() {
if numeric <= 0 {
return None;
}
if numeric >= 1_000_000_000_000 {
return Some(numeric);
}
return Some(numeric.saturating_mul(1000));
}
None
}
pub(crate) fn file_modified_timestamp_ms_opt(path: &Path) -> Option<i64> {
std::fs::metadata(path)
.and_then(|meta| meta.modified())
.ok()
.and_then(|time| time.duration_since(SystemTime::UNIX_EPOCH).ok())
.map(|duration| duration.as_millis() as i64)
}
pub(crate) fn file_modified_timestamp_ms(path: &Path) -> i64 {
file_modified_timestamp_ms_opt(path).unwrap_or_else(|| chrono::Utc::now().timestamp_millis())
}
pub(crate) fn open_readonly_sqlite_result(path: &Path) -> rusqlite::Result<Connection> {
Connection::open_with_flags(
path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
}
pub(crate) fn open_readonly_sqlite_opt(path: &Path) -> Option<Connection> {
open_readonly_sqlite_result(path).ok()
}
pub(crate) fn open_readonly_sqlite(path: &Path) -> Option<Connection> {
open_readonly_sqlite_result(path).ok()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SqliteScan {
Ran,
NotOpened,
NotPrepared,
NotExecuted,
Incomplete,
}
impl SqliteScan {
pub(crate) fn ran(self) -> bool {
matches!(self, SqliteScan::Ran | SqliteScan::Incomplete)
}
pub(crate) fn completed(self) -> bool {
matches!(self, SqliteScan::Ran)
}
pub(crate) fn prepared(self) -> bool {
!matches!(self, SqliteScan::NotPrepared | SqliteScan::NotOpened)
}
}
pub(crate) fn sqlite_for_each_row_on(
conn: &Connection,
db_path: &Path,
sql: &str,
what: Option<&str>,
sink: &mut dyn FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<()>,
) -> SqliteScan {
sqlite_for_each_row_on_with_params(conn, db_path, sql, &[], what, sink)
}
pub(crate) fn sqlite_for_each_row_on_with_params(
conn: &Connection,
db_path: &Path,
sql: &str,
params: &[&dyn rusqlite::ToSql],
what: Option<&str>,
sink: &mut dyn FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<()>,
) -> SqliteScan {
let mut stmt = match conn.prepare(sql) {
Ok(stmt) => stmt,
Err(err) => {
if let Some(what) = what {
warn!(
db_path = %db_path.display(),
what,
error = %err,
"Failed to prepare session query"
);
}
return SqliteScan::NotPrepared;
}
};
let mut stepped_off = false;
let mut rows = match stmt.query(params) {
Ok(rows) => rows,
Err(err) => {
if let Some(what) = what {
warn!(
db_path = %db_path.display(),
what,
error = %err,
"Failed to execute session query"
);
}
return SqliteScan::NotExecuted;
}
};
loop {
match rows.next() {
Ok(Some(row)) => {
if let Err(err) = sink(row) {
if let Some(what) = what {
warn!(
db_path = %db_path.display(),
what,
error = %err,
"Failed to decode session row"
);
}
}
}
Ok(None) => break,
Err(err) => {
if let Some(what) = what {
warn!(
db_path = %db_path.display(),
what,
error = %err,
"Failed to decode session row"
);
}
stepped_off = true;
break;
}
}
}
if stepped_off {
SqliteScan::Incomplete
} else {
SqliteScan::Ran
}
}
pub(crate) fn sqlite_for_each_row(
db_path: &Path,
sql: &str,
what: Option<&str>,
sink: &mut dyn FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<()>,
) -> SqliteScan {
let conn = match open_readonly_sqlite_result(db_path) {
Ok(conn) => conn,
Err(err) => {
if let Some(what) = what {
warn!(
db_path = %db_path.display(),
what,
error = %err,
"Failed to open session database"
);
}
return SqliteScan::NotOpened;
}
};
sqlite_for_each_row_on(&conn, db_path, sql, what, sink)
}
pub(crate) fn parse_json_line<T: DeserializeOwned>(line: &str, buffer: &mut Vec<u8>) -> Option<T> {
buffer.clear();
buffer.extend_from_slice(line.as_bytes());
simd_json::from_slice(buffer).ok()
}
pub(crate) fn read_file_or_none(path: &Path) -> Option<Vec<u8>> {
std::fs::read(path).ok()
}
pub(crate) fn back_anchor_timestamp(end: i64, duration: i64) -> i64 {
end.checked_sub(duration)
.filter(|candidate| *candidate > 0)
.unwrap_or(end)
}
pub(crate) fn estimate_tokens(chars: usize) -> i64 {
chars.div_ceil(4) as i64
}
pub(crate) fn session_id_from_path(path: &Path) -> String {
path.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("unknown")
.to_string()
}
pub(crate) fn workspace_key_from_path(path: &Path) -> Option<String> {
path.parent()
.and_then(|dir| dir.file_name())
.and_then(|name| name.to_str())
.and_then(super::normalize_workspace_key)
}
pub(crate) fn timestamp_secs_to_ms(timestamp: f64) -> i64 {
if timestamp > 1e12 {
timestamp as i64
} else {
let millis = timestamp * 1000.0;
if millis.is_nan() {
0
} else {
millis.clamp(i64::MIN as f64, i64::MAX as f64) as i64
}
}
}
pub(crate) fn resolved_provider(
provider: Option<String>,
model_id: &str,
fallback: &str,
) -> String {
provider
.filter(|provider| !provider.trim().is_empty())
.and_then(|provider| crate::provider_identity::canonical_provider(provider.trim()))
.or_else(|| {
crate::provider_identity::inferred_provider_from_model(model_id).map(str::to_string)
})
.unwrap_or_else(|| fallback.to_string())
}
#[derive(Debug, Deserialize)]
pub struct AnthropicUsage {
pub input_tokens: Option<i64>,
pub output_tokens: Option<i64>,
pub cache_read_input_tokens: Option<i64>,
pub cache_creation_input_tokens: Option<i64>,
}
impl AnthropicUsage {
pub fn to_breakdown(&self) -> TokenBreakdown {
TokenBreakdown {
input: self.input_tokens.unwrap_or(0).max(0),
output: self.output_tokens.unwrap_or(0).max(0),
cache_read: self.cache_read_input_tokens.unwrap_or(0).max(0),
cache_write: self.cache_creation_input_tokens.unwrap_or(0).max(0),
reasoning: 0,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CamelUsage {
pub(crate) input: Option<i64>,
pub(crate) output: Option<i64>,
pub(crate) cache_read: Option<i64>,
pub(crate) cache_write: Option<i64>,
#[allow(dead_code)]
pub(crate) total_tokens: Option<i64>,
pub(crate) reasoning_tokens: Option<i64>,
pub(crate) cost: Option<CamelCost>,
}
impl CamelUsage {
pub(crate) fn to_breakdown(&self) -> TokenBreakdown {
TokenBreakdown {
input: self.input.unwrap_or(0).max(0),
output: self.output.unwrap_or(0).max(0),
cache_read: self.cache_read.unwrap_or(0).max(0),
cache_write: self.cache_write.unwrap_or(0).max(0),
reasoning: 0,
}
}
pub(crate) fn to_breakdown_with_reasoning(&self) -> TokenBreakdown {
let mut breakdown = self.to_breakdown();
let reasoning = self
.reasoning_tokens
.unwrap_or(0)
.max(0)
.min(breakdown.output);
breakdown.output -= reasoning;
breakdown.reasoning = reasoning;
breakdown
}
}
#[derive(Debug, Deserialize)]
pub(crate) struct CamelCost {
pub(crate) total: Option<f64>,
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::ErrorCode;
#[test]
fn lossy_lines_survives_undecodable_bytes_and_strips_a_bom() {
let raw: &[u8] = b"\xef\xbb\xbffirst\r\nse\xffcond\nthird";
let lines: Vec<String> = lossy_lines(raw).collect();
assert_eq!(lines, vec!["first", "se\u{fffd}cond", "third"]);
}
#[test]
fn lossy_lines_keeps_empty_lines_and_ends_at_eof() {
let raw: &[u8] = b"a\n\nb\n";
let lines: Vec<String> = lossy_lines(raw).collect();
assert_eq!(lines, vec!["a", "", "b"]);
}
#[test]
fn for_each_json_line_with_bytes_preserves_distinct_invalid_sequences() {
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("transcript.jsonl");
std::fs::write(
&path,
b"\xef\xbb\xbf {\"clean\":1} \r\n\n \na\xff\na\xfe\n",
)
.unwrap();
let mut lines = Vec::new();
for_each_json_line_with_bytes(&path, &mut |line| {
lines.push((
line.bytes.to_vec(),
line.trimmed.to_string(),
line.valid_utf8,
));
ControlFlow::Continue(())
});
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].0, b" {\"clean\":1} ");
assert_eq!(lines[0].1, r#"{"clean":1}"#);
assert!(lines[0].2);
assert_eq!(lines[1].1, lines[2].1);
assert_eq!(lines[1].0, b"a\xff");
assert_eq!(lines[2].0, b"a\xfe");
assert!(!lines[1].2);
assert!(!lines[2].2);
}
#[test]
fn for_each_json_line_with_bytes_stops_on_break() {
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("transcript.jsonl");
std::fs::write(&path, b"first\nsecond\nthird\n").unwrap();
let mut seen = Vec::new();
for_each_json_line_with_bytes(&path, &mut |line| {
seen.push(line.trimmed.to_string());
if line.trimmed == "second" {
return ControlFlow::Break(());
}
ControlFlow::Continue(())
});
assert_eq!(seen, vec!["first", "second"]);
}
#[test]
fn parse_timestamp_value_rejects_zero_and_negative_numbers() {
assert!(parse_timestamp_value(&serde_json::json!(0)).is_none());
assert!(parse_timestamp_value(&serde_json::json!(-1000)).is_none());
assert!(parse_timestamp_value(&serde_json::json!(-1_700_000_000_000_i64)).is_none());
}
#[test]
fn parse_timestamp_value_accepts_positive_numbers() {
assert_eq!(
parse_timestamp_value(&serde_json::json!(1_700_000_000_000_i64)),
Some(1_700_000_000_000)
);
assert_eq!(
parse_timestamp_value(&serde_json::json!(1_700_000_000_i64)),
Some(1_700_000_000_000)
);
}
#[test]
fn parse_timestamp_str_rejects_zero_and_negative_strings() {
assert!(parse_timestamp_str("0").is_none());
assert!(parse_timestamp_str("-5").is_none());
}
#[test]
fn parse_timestamp_str_accepts_timezone_less_datetimes_as_utc() {
assert_eq!(
parse_timestamp_str("2026-06-16T12:00:00"),
Some(1_781_611_200_000)
);
assert_eq!(
parse_timestamp_str("2026-06-16 12:00:00"),
Some(1_781_611_200_000)
);
assert_eq!(
parse_timestamp_str("2026-06-16T12:00:00.500"),
Some(1_781_611_200_500)
);
assert_eq!(
parse_timestamp_str("2026-06-16T12:00:00Z"),
Some(1_781_611_200_000)
);
}
#[test]
fn open_readonly_sqlite_rejects_writes_but_reads_existing_data() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("state.db");
let conn = Connection::open(&db_path).unwrap();
conn.execute("CREATE TABLE sessions (id TEXT)", []).unwrap();
drop(conn);
let conn = open_readonly_sqlite(&db_path).unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 0);
let error = conn
.execute("INSERT INTO sessions (id) VALUES ('session')", [])
.unwrap_err();
assert!(
matches!(
&error,
rusqlite::Error::SqliteFailure(sqlite_error, _)
if sqlite_error.code == ErrorCode::ReadOnly
),
"expected SQLITE_READONLY, got {error:?}"
);
}
#[test]
fn open_readonly_sqlite_preserves_cannot_open_error() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("missing.db");
let error = open_readonly_sqlite_result(&db_path).unwrap_err();
assert!(
matches!(
&error,
rusqlite::Error::SqliteFailure(sqlite_error, _)
if sqlite_error.code == ErrorCode::CannotOpen
),
"expected SQLITE_CANTOPEN, got {error:?}"
);
}
}