1use std::path::Path;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum IngestKind {
16 Code,
18 Document,
20 Data,
22 Text,
24 Generated,
27 Binary,
29}
30
31impl IngestKind {
32 #[must_use]
34 pub fn is_ingestible(self) -> bool {
35 !matches!(self, IngestKind::Binary | IngestKind::Generated)
36 }
37
38 #[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
52const 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
58const 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
77const BINARY_EXTS: &[&str] = &[
82 "png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "tif", "tiff", "heic", "avif", "mp3", "wav", "flac", "ogg", "mp4", "mov", "avi", "mkv", "webm", "zip", "gz", "tgz", "bz2", "xz", "zst", "7z", "rar", "tar", "jar", "war", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "exe", "dll", "so", "dylib", "o", "a", "class", "wasm", "bin", "dat", "ttf", "otf", "woff", "woff2", "db", "sqlite",
94];
95
96const EXTRACTABLE_DOC_EXTS: &[&str] = &["pdf"];
101
102const SNIFF_BYTES: usize = 8192;
104
105fn 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#[must_use]
138pub fn classify_path(path: &Path) -> IngestKind {
139 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 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 if looks_textual(path) {
171 IngestKind::Text
172 } else {
173 IngestKind::Binary
174 }
175}
176
177#[must_use]
180pub fn is_ingestible(path: &Path) -> bool {
181 classify_path(path).is_ingestible()
182}
183
184fn 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, 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 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 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 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 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 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}