Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions rust/crates/ccusage/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,5 @@ mimalloc = "0.1.50"

[dev-dependencies]
ccusage-test-support = { path = "../ccusage-test-support" }
filetime = "0.2"
insta = { version = "1.47.2", features = ["json"] }
231 changes: 229 additions & 2 deletions rust/crates/ccusage/src/adapter/opencode/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ use std::{
collections::HashSet,
fs,
path::{Path, PathBuf},
time::UNIX_EPOCH,
};

use jiff::tz::TimeZone as JiffTimeZone;
use jiff::{civil, tz::TimeZone as JiffTimeZone};
use serde_json::Value;

use super::{parser::message_value_to_entry, paths::paths};
Expand All @@ -13,6 +14,35 @@ use crate::{
collect_files_with_extension, debug_log, parse_tz, LoadedEntry, PricingMap, Result,
};

const MS_PER_DAY: i64 = 86_400_000;

fn parse_yyyymmdd_to_utc_ms(value: &str) -> Option<i64> {
let digits: String = value.chars().filter(|c| c.is_ascii_digit()).collect();
if digits.len() != 8 {
return None;
}
let year: i16 = digits[0..4].parse().ok()?;
let month: i8 = digits[4..6].parse().ok()?;
let day: i8 = digits[6..8].parse().ok()?;
let date = civil::Date::new(year, month, day).ok()?;
let zoned = date.to_zoned(JiffTimeZone::UTC).ok()?;
Some(zoned.timestamp().as_millisecond())
}

fn since_until_ms_with_slack(shared: &SharedArgs) -> (Option<i64>, Option<i64>) {
let since_ms = shared
.since
.as_deref()
.and_then(parse_yyyymmdd_to_utc_ms)
.map(|ms| ms - MS_PER_DAY);
let until_ms = shared
.until
.as_deref()
.and_then(parse_yyyymmdd_to_utc_ms)
.map(|ms| ms + 2 * MS_PER_DAY);
(since_ms, until_ms)
}

pub(crate) fn load_entries(shared: &SharedArgs) -> Result<Vec<LoadedEntry>> {
crate::progress::track_usage_load(
crate::progress::UsageLoadAgent::OpenCode,
Expand Down Expand Up @@ -51,6 +81,7 @@ pub(crate) fn load_entries_from_directory(
))
};
let tz = parse_tz(shared.timezone.as_deref());
let (since_ms, until_ms) = since_until_ms_with_slack(shared);
let mut entries = Vec::new();
let mut seen = HashSet::new();
if let Some(db_path) = db_path(opencode_dir) {
Expand All @@ -70,6 +101,30 @@ pub(crate) fn load_entries_from_directory(
let mut files = Vec::new();
collect_files_with_extension(&messages_dir, "json", &mut files);
for file in files {
if let (Some(min_ms), Ok(meta)) = (since_ms, fs::metadata(&file)) {
if let Ok(modified) = meta.modified() {
let modified_ms = modified
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(i64::MIN);
if modified_ms < min_ms {
continue;
}
}
}
if let Some(max_ms) = until_ms {
if let Ok(meta) = fs::metadata(&file) {
if let Ok(modified) = meta.modified() {
let modified_ms = modified
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(i64::MAX);
if modified_ms >= max_ms {
continue;
}
}
}
}
if let Some(entry) = read_message_file(&file, tz.as_ref(), shared.mode, pricing.as_ref())? {
if let Some(id) = entry_id(&entry) {
if !seen.insert(id.to_string()) {
Expand Down Expand Up @@ -128,13 +183,39 @@ fn load_entries_from_database(
);
return Vec::new();
};
let Ok(mut statement) = connection.prepare("SELECT id, session_id, data FROM message") else {
let (since_ms, until_ms) = since_until_ms_with_slack(shared);
let sql = match (since_ms, until_ms) {
(Some(_), Some(_)) => {
"SELECT id, session_id, data FROM message \
WHERE time_created >= ?1 AND time_created < ?2"
}
(Some(_), None) => "SELECT id, session_id, data FROM message WHERE time_created >= ?1",
(None, Some(_)) => "SELECT id, session_id, data FROM message WHERE time_created < ?1",
(None, None) => "SELECT id, session_id, data FROM message",
};
let Ok(mut statement) = connection.prepare(sql) else {
debug_log(
shared,
format!("Failed to read OpenCode database: {}", db_path.display()),
);
return Vec::new();
};
let bind_result = match (since_ms, until_ms) {
(Some(s), Some(u)) => statement.bind((1, s)).and_then(|()| statement.bind((2, u))),
(Some(s), None) => statement.bind((1, s)),
(None, Some(u)) => statement.bind((1, u)),
(None, None) => Ok(()),
};
if bind_result.is_err() {
debug_log(
shared,
format!(
"Failed to bind date range to OpenCode query: {}",
db_path.display()
),
);
return Vec::new();
}
let mut entries = Vec::new();
loop {
match statement.next() {
Expand Down Expand Up @@ -210,6 +291,32 @@ mod tests {
statement.next().unwrap();
}

fn create_db_message_with_time(
path: &Path,
id: &str,
session_id: &str,
time_created_ms: i64,
data: &str,
) {
let db = sqlite::open(path).unwrap();
db.execute(
"CREATE TABLE IF NOT EXISTS message \
(id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER NOT NULL DEFAULT 0, data TEXT)",
)
.unwrap();
let mut statement = db
.prepare(
"INSERT INTO message (id, session_id, time_created, data) \
VALUES (?1, ?2, ?3, ?4)",
)
.unwrap();
statement.bind((1, id)).unwrap();
statement.bind((2, session_id)).unwrap();
statement.bind((3, time_created_ms)).unwrap();
statement.bind((4, data)).unwrap();
statement.next().unwrap();
}

#[test]
fn loads_message_json_files() {
let fixture = fs_fixture!({
Expand Down Expand Up @@ -288,6 +395,126 @@ mod tests {
assert_eq!(entries[0].data.message.usage.input_tokens, 80);
}

#[test]
fn since_filter_drops_db_rows_older_than_lower_bound() {
let fixture = fs_fixture!({});
// 2025-12-31 00:00 UTC
create_db_message_with_time(
&fixture.path("opencode.db"),
"msg-old",
"session-old",
1_767_139_200_000,
r#"{"providerID":"anthropic","modelID":"claude-sonnet-4-20250514","time":{"created":1767139200000},"tokens":{"input":1,"output":1}}"#,
);
// 2026-01-04 00:00 UTC, comfortably above since=20260103 minus 1-day slack
create_db_message_with_time(
&fixture.path("opencode.db"),
"msg-new",
"session-new",
1_767_484_800_000,
r#"{"providerID":"anthropic","modelID":"claude-sonnet-4-20250514","time":{"created":1767484800000},"tokens":{"input":2,"output":2}}"#,
);

let shared = SharedArgs {
mode: CostMode::Display,
timezone: Some("UTC".to_string()),
since: Some("20260103".to_string()),
..SharedArgs::default()
};
let entries = load_entries_from_directory(fixture.root(), &shared).unwrap();

assert_eq!(entries.len(), 1);
assert_eq!(entries[0].data.message.id.as_deref(), Some("msg-new"));
}

#[test]
fn until_filter_drops_db_rows_at_or_after_upper_bound() {
let fixture = fs_fixture!({});
// 2026-01-02 00:00 UTC, comfortably below until=20260105 plus 2-day slack (=20260107)
create_db_message_with_time(
&fixture.path("opencode.db"),
"msg-early",
"session-early",
1_767_312_000_000,
r#"{"providerID":"anthropic","modelID":"claude-sonnet-4-20250514","time":{"created":1767312000000},"tokens":{"input":1,"output":1}}"#,
);
// 2026-01-11 00:00 UTC, above until=20260105 plus 2-day slack
create_db_message_with_time(
&fixture.path("opencode.db"),
"msg-late",
"session-late",
1_768_089_600_000,
r#"{"providerID":"anthropic","modelID":"claude-sonnet-4-20250514","time":{"created":1768089600000},"tokens":{"input":2,"output":2}}"#,
);

let shared = SharedArgs {
mode: CostMode::Display,
timezone: Some("UTC".to_string()),
until: Some("20260105".to_string()),
..SharedArgs::default()
};
let entries = load_entries_from_directory(fixture.root(), &shared).unwrap();

assert_eq!(entries.len(), 1);
assert_eq!(entries[0].data.message.id.as_deref(), Some("msg-early"));
}

#[test]
fn since_filter_skips_json_files_with_older_mtime() {
use filetime::{set_file_mtime, FileTime};

let fixture = fs_fixture!({
"storage/message/old.json": r#"{"id":"json-old","sessionID":"session-x","providerID":"anthropic","modelID":"claude-sonnet-4-20250514","time":{"created":1767312000000},"tokens":{"input":1,"output":1}}"#,
"storage/message/new.json": r#"{"id":"json-new","sessionID":"session-y","providerID":"anthropic","modelID":"claude-sonnet-4-20250514","time":{"created":1767312000000},"tokens":{"input":2,"output":2}}"#,
});
// Force old.json mtime far below since=20260102 minus 1-day slack
// 2025-12-25 00:00 UTC = 1_766_620_800 seconds since epoch
set_file_mtime(
fixture.path("storage/message/old.json"),
FileTime::from_unix_time(1_766_620_800, 0),
)
.unwrap();
// new.json keeps its current mtime (now), well above any reasonable since bound

let shared = SharedArgs {
mode: CostMode::Display,
timezone: Some("UTC".to_string()),
since: Some("20260102".to_string()),
..SharedArgs::default()
};
let entries = load_entries_from_directory(fixture.root(), &shared).unwrap();

let ids: Vec<&str> = entries
.iter()
.filter_map(|e| e.data.message.id.as_deref())
.collect();
assert_eq!(ids, vec!["json-new"]);
}

#[test]
fn no_since_until_keeps_all_json_files_regardless_of_mtime() {
use filetime::{set_file_mtime, FileTime};

let fixture = fs_fixture!({
"storage/message/old.json": r#"{"id":"json-old","sessionID":"session-x","providerID":"anthropic","modelID":"claude-sonnet-4-20250514","time":{"created":1767312000000},"tokens":{"input":1,"output":1}}"#,
"storage/message/new.json": r#"{"id":"json-new","sessionID":"session-y","providerID":"anthropic","modelID":"claude-sonnet-4-20250514","time":{"created":1767312000000},"tokens":{"input":2,"output":2}}"#,
});
set_file_mtime(
fixture.path("storage/message/old.json"),
FileTime::from_unix_time(1_000_000_000, 0),
)
.unwrap();

let shared = SharedArgs {
mode: CostMode::Display,
timezone: Some("UTC".to_string()),
..SharedArgs::default()
};
let entries = load_entries_from_directory(fixture.root(), &shared).unwrap();

assert_eq!(entries.len(), 2);
}

#[test]
fn prefers_database_messages_over_duplicate_json_files() {
let fixture = fs_fixture!({
Expand Down
Loading