Skip to main content

_diffctx/
token_corpus.rs

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
14// Bump whenever identifier extraction changes (regex, lowercasing, length
15// filtering): the epoch is part of the on-disk cache key, so stale entries
16// are invalidated instead of silently poisoning results.
17pub const TOKENIZER_EPOCH: u32 = 1;
18
19// Size cap for the on-disk token cache, overridable with
20// DIFFCTX_TOKEN_CACHE_MAX_BYTES (0 = unlimited). The cache is a pure speedup,
21// so the default trades a cold tokenization pass for bounded disk use.
22const 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
35// The rare-identifier and BM25 strategies can share one tokenized corpus
36// only because their tokenizers are identical: same identifier regex, same
37// lowercasing, equal minimum lengths. The assert pins that assumption -
38// filtering by post-lowercase length is NOT equivalent to the pre-lowercase
39// length filter for non-ASCII identifiers, so if these configs ever diverge
40// the corpus must be split back into per-strategy passes.
41fn 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
107// Blob OIDs are only usable as cache keys for regular tracked files whose
108// working-tree content matches the index: symlinks/gitlinks, conflicted
109// stages, and files with unstaged modifications all bypass the cache and
110// fall through to a direct read+tokenize, which keeps cold and warm runs
111// bit-equivalent. Any git failure disables keying entirely for the run.
112fn 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    // Entries are written once and never rewritten, so nothing ages out on its
221    // own and the cache grows with every repository ever analyzed. Walking all
222    // 256 shards per run would cost more than the cache saves, so each run
223    // enforces the per-shard share of the size cap on ONE shard; every shard is
224    // reached within a few hundred runs.
225    fn evict_one_shard(&self) {
226        let Some(max_bytes) = cache_max_bytes() else {
227            return;
228        };
229        // One shard per run is the coupon collector's problem: touching all
230        // 256 takes ~256·H(256) ≈ 1570 runs, and ordinary usage never gets
231        // there — measured at 6.3GB against the 512MB cap, 12x over (#122).
232        // A contiguous window sweeps the whole table every 256/WINDOW runs
233        // while still costing a bounded, small slice per invocation.
234        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    // Oldest first: entries are never touched after their single write, so
290    // mtime orders them by insertion, not by use.
291    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    // mtime is stamped explicitly: filesystems whose timestamp granularity is
336    // coarser than the writes (CI runners) would otherwise give all entries the
337    // same mtime, leaving eviction order up to readdir.
338    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    /// A blob OID is only a valid cache key while the working tree matches the
416    /// index. The three bypass branches are what keep cold and warm runs
417    /// bit-equivalent, and the determinism fixture cannot reach any of them:
418    /// every entry there is mode 100644, stage 0 and clean. A regression here
419    /// surfaces only on the *second* run against a given repo, i.e. never in a
420    /// fresh CI checkout.
421    #[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        // Unstaged modification: the index OID no longer describes the content.
443        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}