use crate::config;
use crate::session::{Session, SessionData};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use std::sync::Mutex;
const CACHE_VERSION: u32 = 8;
#[derive(Serialize, Deserialize)]
struct DiskCache {
version: u32,
entries: HashMap<String, SessionData>,
}
impl Default for DiskCache {
fn default() -> Self {
DiskCache {
version: CACHE_VERSION,
entries: HashMap::new(),
}
}
}
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, SessionData>>,
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 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();
CostCache {
entries: Mutex::new(entries),
dirty: Mutex::new(false),
}
}
pub fn get(&self, key: &str) -> Option<SessionData> {
self.entries.lock().ok()?.get(key).cloned()
}
pub fn put(&self, key: String, data: &SessionData) {
if let Ok(mut e) = self.entries.lock() {
e.insert(key, 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;
};
entries.retain(|key, _| {
let Some(path) = key.split('|').next() else {
return false;
};
cache_key(Path::new(path)).as_deref() == Some(key)
});
let _ = std::fs::create_dir_all(&*config::CACHE_DIR);
if let Ok(text) = serde_json::to_string(&DiskCache {
version: CACHE_VERSION,
entries: entries.clone(),
}) {
let _ = std::fs::write(&*config::COST_CACHE_FILE, text);
}
}
}
struct MemEntry {
mtime: u64,
pricing_epoch: u64,
data: SessionData,
}
#[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 {
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.mtime == mtime
&& entry.pricing_epoch == epoch
{
return entry.data.clone();
}
let disk_key = (session.provider != crate::pricing::Provider::OpenCode)
.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(),
},
);
}
return data;
}
let 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::Pi => crate::session::pi::extract(file),
};
if let Ok(mut mem) = self.mem.lock() {
mem.insert(
mem_key,
MemEntry {
mtime,
pricing_epoch: epoch,
data: data.clone(),
},
);
}
if let Some(key) = disk_key
&& data.error.is_none()
{
self.disk.put(key, &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 sort_col: String,
pub sort_asc: bool,
pub inactivity_filter: Option<String>,
pub agent_live_filter: bool,
pub tool_show_diff: bool,
pub subagent_sort_col: String,
pub subagent_sort_asc: bool,
pub cost_floor: f64,
}
impl Default for UiPrefs {
fn default() -> Self {
UiPrefs {
bottom_tab: 0,
live_only: false,
sort_col: "active".into(),
sort_asc: true,
inactivity_filter: None,
agent_live_filter: false,
tool_show_diff: false,
subagent_sort_col: "last".into(),
subagent_sort_asc: false,
cost_floor: 0.0,
}
}
}
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)]
mod tests {
use super::*;
#[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.sort_col, "active");
assert!(back.sort_asc);
}
#[test]
fn prefs_tolerate_missing_and_unknown_fields() {
let back: UiPrefs = serde_json::from_str(r#"{"bottom_tab":3,"future_field":1}"#).unwrap();
assert_eq!(back.bottom_tab, 3);
assert_eq!(back.sort_col, "active"); }
}