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        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    // Oldest first: entries are never touched after their single write, so
281    // mtime orders them by insertion, not by use.
282    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    // mtime is stamped explicitly: filesystems whose timestamp granularity is
327    // coarser than the writes (CI runners) would otherwise give all entries the
328    // same mtime, leaving eviction order up to readdir.
329    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    /// A blob OID is only a valid cache key while the working tree matches the
407    /// index. The three bypass branches are what keep cold and warm runs
408    /// bit-equivalent, and the determinism fixture cannot reach any of them:
409    /// every entry there is mode 100644, stage 0 and clean. A regression here
410    /// surfaces only on the *second* run against a given repo, i.e. never in a
411    /// fresh CI checkout.
412    #[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        // Unstaged modification: the index OID no longer describes the content.
434        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}