diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 706cb0f0..c103e5d0 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -111,6 +111,7 @@ dependencies = [ "ccusage-terminal", "ccusage-test-support", "compact_str", + "filetime", "insta", "jiff", "memchr", @@ -261,6 +262,16 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" diff --git a/rust/crates/ccusage/Cargo.toml b/rust/crates/ccusage/Cargo.toml index d79c6775..c3c46443 100644 --- a/rust/crates/ccusage/Cargo.toml +++ b/rust/crates/ccusage/Cargo.toml @@ -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"] } diff --git a/rust/crates/ccusage/src/adapter/opencode/loader.rs b/rust/crates/ccusage/src/adapter/opencode/loader.rs index f9ae582a..b2ba0fb0 100644 --- a/rust/crates/ccusage/src/adapter/opencode/loader.rs +++ b/rust/crates/ccusage/src/adapter/opencode/loader.rs @@ -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}; @@ -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 { + 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, Option) { + 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> { crate::progress::track_usage_load( crate::progress::UsageLoadAgent::OpenCode, @@ -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) { @@ -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()) { @@ -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() { @@ -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!({ @@ -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!({