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    // UTF-16 (Visual Studio, PowerShell `Out-File`) is text despite its NULs.
202    if crate::core::text_decode::has_utf16_bom(sample) {
203        return true;
204    }
205    if sample.contains(&0) {
206        return false;
207    }
208    let suspicious = sample
209        .iter()
210        .filter(|&&b| b < 0x09 || (b > 0x0d && b < 0x20))
211        .count();
212    // Allow a small fraction of control bytes (some text files carry form-feed
213    // etc.) but reject clearly binary content.
214    suspicious * 100 / n.max(1) < 10
215}
216
217#[cfg(test)]
218pub mod tests {
219    use super::*;
220    use std::io::Write;
221    use std::path::PathBuf;
222
223    fn p(name: &str) -> PathBuf {
224        PathBuf::from(name)
225    }
226
227    #[test]
228    fn code_files_classify_as_code() {
229        assert_eq!(classify_path(&p("src/main.rs")), IngestKind::Code);
230        assert_eq!(classify_path(&p("app/index.ts")), IngestKind::Code);
231        assert!(is_ingestible(&p("src/main.rs")));
232    }
233
234    #[test]
235    fn documents_and_data_are_ingestible() {
236        assert_eq!(classify_path(&p("README.md")), IngestKind::Document);
237        assert_eq!(classify_path(&p("notes.txt")), IngestKind::Document);
238        assert_eq!(classify_path(&p("page.html")), IngestKind::Document);
239        assert_eq!(classify_path(&p("data.csv")), IngestKind::Data);
240        assert_eq!(classify_path(&p("config.yaml")), IngestKind::Data);
241        for f in ["README.md", "data.csv", "config.yaml", "page.html"] {
242            assert!(is_ingestible(&p(f)), "{f} should ingest");
243        }
244    }
245
246    #[test]
247    fn binaries_are_excluded() {
248        for f in ["logo.png", "archive.zip", "lib.so", "app.wasm"] {
249            assert_eq!(classify_path(&p(f)), IngestKind::Binary, "{f}");
250            assert!(!is_ingestible(&p(f)), "{f} must not ingest");
251        }
252    }
253
254    #[test]
255    fn pdf_is_ingestible_via_extractor() {
256        // PDF has a dedicated extractor, so it classifies as an ingestible
257        // document (read through `extractors::extract`, not as UTF-8).
258        assert_eq!(classify_path(&p("report.pdf")), IngestKind::Document);
259        assert_eq!(classify_path(&p("REPORT.PDF")), IngestKind::Document);
260        assert!(is_ingestible(&p("report.pdf")));
261        // Office binaries without an extractor stay excluded.
262        for f in ["paper.docx", "sheet.xlsx", "deck.pptx", "doc.odt"] {
263            assert_eq!(classify_path(&p(f)), IngestKind::Binary, "{f}");
264            assert!(!is_ingestible(&p(f)), "{f} must not ingest");
265        }
266    }
267
268    #[test]
269    fn unknown_extension_is_sniffed() {
270        let dir = tempfile::tempdir().unwrap();
271
272        let textual = dir.path().join("mystery.weirdext");
273        std::fs::write(&textual, "just normal text content\nwith lines\n").unwrap();
274        assert_eq!(classify_path(&textual), IngestKind::Text);
275        assert!(is_ingestible(&textual));
276
277        let binary = dir.path().join("blob.weirdext");
278        let mut f = std::fs::File::create(&binary).unwrap();
279        f.write_all(&[0u8, 1, 2, 3, 0, 255, 254]).unwrap();
280        assert_eq!(classify_path(&binary), IngestKind::Binary);
281        assert!(!is_ingestible(&binary));
282    }
283
284    #[test]
285    fn no_extension_textual_file_ingests() {
286        let dir = tempfile::tempdir().unwrap();
287        let readme = dir.path().join("LICENSE");
288        std::fs::write(&readme, "MIT License\n\nPermission is hereby granted\n").unwrap();
289        assert_eq!(classify_path(&readme), IngestKind::Text);
290    }
291
292    #[test]
293    fn generated_lockfiles_are_excluded() {
294        // npm/pnpm lockfiles carry ingestible .json/.yaml extensions; the rest
295        // are caught by the .lock/.lockb extension — at any directory depth.
296        for f in [
297            "package-lock.json",
298            "npm-shrinkwrap.json",
299            "pnpm-lock.yaml",
300            "yarn.lock",
301            "Cargo.lock",
302            "bun.lock",
303            "bun.lockb",
304            "poetry.lock",
305            "frontend/package-lock.json",
306            "crates/foo/Cargo.lock",
307        ] {
308            assert_eq!(classify_path(&p(f)), IngestKind::Generated, "{f}");
309            assert!(!is_ingestible(&p(f)), "{f} must not ingest");
310        }
311        // Lookalikes that are real content must still ingest.
312        assert!(is_ingestible(&p("src/lock.rs")));
313        assert!(is_ingestible(&p("docs/locking-notes.md")));
314        assert!(is_ingestible(&p("config/settings.json")));
315    }
316}