use std::path::{Path, PathBuf};
const DEFAULT_SETTLE_MS: i64 = 2_000;
const DEFER_CEILING: u32 = 5;
static SETTLE_MS: std::sync::LazyLock<i64> = std::sync::LazyLock::new(|| {
std::env::var("CCTOP_SETTLE_MS")
.ok()
.and_then(|v| v.trim().parse().ok())
.unwrap_or(DEFAULT_SETTLE_MS)
});
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Corpus {
pub hash: u64,
pub newest_mtime_ms: u64,
pub files: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Reuse,
Defer,
Recompute,
}
#[derive(Debug, Default)]
pub struct Snapshot {
accepted: Option<u64>,
accepted_at_ms: i64,
}
impl Snapshot {
pub fn decide(&mut self, corpus: &Corpus, now_ms: i64) -> Verdict {
let Some(accepted) = self.accepted else {
return Verdict::Recompute;
};
if accepted == corpus.hash {
return Verdict::Reuse;
}
let settle = *SETTLE_MS;
let since_write = now_ms - corpus.newest_mtime_ms as i64;
let held_for = now_ms - self.accepted_at_ms;
if since_write < settle && held_for < settle * i64::from(DEFER_CEILING) {
Verdict::Defer
} else {
Verdict::Recompute
}
}
pub fn recorded(&mut self, corpus: &Corpus, now_ms: i64) {
self.accepted = Some(corpus.hash);
self.accepted_at_ms = now_ms;
}
}
pub fn compute_corpus_fingerprint() -> Corpus {
let _span = crate::trace::span("fingerprint");
let mut files: Vec<(String, u64, u64, u64, u64)> = Vec::new();
for root in roots() {
collect(&root, &mut files);
}
files.sort_unstable();
let mut hash = FNV_OFFSET;
let mut newest = 0u64;
for (path, dev, ino, mtime, size) in &files {
write(&mut hash, path.as_bytes());
for n in [dev, ino, mtime, size] {
write(&mut hash, &n.to_le_bytes());
}
newest = newest.max(*mtime);
}
crate::trace::fact("corpus files", files.len().to_string());
Corpus {
hash,
newest_mtime_ms: newest,
files: files.len(),
}
}
fn roots() -> Vec<PathBuf> {
let mut roots = crate::config::claude_projects_roots();
roots.extend(crate::config::codex_sessions_roots());
roots.extend(crate::config::cursor_projects_roots());
roots.extend(crate::config::pi_sessions_roots());
roots.extend(crate::config::opencode_data_roots());
roots.extend(crate::config::gemini_chats_roots());
roots.extend(crate::config::windsurf_workspace_roots());
roots.extend(crate::config::claude_mac_roots(
&crate::config::CLAUDE_MAC_COWORK_ROOT,
"local-agent-mode-sessions",
));
roots.extend(crate::config::claude_mac_roots(
&crate::config::CLAUDE_MAC_CODE_ROOT,
"claude-code-sessions",
));
roots.retain(|r| r.is_dir());
roots
}
const SKIP_DIRS: &[&str] = &["tool-outputs"];
fn collect(dir: &Path, out: &mut Vec<(String, u64, u64, u64, u64)>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let Ok(kind) = entry.file_type() else {
continue;
};
let path = entry.path();
if kind.is_dir() {
let skipped = path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| SKIP_DIRS.contains(&n));
if !skipped {
collect(&path, out);
}
continue;
}
let Ok(meta) = entry.metadata() else {
continue;
};
let (dev, ino) = identity(&meta);
out.push((
path.to_string_lossy().replace('\\', "/"),
dev,
ino,
crate::config::file_mtime_ms(&path),
meta.len(),
));
}
}
fn identity(meta: &std::fs::Metadata) -> (u64, u64) {
use std::os::unix::fs::MetadataExt;
(meta.dev(), meta.ino())
}
const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
fn write(hash: &mut u64, bytes: &[u8]) {
for b in bytes {
*hash ^= u64::from(*b);
*hash = hash.wrapping_mul(FNV_PRIME);
}
*hash ^= bytes.len() as u64;
*hash = hash.wrapping_mul(FNV_PRIME);
}
#[cfg(test)]
mod tests {
use super::*;
fn fingerprint_of(dir: &Path) -> Corpus {
let mut files = Vec::new();
collect(dir, &mut files);
files.sort_unstable();
let mut hash = FNV_OFFSET;
let mut newest = 0;
for (path, dev, ino, mtime, size) in &files {
write(&mut hash, path.as_bytes());
for n in [dev, ino, mtime, size] {
write(&mut hash, &n.to_le_bytes());
}
newest = newest.max(*mtime);
}
Corpus {
hash,
newest_mtime_ms: newest,
files: files.len(),
}
}
#[test]
fn an_untouched_corpus_hashes_the_same() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("project")).unwrap();
std::fs::write(dir.path().join("project").join("a.jsonl"), b"{}\n").unwrap();
std::fs::write(dir.path().join("b.jsonl"), b"{}\n").unwrap();
let first = fingerprint_of(dir.path());
assert_eq!(first.files, 2);
assert_eq!(first, fingerprint_of(dir.path()));
}
#[test]
fn a_changed_file_changes_the_hash() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("a.jsonl");
std::fs::write(&file, b"{}\n").unwrap();
let before = fingerprint_of(dir.path());
std::fs::write(&file, b"{}\n{}\n").unwrap();
assert_ne!(
before.hash,
fingerprint_of(dir.path()).hash,
"an appended transcript must invalidate the fingerprint"
);
let grown = fingerprint_of(dir.path());
std::fs::write(dir.path().join("b.jsonl"), b"{}\n").unwrap();
assert_ne!(grown.hash, fingerprint_of(dir.path()).hash);
let two = fingerprint_of(dir.path());
std::fs::remove_file(&file).unwrap();
assert_ne!(two.hash, fingerprint_of(dir.path()).hash);
}
#[test]
fn the_skipped_directories_are_not_walked() {
let dir = tempfile::tempdir().unwrap();
let noise = dir.path().join("tool-outputs");
std::fs::create_dir(&noise).unwrap();
std::fs::write(noise.join("call-1.json"), b"{}\n").unwrap();
assert_eq!(fingerprint_of(dir.path()).files, 0);
}
fn corpus(hash: u64, newest_mtime_ms: i64) -> Corpus {
Corpus {
hash,
newest_mtime_ms: newest_mtime_ms.max(0) as u64,
files: 1,
}
}
#[test]
fn a_quiet_corpus_reuses_and_a_settled_change_recomputes() {
let mut snap = Snapshot::default();
let now = 1_000_000;
assert_eq!(
snap.decide(&corpus(1, now - 10_000), now),
Verdict::Recompute
);
snap.recorded(&corpus(1, now - 10_000), now);
assert_eq!(snap.decide(&corpus(1, now - 10_000), now), Verdict::Reuse);
assert_eq!(
snap.decide(&corpus(2, now - 10_000), now),
Verdict::Recompute,
"a change nobody has touched for ten seconds is finished changing"
);
}
#[test]
fn a_streaming_write_defers_without_being_recorded() {
let mut snap = Snapshot::default();
let now = 1_000_000;
snap.recorded(&corpus(1, now - 10_000), now);
assert_eq!(
snap.decide(&corpus(2, now - 50), now),
Verdict::Defer,
"a write from 50ms ago is inside the default window"
);
assert_eq!(
snap.decide(&corpus(2, now - 5_000), now + 5_000),
Verdict::Recompute,
"once the window passes, the same change must recompute"
);
}
#[test]
fn a_never_quiet_corpus_stops_deferring() {
let mut snap = Snapshot::default();
let now = 1_000_000;
snap.recorded(&corpus(1, now), now);
assert_eq!(
snap.decide(&corpus(2, now + 999), now + 1_000),
Verdict::Defer
);
let past_ceiling = now + i64::from(DEFER_CEILING) * DEFAULT_SETTLE_MS;
assert_eq!(
snap.decide(&corpus(2, past_ceiling), past_ceiling),
Verdict::Recompute,
"a deferral older than the ceiling must expire on its own"
);
}
#[test]
fn a_future_mtime_cannot_defer_past_the_ceiling() {
let mut snap = Snapshot::default();
let now = 1_000_000;
snap.recorded(&corpus(1, now), now);
let far_future = now + 86_400_000;
assert_eq!(snap.decide(&corpus(2, far_future), now), Verdict::Defer);
let past_ceiling = now + i64::from(DEFER_CEILING) * DEFAULT_SETTLE_MS;
assert_eq!(
snap.decide(&corpus(2, far_future), past_ceiling),
Verdict::Recompute
);
}
}