use crate::config;
use crate::session::{Session, SessionData};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
const CACHE_VERSION: &str = env!("CCTOP_CACHE_HASH");
#[derive(Serialize, Deserialize)]
struct Entry {
path: PathBuf,
#[serde(default)]
stored_at: u64,
data: SessionData,
}
#[derive(Deserialize)]
struct DiskCache {
version: String,
entries: HashMap<String, Entry>,
}
#[derive(Serialize)]
struct DiskCacheRef<'a> {
version: &'a str,
entries: &'a HashMap<String, Entry>,
}
const MAX_ENTRIES: usize = 2_000;
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
pub fn cache_key(path: &Path) -> Option<String> {
let meta = std::fs::metadata(path).ok()?;
let mut key = format!(
"{}|{}|{}|p{}",
path.display(),
meta.len(),
config::file_mtime_ms(path),
crate::pricing::pricing_epoch()
);
let sub_dir = path.with_extension("").join("subagents");
if let Ok(rd) = std::fs::read_dir(&sub_dir) {
let newest = rd
.flatten()
.filter(|e| e.file_name().to_string_lossy().ends_with(".jsonl"))
.map(|e| config::file_mtime_ms(&e.path()))
.chain(std::iter::once(config::file_mtime_ms(&sub_dir)))
.max()
.unwrap_or(0);
key.push_str(&format!("|{newest}"));
}
Some(key)
}
pub struct CostCache {
entries: Mutex<HashMap<String, Entry>>,
dirty: Mutex<bool>,
}
impl Default for CostCache {
fn default() -> Self {
Self::load()
}
}
pub fn clear_session_cache() -> anyhow::Result<bool> {
match std::fs::remove_file(&*config::COST_CACHE_FILE) {
Ok(()) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error.into()),
}
}
impl CostCache {
pub fn load() -> Self {
let mut entries = std::fs::read_to_string(&*config::COST_CACHE_FILE)
.ok()
.and_then(|t| serde_json::from_str::<DiskCache>(&t).ok())
.filter(|c| c.version == CACHE_VERSION)
.map(|c| c.entries)
.unwrap_or_default();
entries.retain(|_, e| e.path.exists());
CostCache {
entries: Mutex::new(entries),
dirty: Mutex::new(false),
}
}
pub fn get(&self, key: &str) -> Option<SessionData> {
self.entries.lock().ok()?.get(key).map(|e| e.data.clone())
}
pub fn put(&self, key: String, path: &Path, data: &SessionData) {
if let Ok(mut entries) = self.entries.lock() {
entries.retain(|k, e| k == &key || e.path != path);
entries.insert(
key,
Entry {
path: path.to_path_buf(),
stored_at: now_ms(),
data: data.clone(),
},
);
}
if let Ok(mut d) = self.dirty.lock() {
*d = true;
}
}
pub fn save(&self) {
if !self.dirty.lock().map(|d| *d).unwrap_or(false) {
return;
}
let Ok(mut entries) = self.entries.lock() else {
return;
};
evict_oldest(&mut entries);
let _ = std::fs::create_dir_all(&*config::CACHE_DIR);
let tmp = config::COST_CACHE_FILE.with_extension("json.tmp");
let wrote = std::fs::File::create(&tmp).is_ok_and(|file| {
serde_json::to_writer(
std::io::BufWriter::new(&file),
&DiskCacheRef {
version: CACHE_VERSION,
entries: &entries,
},
)
.is_ok()
});
if wrote && std::fs::rename(&tmp, &*config::COST_CACHE_FILE).is_ok() {
if let Ok(mut d) = self.dirty.lock() {
*d = false;
}
} else {
let _ = std::fs::remove_file(&tmp);
}
}
}
fn evict_oldest(entries: &mut HashMap<String, Entry>) {
if entries.len() <= MAX_ENTRIES {
return;
}
let mut order: Vec<(u64, String)> = entries
.iter()
.map(|(k, e)| (e.stored_at, k.clone()))
.collect();
order.sort_unstable();
for (_, key) in order.into_iter().take(entries.len() - MAX_ENTRIES) {
entries.remove(&key);
}
}
struct MemEntry {
mtime: u64,
pricing_epoch: u64,
data: SessionData,
parsed_in: std::time::Duration,
parsed_at: std::time::Instant,
size: u64,
}
fn file_size(path: &Path) -> u64 {
std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)
}
const REPARSE_BACKOFF: u32 = 20;
const LARGE_TRANSCRIPT_BYTES: u64 = 1 << 20;
const FLOOR_SECS_PER_MB: u64 = 10;
const MAX_FLOOR_SECS: u64 = 60;
fn reparse_floor(size: u64) -> std::time::Duration {
if size < LARGE_TRANSCRIPT_BYTES {
return std::time::Duration::ZERO;
}
let mb = size / LARGE_TRANSCRIPT_BYTES;
std::time::Duration::from_secs((mb * FLOOR_SECS_PER_MB).min(MAX_FLOOR_SECS))
}
fn reuse_stale(parsed_in: std::time::Duration, since: std::time::Duration, size: u64) -> bool {
since < parsed_in * REPARSE_BACKOFF || since < reparse_floor(size)
}
#[derive(Default)]
pub struct Store {
mem: Mutex<HashMap<String, MemEntry>>,
disk: CostCache,
}
impl Store {
pub fn new() -> Self {
Store {
mem: Mutex::new(HashMap::new()),
disk: CostCache::load(),
}
}
pub fn session_data(&self, session: &Session) -> SessionData {
self.data(session, true)
}
pub fn session_data_fresh(&self, session: &Session) -> SessionData {
self.data(session, false)
}
fn data(&self, session: &Session, allow_stale: bool) -> SessionData {
let Some(file) = session.data_file.as_ref() else {
return SessionData::default();
};
let mem_key = session.key();
let mtime = crate::session::effective_mtime_ms(session);
let epoch = crate::pricing::pricing_epoch();
if let Ok(mem) = self.mem.lock()
&& let Some(entry) = mem.get(&mem_key)
&& entry.pricing_epoch == epoch
{
if entry.mtime == mtime {
return entry.data.clone();
}
if allow_stale && reuse_stale(entry.parsed_in, entry.parsed_at.elapsed(), entry.size) {
return entry.data.clone();
}
}
let disk_key = (!matches!(
session.provider,
crate::pricing::Provider::OpenCode | crate::pricing::Provider::Windsurf
))
.then(|| cache_key(file))
.flatten();
if let Some(key) = &disk_key
&& let Some(data) = self.disk.get(key)
{
if let Ok(mut mem) = self.mem.lock() {
mem.insert(
mem_key,
MemEntry {
mtime,
pricing_epoch: epoch,
data: data.clone(),
parsed_in: std::time::Duration::ZERO,
parsed_at: std::time::Instant::now(),
size: file_size(file),
},
);
}
return data;
}
let started = std::time::Instant::now();
let mut data = match session.provider {
crate::pricing::Provider::Claude => crate::session::claude::extract(file),
crate::pricing::Provider::Codex => crate::session::codex::extract(file),
crate::pricing::Provider::Cursor => crate::session::cursor::extract(file),
crate::pricing::Provider::OpenCode => {
crate::session::opencode::extract(file, &session.session_id)
}
crate::pricing::Provider::Gemini => crate::session::gemini::extract(file),
crate::pricing::Provider::Pi => crate::session::pi::extract(file),
crate::pricing::Provider::Windsurf => {
crate::session::windsurf::extract(file, &session.session_id)
}
};
let parsed_in = started.elapsed();
data.finalize();
if let Ok(mut mem) = self.mem.lock() {
mem.insert(
mem_key,
MemEntry {
mtime,
pricing_epoch: epoch,
data: data.clone(),
parsed_in,
parsed_at: std::time::Instant::now(),
size: file_size(file),
},
);
}
if let Some(key) = disk_key
&& data.error.is_none()
{
self.disk.put(key, file, &data);
}
data
}
pub fn evict(&self, session: &Session) {
if let Ok(mut mem) = self.mem.lock() {
mem.remove(&session.key());
}
if let Some(file) = session.data_file.as_ref()
&& let Some(key) = cache_key(file)
{
if let Ok(mut e) = self.disk.entries.lock() {
e.remove(&key);
}
if let Ok(mut d) = self.disk.dirty.lock() {
*d = true;
}
}
}
pub fn save(&self) {
self.disk.save();
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct UiPrefs {
pub bottom_tab: usize,
pub live_only: bool,
pub inactivity_filter: Option<String>,
pub agent_live_filter: bool,
pub tool_show_diff: bool,
pub expanded: Vec<String>,
pub subagent_sort_col: String,
pub subagent_sort_asc: bool,
pub cost_floor: f64,
pub notify: bool,
pub shell_alias_installed: bool,
pub hidden_columns: Vec<String>,
pub theme: Option<String>,
pub search_history: Vec<String>,
}
pub const MAX_SEARCH_HISTORY: usize = 20;
impl Default for UiPrefs {
fn default() -> Self {
UiPrefs {
bottom_tab: 0,
live_only: false,
inactivity_filter: None,
agent_live_filter: false,
tool_show_diff: false,
expanded: Vec::new(),
subagent_sort_col: "last".into(),
subagent_sort_asc: false,
cost_floor: 0.0,
notify: false,
shell_alias_installed: false,
hidden_columns: Vec::new(),
theme: None,
search_history: Vec::new(),
}
}
}
impl UiPrefs {
pub fn load() -> Self {
std::fs::read_to_string(&*config::UI_PREFS_FILE)
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default()
}
pub fn save(&self) {
let _ = std::fs::create_dir_all(&*config::CACHE_DIR);
if let Ok(text) = serde_json::to_string(self) {
let _ = std::fs::write(&*config::UI_PREFS_FILE, text);
}
}
}
#[cfg(test)]
#[allow(dead_code)]
mod build_script {
include!(concat!(env!("CARGO_MANIFEST_DIR"), "/build.rs"));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reparse_backoff_scales_with_parse_cost() {
use std::time::Duration;
const SMALL: u64 = 64 * 1024;
assert!(!reuse_stale(
Duration::from_millis(5),
Duration::from_millis(200),
SMALL
));
assert!(reuse_stale(
Duration::from_millis(500),
Duration::from_secs(2),
SMALL
));
assert!(!reuse_stale(
Duration::from_millis(500),
Duration::from_secs(11),
SMALL
));
assert!(!reuse_stale(Duration::ZERO, Duration::ZERO, SMALL));
}
#[test]
fn large_transcripts_get_a_floor_the_refresh_interval_cannot_beat() {
use std::time::Duration;
let big = 4 * (1 << 20);
assert!(reuse_stale(
Duration::from_millis(50),
Duration::from_secs(2),
big
));
assert!(!reuse_stale(
Duration::from_millis(50),
Duration::from_secs(41),
big
));
assert_eq!(reparse_floor(900 * 1024), Duration::ZERO);
assert_eq!(
reparse_floor(500 * (1 << 20)),
Duration::from_secs(MAX_FLOOR_SECS)
);
}
#[test]
fn cache_version_is_derived_and_stable() {
assert!(!CACHE_VERSION.is_empty(), "build.rs must set the hash");
assert_eq!(CACHE_VERSION.len(), 16);
assert!(CACHE_VERSION.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(CACHE_VERSION, env!("CCTOP_CACHE_HASH"));
}
fn hashed_sources() -> Vec<(String, Vec<u8>)> {
let (files, _dirs) = build_script::sources();
assert!(
!files.is_empty(),
"expected to run from the package root, found no sources"
);
build_script::read_all(&files)
}
#[test]
fn the_hashed_set_covers_every_parser_and_the_shipped_hash_matches_it() {
let sources = hashed_sources();
let names: Vec<&str> = sources.iter().map(|(p, _)| p.as_str()).collect();
for provider in [
"claude", "codex", "cursor", "gemini", "opencode", "pi", "windsurf",
] {
let expected = format!("src/session/{provider}.rs");
assert!(names.contains(&expected.as_str()), "{expected} not hashed");
}
assert!(names.contains(&"src/session/mod.rs"));
assert!(names.contains(&"src/config.rs"));
assert!(names.contains(&"src/pricing.rs"));
assert_eq!(
format!("{:016x}", build_script::digest(&sources)),
CACHE_VERSION
);
}
#[test]
fn changing_session_data_changes_the_derived_hash() {
let base = hashed_sources();
let before = build_script::digest(&base);
assert_eq!(before, build_script::digest(&base), "digest is a function");
let mut edited = base.clone();
let model = edited
.iter_mut()
.find(|(p, _)| p == "src/session/mod.rs")
.expect("the data model is hashed");
model
.1
.extend_from_slice(b"\n// a new cached field lands here\n");
assert_ne!(
before,
build_script::digest(&edited),
"an edit to the data model must invalidate the cache"
);
let mut moved = base.clone();
moved[0].0 = format!("src/session/renamed_{}", moved[0].0);
assert_ne!(before, build_script::digest(&moved));
}
#[cfg(unix)]
#[test]
fn entries_survive_a_pipe_in_the_transcript_path() {
let dir = std::env::temp_dir().join(format!("cctop-pipe-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("a|b.jsonl");
std::fs::write(&file, "x").unwrap();
let cache = CostCache {
entries: Mutex::new(HashMap::new()),
dirty: Mutex::new(false),
};
let key = cache_key(&file).unwrap();
assert!(key.contains("a|b"), "the key embeds the awkward path");
cache.put(key.clone(), &file, &SessionData::default());
let entries = cache.entries.lock().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[&key].path, file);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_changed_transcript_supersedes_its_own_entry() {
let path = Path::new("/tmp/whatever.jsonl");
let cache = CostCache {
entries: Mutex::new(HashMap::new()),
dirty: Mutex::new(false),
};
cache.put("k1".into(), path, &SessionData::default());
cache.put("k2".into(), path, &SessionData::default());
let entries = cache.entries.lock().unwrap();
assert_eq!(entries.len(), 1);
assert!(entries.contains_key("k2"));
}
#[test]
fn eviction_keeps_the_newest_entries() {
let mut entries = HashMap::new();
for i in 0..(MAX_ENTRIES + 10) {
entries.insert(
format!("k{i}"),
Entry {
path: PathBuf::from(format!("/tmp/{i}.jsonl")),
stored_at: i as u64,
data: SessionData::default(),
},
);
}
evict_oldest(&mut entries);
assert_eq!(entries.len(), MAX_ENTRIES);
assert!(!entries.contains_key("k9"));
assert!(entries.contains_key("k10"));
}
#[test]
fn cache_key_changes_with_content() {
let dir = std::env::temp_dir().join(format!("cctop-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("a.jsonl");
std::fs::write(&f, "one").unwrap();
let k1 = cache_key(&f).unwrap();
std::fs::write(&f, "one plus more").unwrap();
let k2 = cache_key(&f).unwrap();
assert_ne!(k1, k2, "size change must invalidate the key");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn cache_key_carries_pricing_generation() {
let dir = std::env::temp_dir().join(format!("cctop-price-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("a.jsonl");
std::fs::write(&f, "x").unwrap();
let key = cache_key(&f).unwrap();
assert!(
key.contains(&format!("|p{}", crate::pricing::pricing_epoch())),
"key {key} must embed the pricing epoch"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn cache_key_absent_for_missing_file() {
assert!(cache_key(Path::new("/nonexistent/nope.jsonl")).is_none());
}
#[test]
fn prefs_roundtrip_defaults() {
let p = UiPrefs::default();
let text = serde_json::to_string(&p).unwrap();
let back: UiPrefs = serde_json::from_str(&text).unwrap();
assert_eq!(back.subagent_sort_col, "last");
assert!(!back.live_only);
}
#[test]
fn prefs_tolerate_missing_and_unknown_fields() {
let back: UiPrefs =
serde_json::from_str(r#"{"bottom_tab":3,"sort_col":"cpu","future_field":1}"#).unwrap();
assert_eq!(back.bottom_tab, 3);
assert_eq!(back.subagent_sort_col, "last"); }
#[test]
fn prefs_gain_hidden_columns_and_theme_without_breaking_old_files() {
let old: UiPrefs = serde_json::from_str(r#"{"bottom_tab":1}"#).unwrap();
assert!(old.hidden_columns.is_empty());
assert_eq!(old.theme, None);
let prefs = UiPrefs {
hidden_columns: vec!["cpu".into(), "mem".into()],
theme: Some("mono".into()),
..Default::default()
};
let back: UiPrefs = serde_json::from_str(&serde_json::to_string(&prefs).unwrap()).unwrap();
assert_eq!(back.hidden_columns, ["cpu", "mem"]);
assert_eq!(back.theme.as_deref(), Some("mono"));
}
}