use crate::config;
use crate::session::{Session, SessionData};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
const CACHE_VERSION: &str = env!("CCTOP_CACHE_HASH");
#[derive(Serialize, Deserialize)]
struct Entry {
path: PathBuf,
#[serde(default)]
session: String,
#[serde(default)]
stamp: String,
#[serde(default)]
stored_at: u64,
data: SessionData,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct DiskId {
origin: String,
stamp: String,
}
#[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 = 20_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)
}
fn disk_id(session: &Session, file: &Path) -> Option<DiskId> {
match session.provider {
crate::pricing::Provider::Windsurf => None,
crate::pricing::Provider::OpenCode => Some(DiskId {
origin: format!("{}\u{0}{}", file.display(), session.session_id),
stamp: format!(
"{}|p{}",
session.last_active,
crate::pricing::pricing_epoch()
),
}),
_ => cache_key(file),
}
}
fn cache_key(path: &Path) -> Option<DiskId> {
let meta = std::fs::metadata(path).ok()?;
let mut stamp = format!(
"|{}|{}|p{}",
meta.len(),
config::file_mtime_ms(path),
crate::pricing::pricing_epoch()
);
let sub_dir = path.with_extension("").join("subagents");
if sub_dir.is_dir() {
let newest = crate::session::transcript_files(path)[1..]
.iter()
.map(|f| config::file_mtime_ms(f))
.chain(std::iter::once(config::file_mtime_ms(&sub_dir)))
.chain(
crate::session::workflow_run_dirs(&sub_dir)
.iter()
.map(|run| config::file_mtime_ms(run)),
)
.max()
.unwrap_or(0);
stamp.push_str(&format!("|{newest}"));
}
Some(DiskId {
origin: path.display().to_string(),
stamp,
})
}
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 _span = crate::trace::span("cache.load");
let text = std::fs::read_to_string(&*config::COST_CACHE_FILE).ok();
crate::trace::fact(
"cache file",
match &text {
Some(t) => format!(
"{} ({} bytes)",
crate::trace::redact(&config::COST_CACHE_FILE),
t.len()
),
None => "absent".into(),
},
);
let parsed = match text.as_deref().map(serde_json::from_str::<DiskCache>) {
None => None,
Some(Ok(c)) => {
if c.version != CACHE_VERSION {
crate::trace::fact(
"cache state",
format!("stale — file {} vs binary {CACHE_VERSION}", c.version),
);
}
Some(c)
}
Some(Err(error)) => {
crate::trace::fact("cache state", format!("unreadable — {error}"));
None
}
};
let mut entries = parsed
.filter(|c| c.version == CACHE_VERSION)
.map(|c| c.entries)
.unwrap_or_default();
let stored = entries.len();
entries.retain(|_, e| e.path.exists());
crate::trace::fact(
"cache entries",
format!("{} usable, {} gone", entries.len(), stored - entries.len()),
);
CostCache {
entries: Mutex::new(entries),
dirty: Mutex::new(false),
}
}
pub fn get(&self, id: &DiskId) -> Option<Arc<SessionData>> {
self.entries
.lock()
.ok()?
.get(&id.origin)
.filter(|e| e.stamp == id.stamp)
.map(|e| Arc::new(e.data.clone()))
}
pub fn put(&self, id: DiskId, path: &Path, session: &str, data: &SessionData) {
if let Ok(mut entries) = self.entries.lock() {
entries.insert(
id.origin,
Entry {
path: path.to_path_buf(),
session: session.to_string(),
stamp: id.stamp,
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 _span = crate::trace::span("cache.save");
let Ok(mut entries) = self.entries.lock() else {
return;
};
evict_oldest(&mut entries);
let _ = std::fs::create_dir_all(&*config::CACHE_DIR);
if write_atomically(&config::COST_CACHE_FILE, &entries).is_ok()
&& let Ok(mut d) = self.dirty.lock()
{
*d = false;
}
}
}
fn write_atomically(path: &Path, entries: &HashMap<String, Entry>) -> std::io::Result<()> {
use std::io::Write;
let tmp = path.with_extension("json.tmp");
let written = (|| -> std::io::Result<()> {
let mut out = std::io::BufWriter::new(std::fs::File::create(&tmp)?);
serde_json::to_writer(
&mut out,
&DiskCacheRef {
version: CACHE_VERSION,
entries,
},
)?;
out.flush()
})();
if written.is_ok()
&& let Err(error) = std::fs::rename(&tmp, path)
{
let _ = std::fs::remove_file(&tmp);
return Err(error);
}
if written.is_err() {
let _ = std::fs::remove_file(&tmp);
}
written
}
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: Arc<SessionData>,
parsed_in: std::time::Duration,
parsed_at: std::time::Instant,
size: u64,
}
fn file_size(path: &Path) -> u64 {
crate::session::transcript_files(path)
.iter()
.map(|f| std::fs::metadata(f).map(|m| m.len()).unwrap_or(0))
.sum()
}
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) -> Arc<SessionData> {
self.data(session, true)
}
pub fn session_data_fresh(&self, session: &Session) -> Arc<SessionData> {
self.data(session, false)
}
fn data(&self, session: &Session, allow_stale: bool) -> Arc<SessionData> {
let Some(file) = session.data_file.as_ref() else {
return Arc::new(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 && (allow_stale || entry.data.complete) {
crate::trace::add("served from memory", 1);
return entry.data.clone();
}
if (allow_stale || entry.data.complete)
&& reuse_stale(entry.parsed_in, entry.parsed_at.elapsed(), entry.size)
{
crate::trace::add("served stale to bound cpu", 1);
return entry.data.clone();
}
}
let disk_id = disk_id(session, file);
if allow_stale
&& let Some(id) = &disk_id
&& let Some(data) = self.disk.get(id)
{
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),
},
);
}
crate::trace::add("served from cache file", 1);
return data;
}
let _span = crate::trace::span("extract.parse");
let _by_provider = crate::trace::span(session.provider.extract_trace_name());
crate::trace::add("transcripts parsed", 1);
if !matches!(
session.provider,
crate::pricing::Provider::OpenCode | crate::pricing::Provider::Windsurf
) {
crate::trace::add("parsed_bytes", file_size(file));
}
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::Devin => crate::session::devin::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();
let data = Arc::new(data);
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(id) = disk_id
&& data.error.is_none()
{
self.disk.put(id, file, &session.session_id, &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(id) = disk_id(session, file)
{
if let Ok(mut e) = self.disk.entries.lock() {
e.remove(&id.origin);
}
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 auto_update: bool,
pub declined_update: Option<String>,
pub shell_alias_installed: bool,
pub hidden_columns: Vec<String>,
pub theme: Option<String>,
pub claude_profile: Option<String>,
pub codex_profile: Option<String>,
pub search_history: Vec<String>,
pub tree: bool,
pub collapsed_groups: 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,
auto_update: true,
declined_update: None,
shell_alias_installed: false,
hidden_columns: Vec::new(),
theme: None,
claude_profile: None,
codex_profile: None,
search_history: Vec::new(),
tree: false,
collapsed_groups: 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) {
if cfg!(test) {
return;
}
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::*;
use crate::pricing::Provider;
#[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 Provider::ALL {
let expected = format!("src/session/{}.rs", provider.as_str());
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));
}
#[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 out = dir.join("cost-cache.json");
let cache = CostCache {
entries: Mutex::new(HashMap::new()),
dirty: Mutex::new(false),
};
let id = cache_key(&file).unwrap();
assert!(id.origin.contains("a|b"), "the key embeds the awkward path");
cache.put(
DiskId {
origin: id.origin.clone(),
stamp: id.stamp.clone(),
},
&file,
"sess",
&SessionData::default(),
);
write_atomically(&out, &cache.entries.lock().unwrap()).unwrap();
let back: DiskCache =
serde_json::from_str(&std::fs::read_to_string(&out).unwrap()).unwrap();
let entries = back.entries;
assert_eq!(entries.len(), 1);
assert_eq!(entries[&id.origin].path, file);
assert_eq!(entries[&id.origin].stamp, id.stamp);
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),
};
let put = |stamp: &str| {
cache.put(
DiskId {
origin: "o".into(),
stamp: stamp.into(),
},
path,
"sess",
&SessionData::default(),
)
};
put("k1");
put("k2");
let entries = cache.entries.lock().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries["o"].stamp, "k2");
}
#[test]
fn an_entry_is_served_only_while_its_stamp_still_matches() {
let dir = std::env::temp_dir().join(format!("cctop-stamp-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("a.jsonl");
std::fs::write(&file, "one").unwrap();
let cache = CostCache {
entries: Mutex::new(HashMap::new()),
dirty: Mutex::new(false),
};
let before = cache_key(&file).unwrap();
cache.put(
DiskId {
origin: before.origin.clone(),
stamp: before.stamp.clone(),
},
&file,
"sess",
&SessionData::default(),
);
assert!(cache.get(&before).is_some(), "an unchanged transcript hits");
std::fs::write(&file, "one, and rather more of it").unwrap();
let after = cache_key(&file).unwrap();
assert_eq!(after.origin, before.origin, "the slot does not move");
assert!(
cache.get(&after).is_none(),
"an appended transcript must not be served the old extraction"
);
std::fs::remove_dir_all(&dir).ok();
}
#[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")),
session: format!("s{i}"),
stamp: String::new(),
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_eq!(k1.origin, k2.origin, "the slot is the file");
assert_ne!(k1.stamp, k2.stamp, "size change must invalidate the key");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn cache_key_moves_when_a_workflow_agent_writes() {
let dir = std::env::temp_dir().join(format!("cctop-wf-{}", std::process::id()));
let f = dir.join("s.jsonl");
let run = dir
.join("s")
.join("subagents")
.join("workflows")
.join("wf_1");
std::fs::create_dir_all(&run).unwrap();
std::fs::write(&f, "x").unwrap();
let agent = run.join("agent-a1.jsonl");
std::fs::write(&agent, "one").unwrap();
let at = |secs| std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs);
let touch = |secs| {
std::fs::File::options()
.write(true)
.open(&agent)
.unwrap()
.set_modified(at(secs))
.unwrap()
};
touch(4_000_000_000);
let k1 = cache_key(&f).unwrap();
touch(4_000_000_100);
let k2 = cache_key(&f).unwrap();
assert_ne!(
k1.stamp, k2.stamp,
"a workflow agent's write left the key as it was"
);
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 id = cache_key(&f).unwrap();
assert!(
id.stamp
.contains(&format!("|p{}", crate::pricing::pricing_epoch())),
"stamp {} must embed the pricing epoch",
id.stamp
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_cache_drops_the_tool_history_but_keeps_what_a_row_needs() {
let mut data = SessionData::default();
data.metrics.tool_details.insert(
"Edit".into(),
vec![crate::session::ToolDetail {
d: "src/main.rs".into(),
ts: "2026-01-01T00:00".into(),
..Default::default()
}],
);
data.context_series.push(crate::session::CtxPoint {
ts: "2026-01-01T00:00".into(),
window: 1234,
after_compaction: false,
});
data.costs.total = 4.25;
data.finalize();
assert_eq!(data.recent_writes, ["src/main.rs"]);
assert!(data.complete, "a fresh extraction is complete");
let back: SessionData =
serde_json::from_str(&serde_json::to_string(&data).unwrap()).unwrap();
assert!(
back.metrics.tool_details.is_empty(),
"history was persisted"
);
assert!(back.context_series.is_empty(), "series was persisted");
assert_eq!(back.recent_writes, ["src/main.rs"]);
assert_eq!(back.costs.total, 4.25);
assert!(
!back.complete,
"a restored entry must not claim completeness"
);
}
#[test]
fn a_stale_parse_serves_the_panel_within_the_backoff() {
let dir = std::env::temp_dir().join(format!("cctop-stale-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let transcript = dir.join("a.jsonl");
let assistant = |request: &str, out: u64| {
format!(
r#"{{"type":"assistant","timestamp":"2026-08-05T10:00:00.000Z","requestId":"{request}","message":{{"id":"m_{request}","role":"assistant","model":"claude-opus-5","content":[{{"type":"text","text":"a"}}],"usage":{{"input_tokens":100,"output_tokens":{out}}}}}}}"#
)
};
let body = format!("{}\n{}", assistant("r1", 5), " ".repeat(1 << 20));
std::fs::write(&transcript, &body).unwrap();
let store = Store::new();
let mut s = Session::new(crate::pricing::Provider::Claude, "s1".into());
s.data_file = Some(transcript.clone());
let first = store.session_data_fresh(&s);
assert_eq!(first.tokens.output, 5);
assert!(first.complete);
std::thread::sleep(std::time::Duration::from_millis(20));
std::fs::write(&transcript, format!("{body}\n{}", assistant("r2", 50))).unwrap();
let second = store.session_data_fresh(&s);
assert_eq!(
second.tokens.output, 5,
"inside the backoff the panel is served the previous parse"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_restored_entry_satisfies_a_row_but_not_a_panel() {
let fresh = {
let mut d = SessionData::default();
d.finalize();
d
};
let restored: SessionData =
serde_json::from_str(&serde_json::to_string(&fresh).unwrap()).unwrap();
let serves = |data: &SessionData, allow_stale: bool| allow_stale || data.complete;
assert!(serves(&restored, true), "a row may be served from cache");
assert!(!serves(&restored, false), "a panel may not");
assert!(serves(&fresh, false), "a real parse serves anything");
}
#[test]
fn a_saved_cache_reads_back_whole() {
let dir = std::env::temp_dir().join(format!("cctop-save-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let transcript = dir.join("a.jsonl");
std::fs::write(&transcript, "x").unwrap();
let file = dir.join("cost-cache.json");
let mut entries = HashMap::new();
for i in 0..500 {
let mut data = SessionData::default();
data.costs.total = i as f64;
data.title = Some("x".repeat(500));
entries.insert(
format!("k{i}"),
Entry {
path: transcript.clone(),
session: format!("s{i}"),
stamp: format!("|{i}|0|p0"),
stored_at: i,
data,
},
);
}
write_atomically(&file, &entries).expect("the write must report its own failure");
let back: DiskCache = serde_json::from_str(&std::fs::read_to_string(&file).unwrap())
.expect("a saved cache must parse");
assert_eq!(back.version, CACHE_VERSION);
assert_eq!(back.entries.len(), 500);
assert_eq!(back.entries["k499"].data.costs.total, 499.0);
assert!(!file.with_extension("json.tmp").exists());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_saved_cost_comes_back_as_the_number_it_was() {
let costs = [
0.001_625_000_000_000_000_1_f64,
0.042_535_301_669_349_68_f64,
1.0000000000000002,
0.1 + 0.2,
1e-7,
1.7976931348623157e308,
2.2250738585072014e-308,
1234.5678901234567,
];
let dir = std::env::temp_dir().join(format!("cctop-float-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let transcript = dir.join("a.jsonl");
std::fs::write(&transcript, "x").unwrap();
let mut entries = HashMap::new();
for (i, cost) in costs.iter().enumerate() {
let mut data = SessionData::default();
data.costs.total = *cost;
entries.insert(
format!("k{i}"),
Entry {
path: transcript.clone(),
session: format!("s{i}"),
stamp: format!("s{i}"),
stored_at: i as u64,
data,
},
);
}
let file = dir.join("cost-cache.json");
write_atomically(&file, &entries).expect("the write must succeed");
let back: DiskCache = serde_json::from_str(&std::fs::read_to_string(&file).unwrap())
.expect("a saved cache must parse");
for (i, cost) in costs.iter().enumerate() {
let got = back.entries[&format!("k{i}")].data.costs.total;
assert_eq!(
got.to_bits(),
cost.to_bits(),
"cost {i} ({cost:e}) came back as {got:e}"
);
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn an_unwritable_destination_is_an_error() {
let path = Path::new("/nonexistent-directory-cctop/cost-cache.json");
assert!(write_atomically(path, &HashMap::new()).is_err());
}
#[test]
fn shared_database_sessions_get_a_key_each() {
let db = Path::new("/tmp/opencode.db");
let key_of = |id: &str, updated: &str| {
let mut s = Session::new(crate::pricing::Provider::OpenCode, id.into());
s.last_active = updated.into();
disk_id(&s, db).expect("a shared-database session must still be keyable")
};
let a = key_of("a", "2026-01-01");
assert_ne!(
a.origin,
key_of("b", "2026-01-01").origin,
"two sessions in one file shared a slot"
);
assert_ne!(a.stamp, key_of("a", "2026-01-02").stamp);
assert_eq!(a, key_of("a", "2026-01-01"));
}
#[test]
fn windsurf_stays_uncached_for_want_of_a_stamp() {
let s = Session::new(crate::pricing::Provider::Windsurf, "x".into());
assert!(disk_id(&s, Path::new("/tmp/state.vscdb")).is_none());
}
#[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 a_saved_entry_comes_back_serving_what_went_in() {
let dir = std::env::temp_dir().join(format!("cctop-rt-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("a.jsonl");
std::fs::write(&file, "one").unwrap();
let mut s = Session::new(crate::pricing::Provider::Claude, "sess1".into());
s.data_file = Some(file.clone());
let id = disk_id(&s, &file).expect("a claude transcript is always keyable");
let mut data = SessionData::default();
data.costs.total = 1.25;
data.tokens.output = 7;
data.title = Some("a session worth finding".into());
let cache = CostCache {
entries: Mutex::new(HashMap::new()),
dirty: Mutex::new(false),
};
cache.put(
DiskId {
origin: id.origin.clone(),
stamp: id.stamp.clone(),
},
&file,
"sess1",
&data,
);
let out = dir.join("cost-cache.json");
write_atomically(&out, &cache.entries.lock().unwrap()).expect("the save must succeed");
let reopened: DiskCache =
serde_json::from_str(&std::fs::read_to_string(&out).unwrap()).expect("must parse");
assert_eq!(reopened.entries.len(), 1);
let back = CostCache {
entries: Mutex::new(reopened.entries),
dirty: Mutex::new(false),
};
let hit = back.get(&id).expect("a matching stamp must be served");
assert_eq!(hit.costs.total, 1.25);
assert_eq!(hit.tokens.output, 7);
assert_eq!(hit.title.as_deref(), Some("a session worth finding"));
let stored = back.entries.lock().unwrap();
assert!(stored.contains_key(&id.origin), "keyed by origin");
assert_eq!(
stored[&id.origin].path, file,
"the path survived the round trip"
);
assert_eq!(
stored[&id.origin].stamp, id.stamp,
"the stamp is what gates a hit"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_shared_file_gives_each_session_its_own_slot() {
let mut first = Session::new(crate::pricing::Provider::OpenCode, "one".into());
first.data_file = Some(PathBuf::from("/tmp/shared.db"));
first.last_active = "2026-01-01T00:00:00Z".into();
let mut second = Session::new(crate::pricing::Provider::OpenCode, "two".into());
second.data_file = Some(PathBuf::from("/tmp/shared.db"));
second.last_active = "2026-01-01T00:00:00Z".into();
let id = |s: &Session| disk_id(s, Path::new("/tmp/shared.db")).unwrap();
let (a, b) = (id(&first), id(&second));
assert_ne!(a.origin, b.origin, "one file, two slots");
let cache = CostCache {
entries: Mutex::new(HashMap::new()),
dirty: Mutex::new(false),
};
let mut data = SessionData::default();
data.costs.total = 3.0;
cache.put(id(&first), Path::new("/tmp/shared.db"), "one", &data);
cache.put(id(&second), Path::new("/tmp/shared.db"), "two", &data);
assert_eq!(
cache.entries.lock().unwrap().len(),
2,
"neither evicted the other"
);
assert!(cache.get(&a).is_some());
assert!(cache.get(&b).is_some());
}
#[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"));
}
}