Skip to main content

lean_ctx/core/
ingestion.rs

1//! Ingestion front-door (`ingestion-spec-v1`).
2//!
3//! Decides what reaches the index — BM25, semantic, knowledge — **independent of
4//! code-ness**. Historically only files passing `is_code_file` were indexed,
5//! which locked lean-ctx to source repositories. Intake is now driven by a
6//! content-*kind* classification (extension fast-path + a bounded binary sniff),
7//! so any text corpus (docs, data, transcripts, logs) is ingestible while
8//! genuine binaries are excluded. Code repositories behave exactly as before:
9//! every kind that used to index still indexes.
10
11use std::path::Path;
12
13/// What kind of content a path holds, for intake decisions.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum IngestKind {
16    /// Source code — eligible for AST/symbol-aware processing downstream.
17    Code,
18    /// Prose / human documents (markdown, txt, html, email, …).
19    Document,
20    /// Structured data (json, yaml, toml, csv, xml, …).
21    Data,
22    /// Other UTF-8 text (unknown extension but verified textual).
23    Text,
24    /// Auto-generated content excluded from every index regardless of its
25    /// (often textual) extension — currently dependency lockfiles (#585).
26    Generated,
27    /// Not ingestible as text (images, media, archives, binary documents).
28    Binary,
29}
30
31impl IngestKind {
32    /// Whether content of this kind should be fed to the index.
33    #[must_use]
34    pub fn is_ingestible(self) -> bool {
35        !matches!(self, IngestKind::Binary | IngestKind::Generated)
36    }
37
38    /// Stable lowercase label (for capabilities / diagnostics).
39    #[must_use]
40    pub fn as_str(self) -> &'static str {
41        match self {
42            IngestKind::Code => "code",
43            IngestKind::Document => "document",
44            IngestKind::Data => "data",
45            IngestKind::Text => "text",
46            IngestKind::Generated => "generated",
47            IngestKind::Binary => "binary",
48        }
49    }
50}
51
52/// Prose / document extensions.
53const DOCUMENT_EXTS: &[&str] = &[
54    "md", "markdown", "mdx", "txt", "text", "rst", "org", "adoc", "asciidoc", "tex", "html", "htm",
55    "xhtml", "eml", "mbox", "log", "srt", "vtt",
56];
57
58/// Structured-data extensions.
59const DATA_EXTS: &[&str] = &[
60    "json",
61    "jsonl",
62    "ndjson",
63    "yaml",
64    "yml",
65    "toml",
66    "csv",
67    "tsv",
68    "xml",
69    "ini",
70    "cfg",
71    "conf",
72    "properties",
73    "graphql",
74    "proto",
75];
76
77/// Extensions we know are binary or not useful as raw text. Binary documents
78/// that *do* have a dedicated extractor (currently PDF) live in
79/// [`EXTRACTABLE_DOC_EXTS`] instead and are ingestible; office formats without
80/// an extractor yet (DOCX, XLSX, …) stay here and are skipped.
81const BINARY_EXTS: &[&str] = &[
82    // images
83    "png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "tif", "tiff", "heic", "avif", //
84    // media
85    "mp3", "wav", "flac", "ogg", "mp4", "mov", "avi", "mkv", "webm", //
86    // archives / packages
87    "zip", "gz", "tgz", "bz2", "xz", "zst", "7z", "rar", "tar", "jar", "war", //
88    // binary docs without an extractor yet (PDF lives in EXTRACTABLE_DOC_EXTS)
89    "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", //
90    // compiled / binary artifacts
91    "exe", "dll", "so", "dylib", "o", "a", "class", "wasm", "bin", "dat", //
92    // fonts / db / images-vector-binary
93    "ttf", "otf", "woff", "woff2", "db", "sqlite",
94];
95
96/// Binary document formats that have a dedicated byte-level extractor
97/// ([`super::extractors`]) and are therefore ingestible: the indexer reads their
98/// raw bytes and converts them to text at index time rather than skipping them
99/// as opaque binaries. Grows as binary extractors (DOCX, XLSX, …) are added.
100const EXTRACTABLE_DOC_EXTS: &[&str] = &["pdf"];
101
102/// Max bytes inspected when sniffing an unknown-extension file.
103const SNIFF_BYTES: usize = 8192;
104
105/// Auto-generated dependency lockfiles. These are pinned dependency manifests —
106/// never useful for code understanding, often huge, and a token sink whenever a
107/// retrieval surface (`ctx_compose`, BM25 search) inlines them, so every index
108/// walker skips them via [`IngestKind::Generated`]. An explicit
109/// `ctx_read`/`ctx_tree`/`ctx_glob` of a lockfile still works — those paths
110/// never consult the ingestion gate.
111///
112/// Detection is by file *name*, independent of directory depth (so a monorepo's
113/// `frontend/package-lock.json` is caught too) and of extension: `*.lock` /
114/// `*.lockb` (Cargo, yarn, bun, poetry, Gemfile, flake, …) plus the JSON/YAML
115/// npm/pnpm lockfiles that would otherwise ingest as structured data (#585).
116fn is_generated_lockfile(path: &Path) -> bool {
117    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
118    if matches!(
119        name,
120        "package-lock.json" | "npm-shrinkwrap.json" | "pnpm-lock.yaml"
121    ) {
122        return true;
123    }
124    matches!(
125        path.extension()
126            .and_then(|e| e.to_str())
127            .map(str::to_ascii_lowercase)
128            .as_deref(),
129        Some("lock" | "lockb")
130    )
131}
132
133/// Classify a path into an [`IngestKind`].
134///
135/// Fast path is extension-based; files with an unknown extension are sniffed
136/// (bounded read) so textual content is still picked up and binaries rejected.
137#[must_use]
138pub fn classify_path(path: &Path) -> IngestKind {
139    // Auto-generated lockfiles are excluded before any extension routing — their
140    // .json/.yaml variants would otherwise pass as ingestible data (#585).
141    if is_generated_lockfile(path) {
142        return IngestKind::Generated;
143    }
144
145    let ext = path
146        .extension()
147        .and_then(|e| e.to_str())
148        .unwrap_or("")
149        .to_lowercase();
150
151    if BINARY_EXTS.contains(&ext.as_str()) {
152        return IngestKind::Binary;
153    }
154    // Binary documents with a dedicated extractor (PDF, …): ingestible as
155    // documents — the indexer routes their bytes through `extractors::extract`
156    // instead of reading them as UTF-8.
157    if EXTRACTABLE_DOC_EXTS.contains(&ext.as_str()) {
158        return IngestKind::Document;
159    }
160    if crate::core::bm25_index::is_code_file(path) {
161        return IngestKind::Code;
162    }
163    if DOCUMENT_EXTS.contains(&ext.as_str()) {
164        return IngestKind::Document;
165    }
166    if DATA_EXTS.contains(&ext.as_str()) {
167        return IngestKind::Data;
168    }
169    // Unknown extension (or none): verify it is actually text before ingesting.
170    if looks_textual(path) {
171        IngestKind::Text
172    } else {
173        IngestKind::Binary
174    }
175}
176
177/// Whether a path should be fed to the index. Single front-door replacing the
178/// old `is_code_file` gate.
179#[must_use]
180pub fn is_ingestible(path: &Path) -> bool {
181    classify_path(path).is_ingestible()
182}
183
184/// Bounded heuristic: read the first [`SNIFF_BYTES`] and decide whether the
185/// content is text. A NUL byte or a high ratio of non-text control bytes marks
186/// it binary. Unreadable files are treated as binary (skipped).
187fn looks_textual(path: &Path) -> bool {
188    use std::io::Read;
189
190    let Ok(mut file) = std::fs::File::open(path) else {
191        return false;
192    };
193    let mut buf = [0u8; SNIFF_BYTES];
194    let n = match file.read(&mut buf) {
195        Ok(0) => return true, // empty file: harmless to index
196        Ok(n) => n,
197        Err(_) => return false,
198    };
199    let sample = &buf[..n];
200
201    if sample.contains(&0) {
202        return false;
203    }
204    let suspicious = sample
205        .iter()
206        .filter(|&&b| b < 0x09 || (b > 0x0d && b < 0x20))
207        .count();
208    // Allow a small fraction of control bytes (some text files carry form-feed
209    // etc.) but reject clearly binary content.
210    suspicious * 100 / n.max(1) < 10
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use std::io::Write;
217    use std::path::PathBuf;
218
219    fn p(name: &str) -> PathBuf {
220        PathBuf::from(name)
221    }
222
223    #[test]
224    fn code_files_classify_as_code() {
225        assert_eq!(classify_path(&p("src/main.rs")), IngestKind::Code);
226        assert_eq!(classify_path(&p("app/index.ts")), IngestKind::Code);
227        assert!(is_ingestible(&p("src/main.rs")));
228    }
229
230    #[test]
231    fn documents_and_data_are_ingestible() {
232        assert_eq!(classify_path(&p("README.md")), IngestKind::Document);
233        assert_eq!(classify_path(&p("notes.txt")), IngestKind::Document);
234        assert_eq!(classify_path(&p("page.html")), IngestKind::Document);
235        assert_eq!(classify_path(&p("data.csv")), IngestKind::Data);
236        assert_eq!(classify_path(&p("config.yaml")), IngestKind::Data);
237        for f in ["README.md", "data.csv", "config.yaml", "page.html"] {
238            assert!(is_ingestible(&p(f)), "{f} should ingest");
239        }
240    }
241
242    #[test]
243    fn binaries_are_excluded() {
244        for f in ["logo.png", "archive.zip", "lib.so", "app.wasm"] {
245            assert_eq!(classify_path(&p(f)), IngestKind::Binary, "{f}");
246            assert!(!is_ingestible(&p(f)), "{f} must not ingest");
247        }
248    }
249
250    #[test]
251    fn pdf_is_ingestible_via_extractor() {
252        // PDF has a dedicated extractor, so it classifies as an ingestible
253        // document (read through `extractors::extract`, not as UTF-8).
254        assert_eq!(classify_path(&p("report.pdf")), IngestKind::Document);
255        assert_eq!(classify_path(&p("REPORT.PDF")), IngestKind::Document);
256        assert!(is_ingestible(&p("report.pdf")));
257        // Office binaries without an extractor stay excluded.
258        for f in ["paper.docx", "sheet.xlsx", "deck.pptx", "doc.odt"] {
259            assert_eq!(classify_path(&p(f)), IngestKind::Binary, "{f}");
260            assert!(!is_ingestible(&p(f)), "{f} must not ingest");
261        }
262    }
263
264    #[test]
265    fn unknown_extension_is_sniffed() {
266        let dir = tempfile::tempdir().unwrap();
267
268        let textual = dir.path().join("mystery.weirdext");
269        std::fs::write(&textual, "just normal text content\nwith lines\n").unwrap();
270        assert_eq!(classify_path(&textual), IngestKind::Text);
271        assert!(is_ingestible(&textual));
272
273        let binary = dir.path().join("blob.weirdext");
274        let mut f = std::fs::File::create(&binary).unwrap();
275        f.write_all(&[0u8, 1, 2, 3, 0, 255, 254]).unwrap();
276        assert_eq!(classify_path(&binary), IngestKind::Binary);
277        assert!(!is_ingestible(&binary));
278    }
279
280    #[test]
281    fn no_extension_textual_file_ingests() {
282        let dir = tempfile::tempdir().unwrap();
283        let readme = dir.path().join("LICENSE");
284        std::fs::write(&readme, "MIT License\n\nPermission is hereby granted\n").unwrap();
285        assert_eq!(classify_path(&readme), IngestKind::Text);
286    }
287
288    #[test]
289    fn generated_lockfiles_are_excluded() {
290        // npm/pnpm lockfiles carry ingestible .json/.yaml extensions; the rest
291        // are caught by the .lock/.lockb extension — at any directory depth.
292        for f in [
293            "package-lock.json",
294            "npm-shrinkwrap.json",
295            "pnpm-lock.yaml",
296            "yarn.lock",
297            "Cargo.lock",
298            "bun.lock",
299            "bun.lockb",
300            "poetry.lock",
301            "frontend/package-lock.json",
302            "crates/foo/Cargo.lock",
303        ] {
304            assert_eq!(classify_path(&p(f)), IngestKind::Generated, "{f}");
305            assert!(!is_ingestible(&p(f)), "{f} must not ingest");
306        }
307        // Lookalikes that are real content must still ingest.
308        assert!(is_ingestible(&p("src/lock.rs")));
309        assert!(is_ingestible(&p("docs/locking-notes.md")));
310        assert!(is_ingestible(&p("config/settings.json")));
311    }
312}