1use std::path::{Path, PathBuf};
2use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
3use std::time::Instant;
4
5use rayon::prelude::*;
6use rustc_hash::{FxHashMap, FxHashSet};
7use serde::{Deserialize, Serialize};
8
9use crate::config::bm25::BM25;
10use crate::config::tokenization::TOKENIZATION;
11use crate::discovery::DiscoveryContext;
12use crate::git;
13
14pub const TOKENIZER_EPOCH: u32 = 1;
18
19const DEFAULT_CACHE_MAX_BYTES: u64 = 512 * 1024 * 1024;
23const CACHE_SHARDS: u64 = 256;
24const SHARD_EVICTION_TARGET_FRACTION: f64 = 0.8;
25
26pub struct DocTokens {
27 pub term_counts: FxHashMap<String, u32>,
28 pub total_len: u32,
29}
30
31pub struct TokenCorpus {
32 pub docs: Vec<(PathBuf, DocTokens)>,
33}
34
35fn shared_min_token_length() -> usize {
42 debug_assert_eq!(
43 TOKENIZATION.query_min_identifier_length,
44 BM25.min_query_token_length
45 );
46 TOKENIZATION
47 .query_min_identifier_length
48 .min(BM25.min_query_token_length)
49}
50
51impl TokenCorpus {
52 pub fn build(ctx: &DiscoveryContext) -> Self {
53 let t0 = Instant::now();
54 let min_len = shared_min_token_length();
55 let changed_set: FxHashSet<&Path> = ctx.changed_files.iter().map(|p| p.as_path()).collect();
56 let store = TokenCacheStore::open(min_len);
57 let oids = if store.is_some() {
58 resolve_cacheable_oids(&ctx.root_dir)
59 } else {
60 FxHashMap::default()
61 };
62
63 let hits = AtomicUsize::new(0);
64 let tokenized = AtomicUsize::new(0);
65 let docs: Vec<(PathBuf, DocTokens)> = ctx
66 .all_candidates
67 .par_iter()
68 .filter(|f| !changed_set.contains(f.as_path()))
69 .filter_map(|f| {
70 let oid = oids.get(f.as_path());
71 if let (Some(store), Some(oid)) = (store.as_ref(), oid) {
72 if let Some(doc) = store.load(oid) {
73 hits.fetch_add(1, Ordering::Relaxed);
74 return Some((f.clone(), doc));
75 }
76 }
77 let content = ctx.read_file(f)?;
78 let (term_counts, total_len) =
79 crate::types::extract_identifier_counts(&content, min_len);
80 let doc = DocTokens {
81 term_counts,
82 total_len,
83 };
84 if let (Some(store), Some(oid)) = (store.as_ref(), oid) {
85 store.save(oid, &doc);
86 }
87 tokenized.fetch_add(1, Ordering::Relaxed);
88 Some((f.clone(), doc))
89 })
90 .collect();
91
92 if let Some(store) = store.as_ref() {
93 store.evict_one_shard();
94 }
95
96 tracing::debug!(
97 "token corpus: {} docs ({} cache hits, {} tokenized) in {:.3}s",
98 docs.len(),
99 hits.load(Ordering::Relaxed),
100 tokenized.load(Ordering::Relaxed),
101 t0.elapsed().as_secs_f64(),
102 );
103 Self { docs }
104 }
105}
106
107fn resolve_cacheable_oids(root_dir: &Path) -> FxHashMap<PathBuf, String> {
113 let Ok(entries) = git::run_git_z(root_dir, &["ls-files", "-s", "-z"]) else {
114 return FxHashMap::default();
115 };
116 let Ok(dirty_parts) = git::run_git_z(root_dir, &["diff-files", "--name-only", "-z"]) else {
117 return FxHashMap::default();
118 };
119 let dirty: FxHashSet<PathBuf> = dirty_parts.into_iter().map(|p| root_dir.join(p)).collect();
120
121 let mut oids: FxHashMap<PathBuf, String> = FxHashMap::default();
122 for entry in entries {
123 let Some((meta, rel)) = entry.split_once('\t') else {
124 continue;
125 };
126 let mut fields = meta.split_ascii_whitespace();
127 let (Some(mode), Some(oid), Some(stage)) = (fields.next(), fields.next(), fields.next())
128 else {
129 continue;
130 };
131 if stage != "0" || !(mode == "100644" || mode == "100755") {
132 continue;
133 }
134 let path = root_dir.join(rel);
135 if dirty.contains(&path) {
136 continue;
137 }
138 oids.insert(path, oid.to_string());
139 }
140 oids
141}
142
143#[derive(Serialize, Deserialize)]
144struct StoredDoc {
145 len: u32,
146 terms: Vec<(String, u32)>,
147}
148
149struct TokenCacheStore {
150 dir: PathBuf,
151}
152
153static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
154
155impl TokenCacheStore {
156 fn open(min_token_length: usize) -> Option<Self> {
157 let root = std::env::var_os("DIFFCTX_TOKEN_CACHE_DIR")
158 .filter(|v| !v.is_empty())
159 .map(PathBuf::from)
160 .or_else(default_cache_root)?;
161 let dir = root.join(format!("v{TOKENIZER_EPOCH}-l{min_token_length}"));
162 std::fs::create_dir_all(&dir).ok()?;
163 Some(Self { dir })
164 }
165
166 fn entry_path(&self, oid: &str) -> Option<PathBuf> {
167 if oid.len() < 3 || !oid.bytes().all(|b| b.is_ascii_hexdigit()) {
168 return None;
169 }
170 Some(self.dir.join(&oid[..2]).join(&oid[2..]))
171 }
172
173 fn load(&self, oid: &str) -> Option<DocTokens> {
174 let bytes = std::fs::read(self.entry_path(oid)?).ok()?;
175 let stored: StoredDoc = serde_json::from_slice(&bytes).ok()?;
176 Some(DocTokens {
177 term_counts: stored.terms.into_iter().collect(),
178 total_len: stored.len,
179 })
180 }
181
182 fn save(&self, oid: &str, doc: &DocTokens) {
183 let Some(path) = self.entry_path(oid) else {
184 return;
185 };
186 let mut terms: Vec<(String, u32)> = doc
187 .term_counts
188 .iter()
189 .map(|(t, c)| (t.clone(), *c))
190 .collect();
191 terms.sort();
192 let stored = StoredDoc {
193 len: doc.total_len,
194 terms,
195 };
196 let Ok(bytes) = serde_json::to_vec(&stored) else {
197 return;
198 };
199 let Some(parent) = path.parent() else {
200 return;
201 };
202 if std::fs::create_dir_all(parent).is_err() {
203 return;
204 }
205 let tmp = parent.join(format!(
206 ".{}.{}.{}.tmp",
207 &oid[2..],
208 std::process::id(),
209 TMP_COUNTER.fetch_add(1, Ordering::Relaxed),
210 ));
211 if std::fs::write(&tmp, &bytes).is_err() {
212 let _ = std::fs::remove_file(&tmp);
213 return;
214 }
215 if std::fs::rename(&tmp, &path).is_err() {
216 let _ = std::fs::remove_file(&tmp);
217 }
218 }
219
220 fn evict_one_shard(&self) {
226 let Some(max_bytes) = cache_max_bytes() else {
227 return;
228 };
229 const EVICT_WINDOW: u64 = 16;
235 let start = std::time::SystemTime::now()
236 .duration_since(std::time::UNIX_EPOCH)
237 .map(|d| d.subsec_nanos() as u64 % CACHE_SHARDS)
238 .unwrap_or(0);
239 for off in 0..EVICT_WINDOW {
240 let shard = (start + off) % CACHE_SHARDS;
241 evict_shard(
242 &self.dir.join(format!("{shard:02x}")),
243 max_bytes / CACHE_SHARDS,
244 );
245 }
246 }
247}
248
249fn cache_max_bytes() -> Option<u64> {
250 match std::env::var("DIFFCTX_TOKEN_CACHE_MAX_BYTES") {
251 Ok(raw) => cache_max_bytes_from(&raw),
252 Err(_) => Some(DEFAULT_CACHE_MAX_BYTES),
253 }
254}
255
256fn cache_max_bytes_from(raw: &str) -> Option<u64> {
257 let raw = raw.trim();
258 if raw.is_empty() {
259 return Some(DEFAULT_CACHE_MAX_BYTES);
260 }
261 raw.parse::<u64>()
262 .map_or(Some(DEFAULT_CACHE_MAX_BYTES), |b| (b > 0).then_some(b))
263}
264
265fn evict_shard(shard_dir: &Path, shard_max_bytes: u64) {
266 let Ok(entries) = std::fs::read_dir(shard_dir) else {
267 return;
268 };
269 let mut files: Vec<(std::time::SystemTime, u64, PathBuf)> = entries
270 .flatten()
271 .filter_map(|e| {
272 let meta = e.metadata().ok()?;
273 if !meta.is_file() {
274 return None;
275 }
276 Some((
277 meta.modified().unwrap_or(std::time::UNIX_EPOCH),
278 meta.len(),
279 e.path(),
280 ))
281 })
282 .collect();
283
284 let mut total: u64 = files.iter().map(|(_, len, _)| len).sum();
285 if total <= shard_max_bytes {
286 return;
287 }
288
289 files.sort_by_key(|(modified, _, _)| *modified);
292 let target = (shard_max_bytes as f64 * SHARD_EVICTION_TARGET_FRACTION) as u64;
293 let mut removed = 0usize;
294 for (_, len, path) in &files {
295 if total <= target {
296 break;
297 }
298 if std::fs::remove_file(path).is_ok() {
299 total -= len;
300 removed += 1;
301 }
302 }
303 tracing::debug!(
304 "token cache: evicted {} entries from {}",
305 removed,
306 shard_dir.display()
307 );
308}
309
310fn default_cache_root() -> Option<PathBuf> {
311 #[cfg(target_os = "macos")]
312 {
313 std::env::var_os("HOME")
314 .map(|h| PathBuf::from(h).join("Library/Caches/diffctx/token-cache"))
315 }
316 #[cfg(target_os = "windows")]
317 {
318 std::env::var_os("LOCALAPPDATA").map(|d| PathBuf::from(d).join("diffctx/token-cache"))
319 }
320 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
321 {
322 std::env::var_os("XDG_CACHE_HOME")
323 .filter(|v| !v.is_empty())
324 .map(PathBuf::from)
325 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
326 .map(|c| c.join("diffctx/token-cache"))
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use tempfile::TempDir;
334
335 fn write_entry(dir: &Path, name: &str, bytes: usize, age_secs: u64) {
339 let path = dir.join(name);
340 std::fs::write(&path, vec![b'x'; bytes]).expect("write entry");
341 let stamp = std::time::SystemTime::now() - std::time::Duration::from_secs(age_secs);
342 std::fs::File::options()
343 .write(true)
344 .open(&path)
345 .expect("open entry")
346 .set_modified(stamp)
347 .expect("set mtime");
348 }
349
350 #[test]
351 fn evict_shard_drops_oldest_entries_until_under_target() {
352 let tmp = TempDir::new().expect("tempdir");
353 let shard = tmp.path().join("ab");
354 std::fs::create_dir_all(&shard).expect("shard dir");
355 for (age_secs, name) in [(3600, "oldest"), (1800, "middle"), (60, "newest")] {
356 write_entry(&shard, name, 1000, age_secs);
357 }
358
359 evict_shard(&shard, 2000);
360
361 let survivors: Vec<String> = std::fs::read_dir(&shard)
362 .expect("read shard")
363 .flatten()
364 .map(|e| e.file_name().to_string_lossy().into_owned())
365 .collect();
366 assert_eq!(survivors, vec!["newest".to_string()]);
367 }
368
369 #[test]
370 fn evict_shard_keeps_everything_under_the_cap() {
371 let tmp = TempDir::new().expect("tempdir");
372 let shard = tmp.path().join("cd");
373 std::fs::create_dir_all(&shard).expect("shard dir");
374 write_entry(&shard, "kept", 1000, 86_400);
375
376 evict_shard(&shard, 4096);
377
378 assert!(shard.join("kept").exists());
379 }
380
381 #[test]
382 fn cache_max_bytes_honors_the_unlimited_and_override_settings() {
383 assert_eq!(cache_max_bytes_from("0"), None);
384 assert_eq!(cache_max_bytes_from("4096"), Some(4096));
385 assert_eq!(
386 cache_max_bytes_from("not-a-number"),
387 Some(DEFAULT_CACHE_MAX_BYTES)
388 );
389 assert_eq!(cache_max_bytes_from(""), Some(DEFAULT_CACHE_MAX_BYTES));
390 }
391
392 fn git(repo: &Path, args: &[&str]) {
393 let out = git::git_command(repo)
394 .args(args)
395 .env("GIT_AUTHOR_NAME", "test")
396 .env("GIT_AUTHOR_EMAIL", "test@example.com")
397 .env("GIT_COMMITTER_NAME", "test")
398 .env("GIT_COMMITTER_EMAIL", "test@example.com")
399 .output()
400 .expect("spawn git");
401 assert!(
402 out.status.success(),
403 "git {args:?} failed: {}",
404 String::from_utf8_lossy(&out.stderr)
405 );
406 }
407
408 fn init_repo(root: &Path) {
409 git(root, &["init", "--quiet"]);
410 git(root, &["config", "user.email", "test@example.com"]);
411 git(root, &["config", "user.name", "test"]);
412 git(root, &["config", "commit.gpgsign", "false"]);
413 }
414
415 #[test]
422 fn only_clean_regular_tracked_files_are_cache_keyed() {
423 let tmp = TempDir::new().expect("tempdir");
424 let root = tmp.path();
425 init_repo(root);
426
427 std::fs::write(root.join("clean.py"), "x = 1\n").expect("write clean");
428 std::fs::write(root.join("exec.sh"), "echo hi\n").expect("write exec");
429 #[cfg(unix)]
430 {
431 use std::os::unix::fs::PermissionsExt;
432 std::fs::set_permissions(root.join("exec.sh"), std::fs::Permissions::from_mode(0o755))
433 .expect("chmod exec");
434 }
435 std::fs::write(root.join("dirty.py"), "y = 1\n").expect("write dirty");
436 #[cfg(unix)]
437 std::os::unix::fs::symlink("clean.py", root.join("link.py")).expect("symlink");
438
439 git(root, &["add", "-A"]);
440 git(root, &["commit", "--quiet", "-m", "base"]);
441
442 std::fs::write(root.join("dirty.py"), "y = 2\n").expect("modify dirty");
444
445 let oids = resolve_cacheable_oids(root);
446 let keyed = |name: &str| oids.contains_key(&root.join(name));
447
448 assert!(keyed("clean.py"), "a clean 100644 file must be cache-keyed");
449 assert!(keyed("exec.sh"), "a clean 100755 file must be cache-keyed");
450 assert!(
451 !keyed("dirty.py"),
452 "a file with unstaged modifications must bypass the cache"
453 );
454 #[cfg(unix)]
455 assert!(
456 !keyed("link.py"),
457 "a symlink (mode 120000) must bypass the cache"
458 );
459
460 for oid in oids.values() {
461 assert!(
462 oid.len() >= 3 && oid.bytes().all(|b| b.is_ascii_hexdigit()),
463 "unusable oid as a cache key: {oid:?}"
464 );
465 }
466 }
467
468 #[test]
469 fn resolve_cacheable_oids_is_empty_outside_a_repository() {
470 let tmp = TempDir::new().expect("tempdir");
471 assert!(resolve_cacheable_oids(tmp.path()).is_empty());
472 }
473
474 #[test]
475 fn cache_entries_round_trip_and_reject_unusable_oids() {
476 let tmp = TempDir::new().expect("tempdir");
477 let store = TokenCacheStore {
478 dir: tmp.path().to_path_buf(),
479 };
480
481 let mut term_counts: FxHashMap<String, u32> = FxHashMap::default();
482 term_counts.insert("alpha".into(), 3);
483 term_counts.insert("beta".into(), 1);
484 let doc = DocTokens {
485 term_counts: term_counts.clone(),
486 total_len: 4,
487 };
488
489 let oid = "abcdef0123456789";
490 store.save(oid, &doc);
491 let loaded = store.load(oid).expect("entry round-trips");
492 assert_eq!(loaded.total_len, doc.total_len);
493 assert_eq!(loaded.term_counts, term_counts);
494
495 assert!(store.entry_path("ab").is_none(), "too-short oid accepted");
496 assert!(
497 store.entry_path("../../etc/passwd").is_none(),
498 "non-hex oid accepted as a path component"
499 );
500 assert!(store.load("zzzz").is_none());
501 }
502}