1use std::path::Path;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum IngestKind {
16 Code,
18 Document,
20 Data,
22 Text,
24 Binary,
26}
27
28impl IngestKind {
29 #[must_use]
31 pub fn is_ingestible(self) -> bool {
32 !matches!(self, IngestKind::Binary)
33 }
34
35 #[must_use]
37 pub fn as_str(self) -> &'static str {
38 match self {
39 IngestKind::Code => "code",
40 IngestKind::Document => "document",
41 IngestKind::Data => "data",
42 IngestKind::Text => "text",
43 IngestKind::Binary => "binary",
44 }
45 }
46}
47
48const DOCUMENT_EXTS: &[&str] = &[
50 "md", "markdown", "mdx", "txt", "text", "rst", "org", "adoc", "asciidoc", "tex", "html", "htm",
51 "xhtml", "eml", "mbox", "log", "srt", "vtt",
52];
53
54const DATA_EXTS: &[&str] = &[
56 "json",
57 "jsonl",
58 "ndjson",
59 "yaml",
60 "yml",
61 "toml",
62 "csv",
63 "tsv",
64 "xml",
65 "ini",
66 "cfg",
67 "conf",
68 "properties",
69 "graphql",
70 "proto",
71];
72
73const BINARY_EXTS: &[&str] = &[
78 "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", "lock",
90];
91
92const EXTRACTABLE_DOC_EXTS: &[&str] = &["pdf"];
97
98const SNIFF_BYTES: usize = 8192;
100
101#[must_use]
106pub fn classify_path(path: &Path) -> IngestKind {
107 let ext = path
108 .extension()
109 .and_then(|e| e.to_str())
110 .unwrap_or("")
111 .to_lowercase();
112
113 if BINARY_EXTS.contains(&ext.as_str()) {
114 return IngestKind::Binary;
115 }
116 if EXTRACTABLE_DOC_EXTS.contains(&ext.as_str()) {
120 return IngestKind::Document;
121 }
122 if crate::core::bm25_index::is_code_file(path) {
123 return IngestKind::Code;
124 }
125 if DOCUMENT_EXTS.contains(&ext.as_str()) {
126 return IngestKind::Document;
127 }
128 if DATA_EXTS.contains(&ext.as_str()) {
129 return IngestKind::Data;
130 }
131 if looks_textual(path) {
133 IngestKind::Text
134 } else {
135 IngestKind::Binary
136 }
137}
138
139#[must_use]
142pub fn is_ingestible(path: &Path) -> bool {
143 classify_path(path).is_ingestible()
144}
145
146fn looks_textual(path: &Path) -> bool {
150 use std::io::Read;
151
152 let Ok(mut file) = std::fs::File::open(path) else {
153 return false;
154 };
155 let mut buf = [0u8; SNIFF_BYTES];
156 let n = match file.read(&mut buf) {
157 Ok(0) => return true, Ok(n) => n,
159 Err(_) => return false,
160 };
161 let sample = &buf[..n];
162
163 if sample.contains(&0) {
164 return false;
165 }
166 let suspicious = sample
167 .iter()
168 .filter(|&&b| b < 0x09 || (b > 0x0d && b < 0x20))
169 .count();
170 suspicious * 100 / n.max(1) < 10
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use std::io::Write;
179 use std::path::PathBuf;
180
181 fn p(name: &str) -> PathBuf {
182 PathBuf::from(name)
183 }
184
185 #[test]
186 fn code_files_classify_as_code() {
187 assert_eq!(classify_path(&p("src/main.rs")), IngestKind::Code);
188 assert_eq!(classify_path(&p("app/index.ts")), IngestKind::Code);
189 assert!(is_ingestible(&p("src/main.rs")));
190 }
191
192 #[test]
193 fn documents_and_data_are_ingestible() {
194 assert_eq!(classify_path(&p("README.md")), IngestKind::Document);
195 assert_eq!(classify_path(&p("notes.txt")), IngestKind::Document);
196 assert_eq!(classify_path(&p("page.html")), IngestKind::Document);
197 assert_eq!(classify_path(&p("data.csv")), IngestKind::Data);
198 assert_eq!(classify_path(&p("config.yaml")), IngestKind::Data);
199 for f in ["README.md", "data.csv", "config.yaml", "page.html"] {
200 assert!(is_ingestible(&p(f)), "{f} should ingest");
201 }
202 }
203
204 #[test]
205 fn binaries_are_excluded() {
206 for f in ["logo.png", "archive.zip", "lib.so", "app.wasm"] {
207 assert_eq!(classify_path(&p(f)), IngestKind::Binary, "{f}");
208 assert!(!is_ingestible(&p(f)), "{f} must not ingest");
209 }
210 }
211
212 #[test]
213 fn pdf_is_ingestible_via_extractor() {
214 assert_eq!(classify_path(&p("report.pdf")), IngestKind::Document);
217 assert_eq!(classify_path(&p("REPORT.PDF")), IngestKind::Document);
218 assert!(is_ingestible(&p("report.pdf")));
219 for f in ["paper.docx", "sheet.xlsx", "deck.pptx", "doc.odt"] {
221 assert_eq!(classify_path(&p(f)), IngestKind::Binary, "{f}");
222 assert!(!is_ingestible(&p(f)), "{f} must not ingest");
223 }
224 }
225
226 #[test]
227 fn unknown_extension_is_sniffed() {
228 let dir = tempfile::tempdir().unwrap();
229
230 let textual = dir.path().join("mystery.weirdext");
231 std::fs::write(&textual, "just normal text content\nwith lines\n").unwrap();
232 assert_eq!(classify_path(&textual), IngestKind::Text);
233 assert!(is_ingestible(&textual));
234
235 let binary = dir.path().join("blob.weirdext");
236 let mut f = std::fs::File::create(&binary).unwrap();
237 f.write_all(&[0u8, 1, 2, 3, 0, 255, 254]).unwrap();
238 assert_eq!(classify_path(&binary), IngestKind::Binary);
239 assert!(!is_ingestible(&binary));
240 }
241
242 #[test]
243 fn no_extension_textual_file_ingests() {
244 let dir = tempfile::tempdir().unwrap();
245 let readme = dir.path().join("LICENSE");
246 std::fs::write(&readme, "MIT License\n\nPermission is hereby granted\n").unwrap();
247 assert_eq!(classify_path(&readme), IngestKind::Text);
248 }
249}