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 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 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 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 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 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 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}