lean_ctx/core/extractors/
mod.rs1#![allow(clippy::unnecessary_literal_bound)]
24
25pub mod csv;
26pub mod eml;
27pub mod json;
28
29use std::path::Path;
30use std::sync::Arc;
31
32use super::extension_registry::{Chunker, ExtensionRegistry};
33
34#[derive(Debug, Clone)]
37pub struct Extracted {
38 pub kind: &'static str,
39 pub text: String,
40 pub chunks: Vec<String>,
41}
42
43#[must_use]
47pub fn extract(path: &Path, bytes: &[u8]) -> Extracted {
48 let ext = path
49 .extension()
50 .and_then(|e| e.to_str())
51 .unwrap_or_default()
52 .to_ascii_lowercase();
53
54 match ext.as_str() {
55 "json" | "jsonl" | "ndjson" => {
56 let s = String::from_utf8_lossy(bytes);
57 Extracted {
58 kind: "json",
59 text: json::to_text(&s),
60 chunks: json::chunks(&s),
61 }
62 }
63 "csv" | "tsv" => {
64 let s = String::from_utf8_lossy(bytes);
65 let delim = if ext == "tsv" { '\t' } else { ',' };
66 Extracted {
67 kind: "csv",
68 text: csv::to_text(&s, delim),
69 chunks: csv::chunks(&s, delim),
70 }
71 }
72 "eml" => {
73 let s = String::from_utf8_lossy(bytes);
74 Extracted {
75 kind: "eml",
76 text: eml::to_text(&s),
77 chunks: eml::chunks(&s),
78 }
79 }
80 "html" | "htm" | "xhtml" => {
81 let s = String::from_utf8_lossy(bytes);
82 let doc = super::web::html_to_text::parse(&s);
83 Extracted {
84 kind: "html",
85 chunks: paragraph_chunks(&doc.markdown),
86 text: doc.markdown,
87 }
88 }
89 "pdf" => match super::web::pdf::extract_text(bytes) {
90 Ok(text) => Extracted {
91 kind: "pdf",
92 chunks: paragraph_chunks(&text),
93 text,
94 },
95 Err(e) => Extracted {
96 kind: "pdf",
97 text: String::new(),
98 chunks: vec![format!("[pdf extraction failed: {e}]")],
99 },
100 },
101 _ => {
102 let s = String::from_utf8_lossy(bytes).to_string();
103 Extracted {
104 kind: "text",
105 chunks: paragraph_chunks(&s),
106 text: s,
107 }
108 }
109 }
110}
111
112#[must_use]
119pub fn is_binary_document(path: &Path) -> bool {
120 matches!(
121 path.extension()
122 .and_then(|e| e.to_str())
123 .map(str::to_ascii_lowercase)
124 .as_deref(),
125 Some("pdf")
126 )
127}
128
129#[must_use]
132pub fn paragraph_chunks(text: &str) -> Vec<String> {
133 text.split("\n\n")
134 .map(str::trim)
135 .filter(|s| !s.is_empty())
136 .map(String::from)
137 .collect()
138}
139
140pub fn register_into(reg: &mut ExtensionRegistry) {
144 reg.register_chunker(Arc::new(FormatChunker {
145 name: "csv",
146 f: |s| csv::chunks(s, ','),
147 }));
148 reg.register_chunker(Arc::new(FormatChunker {
149 name: "json",
150 f: json::chunks,
151 }));
152 reg.register_chunker(Arc::new(FormatChunker {
153 name: "eml",
154 f: eml::chunks,
155 }));
156 reg.register_chunker(Arc::new(FormatChunker {
157 name: "html",
158 f: |s| paragraph_chunks(&super::web::html_to_text::parse(s).markdown),
159 }));
160}
161
162struct FormatChunker {
164 name: &'static str,
165 f: fn(&str) -> Vec<String>,
166}
167
168impl Chunker for FormatChunker {
169 fn name(&self) -> &str {
170 self.name
171 }
172 fn chunk(&self, input: &str) -> Vec<String> {
173 (self.f)(input)
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn dispatches_json_by_extension() {
183 let e = extract(Path::new("data.json"), br#"[{"a":1}]"#);
184 assert_eq!(e.kind, "json");
185 assert_eq!(e.chunks.len(), 1);
186 }
187
188 #[test]
189 fn dispatches_csv_and_tsv() {
190 let csv = extract(Path::new("t.csv"), b"a,b\n1,2");
191 assert_eq!(csv.kind, "csv");
192 assert!(csv.text.contains("a: 1 | b: 2"));
193 let tsv = extract(Path::new("t.tsv"), b"a\tb\n1\t2");
194 assert!(tsv.text.contains("a: 1 | b: 2"));
195 }
196
197 #[test]
198 fn dispatches_html_to_markdown() {
199 let e = extract(Path::new("p.html"), b"<h1>Title</h1><p>Body</p>");
200 assert_eq!(e.kind, "html");
201 assert!(e.text.contains("Title"));
202 }
203
204 #[test]
205 fn unknown_extension_is_text_paragraphs() {
206 let e = extract(Path::new("notes.txt"), b"one\n\ntwo");
207 assert_eq!(e.kind, "text");
208 assert_eq!(e.chunks, vec!["one", "two"]);
209 }
210
211 #[test]
212 fn binary_document_predicate_matches_pdf_only() {
213 assert!(is_binary_document(Path::new("a.pdf")));
214 assert!(is_binary_document(Path::new("A.PDF")));
215 for f in ["p.html", "d.json", "t.csv", "m.eml", "n.txt", "s.rs"] {
216 assert!(!is_binary_document(Path::new(f)), "{f}");
217 }
218 }
219
220 #[test]
221 fn format_chunkers_register_and_run() {
222 let mut reg = ExtensionRegistry::new();
223 register_into(&mut reg);
224 for name in ["csv", "json", "eml", "html"] {
225 let c = reg
226 .chunker(name)
227 .unwrap_or_else(|| panic!("{name} missing"));
228 assert!(c.chunk("").is_empty(), "{name} empty input must be empty");
229 assert!(
230 !c.chunk("hello world").is_empty(),
231 "{name} non-empty input must chunk"
232 );
233 }
234 }
235}