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 let shard = std::time::SystemTime::now()
230 .duration_since(std::time::UNIX_EPOCH)
231 .map(|d| d.subsec_nanos() as u64 % CACHE_SHARDS)
232 .unwrap_or(0);
233 evict_shard(
234 &self.dir.join(format!("{shard:02x}")),
235 max_bytes / CACHE_SHARDS,
236 );
237 }
238}
239
240fn cache_max_bytes() -> Option<u64> {
241 match std::env::var("DIFFCTX_TOKEN_CACHE_MAX_BYTES") {
242 Ok(raw) => cache_max_bytes_from(&raw),
243 Err(_) => Some(DEFAULT_CACHE_MAX_BYTES),
244 }
245}
246
247fn cache_max_bytes_from(raw: &str) -> Option<u64> {
248 let raw = raw.trim();
249 if raw.is_empty() {
250 return Some(DEFAULT_CACHE_MAX_BYTES);
251 }
252 raw.parse::<u64>()
253 .map_or(Some(DEFAULT_CACHE_MAX_BYTES), |b| (b > 0).then_some(b))
254}
255
256fn evict_shard(shard_dir: &Path, shard_max_bytes: u64) {
257 let Ok(entries) = std::fs::read_dir(shard_dir) else {
258 return;
259 };
260 let mut files: Vec<(std::time::SystemTime, u64, PathBuf)> = entries
261 .flatten()
262 .filter_map(|e| {
263 let meta = e.metadata().ok()?;
264 if !meta.is_file() {
265 return None;
266 }
267 Some((
268 meta.modified().unwrap_or(std::time::UNIX_EPOCH),
269 meta.len(),
270 e.path(),
271 ))
272 })
273 .collect();
274
275 let mut total: u64 = files.iter().map(|(_, len, _)| len).sum();
276 if total <= shard_max_bytes {
277 return;
278 }
279
280 files.sort_by_key(|(modified, _, _)| *modified);
283 let target = (shard_max_bytes as f64 * SHARD_EVICTION_TARGET_FRACTION) as u64;
284 let mut removed = 0usize;
285 for (_, len, path) in &files {
286 if total <= target {
287 break;
288 }
289 if std::fs::remove_file(path).is_ok() {
290 total -= len;
291 removed += 1;
292 }
293 }
294 tracing::debug!(
295 "token cache: evicted {} entries from {}",
296 removed,
297 shard_dir.display()
298 );
299}
300
301fn default_cache_root() -> Option<PathBuf> {
302 #[cfg(target_os = "macos")]
303 {
304 std::env::var_os("HOME")
305 .map(|h| PathBuf::from(h).join("Library/Caches/diffctx/token-cache"))
306 }
307 #[cfg(target_os = "windows")]
308 {
309 std::env::var_os("LOCALAPPDATA").map(|d| PathBuf::from(d).join("diffctx/token-cache"))
310 }
311 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
312 {
313 std::env::var_os("XDG_CACHE_HOME")
314 .filter(|v| !v.is_empty())
315 .map(PathBuf::from)
316 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
317 .map(|c| c.join("diffctx/token-cache"))
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use tempfile::TempDir;
325
326 fn write_entry(dir: &Path, name: &str, bytes: usize, age_secs: u64) {
330 let path = dir.join(name);
331 std::fs::write(&path, vec![b'x'; bytes]).expect("write entry");
332 let stamp = std::time::SystemTime::now() - std::time::Duration::from_secs(age_secs);
333 std::fs::File::options()
334 .write(true)
335 .open(&path)
336 .expect("open entry")
337 .set_modified(stamp)
338 .expect("set mtime");
339 }
340
341 #[test]
342 fn evict_shard_drops_oldest_entries_until_under_target() {
343 let tmp = TempDir::new().expect("tempdir");
344 let shard = tmp.path().join("ab");
345 std::fs::create_dir_all(&shard).expect("shard dir");
346 for (age_secs, name) in [(3600, "oldest"), (1800, "middle"), (60, "newest")] {
347 write_entry(&shard, name, 1000, age_secs);
348 }
349
350 evict_shard(&shard, 2000);
351
352 let survivors: Vec<String> = std::fs::read_dir(&shard)
353 .expect("read shard")
354 .flatten()
355 .map(|e| e.file_name().to_string_lossy().into_owned())
356 .collect();
357 assert_eq!(survivors, vec!["newest".to_string()]);
358 }
359
360 #[test]
361 fn evict_shard_keeps_everything_under_the_cap() {
362 let tmp = TempDir::new().expect("tempdir");
363 let shard = tmp.path().join("cd");
364 std::fs::create_dir_all(&shard).expect("shard dir");
365 write_entry(&shard, "kept", 1000, 86_400);
366
367 evict_shard(&shard, 4096);
368
369 assert!(shard.join("kept").exists());
370 }
371
372 #[test]
373 fn cache_max_bytes_honors_the_unlimited_and_override_settings() {
374 assert_eq!(cache_max_bytes_from("0"), None);
375 assert_eq!(cache_max_bytes_from("4096"), Some(4096));
376 assert_eq!(
377 cache_max_bytes_from("not-a-number"),
378 Some(DEFAULT_CACHE_MAX_BYTES)
379 );
380 assert_eq!(cache_max_bytes_from(""), Some(DEFAULT_CACHE_MAX_BYTES));
381 }
382
383 fn git(repo: &Path, args: &[&str]) {
384 let out = git::git_command(repo)
385 .args(args)
386 .env("GIT_AUTHOR_NAME", "test")
387 .env("GIT_AUTHOR_EMAIL", "test@example.com")
388 .env("GIT_COMMITTER_NAME", "test")
389 .env("GIT_COMMITTER_EMAIL", "test@example.com")
390 .output()
391 .expect("spawn git");
392 assert!(
393 out.status.success(),
394 "git {args:?} failed: {}",
395 String::from_utf8_lossy(&out.stderr)
396 );
397 }
398
399 fn init_repo(root: &Path) {
400 git(root, &["init", "--quiet"]);
401 git(root, &["config", "user.email", "test@example.com"]);
402 git(root, &["config", "user.name", "test"]);
403 git(root, &["config", "commit.gpgsign", "false"]);
404 }
405
406 #[test]
413 fn only_clean_regular_tracked_files_are_cache_keyed() {
414 let tmp = TempDir::new().expect("tempdir");
415 let root = tmp.path();
416 init_repo(root);
417
418 std::fs::write(root.join("clean.py"), "x = 1\n").expect("write clean");
419 std::fs::write(root.join("exec.sh"), "echo hi\n").expect("write exec");
420 #[cfg(unix)]
421 {
422 use std::os::unix::fs::PermissionsExt;
423 std::fs::set_permissions(root.join("exec.sh"), std::fs::Permissions::from_mode(0o755))
424 .expect("chmod exec");
425 }
426 std::fs::write(root.join("dirty.py"), "y = 1\n").expect("write dirty");
427 #[cfg(unix)]
428 std::os::unix::fs::symlink("clean.py", root.join("link.py")).expect("symlink");
429
430 git(root, &["add", "-A"]);
431 git(root, &["commit", "--quiet", "-m", "base"]);
432
433 std::fs::write(root.join("dirty.py"), "y = 2\n").expect("modify dirty");
435
436 let oids = resolve_cacheable_oids(root);
437 let keyed = |name: &str| oids.contains_key(&root.join(name));
438
439 assert!(keyed("clean.py"), "a clean 100644 file must be cache-keyed");
440 assert!(keyed("exec.sh"), "a clean 100755 file must be cache-keyed");
441 assert!(
442 !keyed("dirty.py"),
443 "a file with unstaged modifications must bypass the cache"
444 );
445 #[cfg(unix)]
446 assert!(
447 !keyed("link.py"),
448 "a symlink (mode 120000) must bypass the cache"
449 );
450
451 for oid in oids.values() {
452 assert!(
453 oid.len() >= 3 && oid.bytes().all(|b| b.is_ascii_hexdigit()),
454 "unusable oid as a cache key: {oid:?}"
455 );
456 }
457 }
458
459 #[test]
460 fn resolve_cacheable_oids_is_empty_outside_a_repository() {
461 let tmp = TempDir::new().expect("tempdir");
462 assert!(resolve_cacheable_oids(tmp.path()).is_empty());
463 }
464
465 #[test]
466 fn cache_entries_round_trip_and_reject_unusable_oids() {
467 let tmp = TempDir::new().expect("tempdir");
468 let store = TokenCacheStore {
469 dir: tmp.path().to_path_buf(),
470 };
471
472 let mut term_counts: FxHashMap<String, u32> = FxHashMap::default();
473 term_counts.insert("alpha".into(), 3);
474 term_counts.insert("beta".into(), 1);
475 let doc = DocTokens {
476 term_counts: term_counts.clone(),
477 total_len: 4,
478 };
479
480 let oid = "abcdef0123456789";
481 store.save(oid, &doc);
482 let loaded = store.load(oid).expect("entry round-trips");
483 assert_eq!(loaded.total_len, doc.total_len);
484 assert_eq!(loaded.term_counts, term_counts);
485
486 assert!(store.entry_path("ab").is_none(), "too-short oid accepted");
487 assert!(
488 store.entry_path("../../etc/passwd").is_none(),
489 "non-hex oid accepted as a path component"
490 );
491 assert!(store.load("zzzz").is_none());
492 }
493}