1#![deny(
20 clippy::unwrap_used,
21 clippy::expect_used,
22 clippy::indexing_slicing,
23 clippy::panic,
24 clippy::unreachable
25)]
26
27use std::path::Path;
28
29use tokio::io::AsyncReadExt;
30
31const MAX_READ: u64 = 4 * 1024 * 1024;
33const MAX_TEXT: usize = 200_000;
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37enum Kind {
38 QuotedStrings,
39 PlainText,
41 Geometry,
43 Pdf,
45 Html,
47 None,
48}
49
50fn kind_of(filename: &str) -> Kind {
51 let ext = filename
52 .rsplit_once('.')
53 .map(|(_, e)| e.to_ascii_lowercase())
54 .unwrap_or_default();
55 match ext.as_str() {
56 "pdf" => Kind::Pdf,
57 "html" | "htm" => Kind::Html,
58 "stl" | "obj" | "kicad_sch" | "kicad_pcb" | "kicad_sym" | "kicad_mod" => Kind::Geometry,
59 "step" | "stp" | "p21" | "iges" | "igs" => Kind::QuotedStrings,
60 "txt" | "md" | "csv" | "tsv" | "json" | "xml" | "yaml" | "yml" | "toml" | "ini" | "cfg"
61 | "log" | "kicad_pro" | "net" | "sch" | "plmxml" | "scad" | "eng" | "rse" | "tex"
62 | "rst" | "py" | "rs" | "c" | "h" | "cpp" | "hpp" | "m"
63 | "out" | "trn" | "f06" | "f04" | "pch" | "jou" | "java" => Kind::PlainText,
66 _ => Kind::None,
67 }
68}
69
70pub fn indexes(filename: &str) -> bool {
72 kind_of(filename) != Kind::None
73}
74
75pub fn extract(filename: &str, content: &[u8]) -> String {
77 let text = match kind_of(filename) {
78 Kind::None | Kind::Pdf | Kind::Html => return String::new(),
79 Kind::PlainText | Kind::Geometry
81 if content.contains(&0) && encoding_rs::Encoding::for_bom(content).is_none() =>
82 {
83 return String::new()
84 }
85 Kind::PlainText => return plain_text(&crate::htmltext::decode(content, false)),
86 Kind::Geometry => String::from_utf8_lossy(content)
89 .split_whitespace()
90 .filter(|w| !is_decimal_number(w))
91 .collect::<Vec<_>>()
92 .join(" "),
93 Kind::QuotedStrings => quoted_strings(&String::from_utf8_lossy(content)),
95 };
96 text.chars()
98 .map(|c| if c.is_control() { ' ' } else { c })
99 .take(MAX_TEXT)
100 .collect()
101}
102
103fn plain_text(text: &str) -> String {
108 let mut out = String::new();
109 let mut chars = 0usize;
110 let mut blank = 0;
111 for line in text.lines() {
112 let line: String = line
113 .chars()
114 .map(|c| if c.is_control() && c != '\t' { ' ' } else { c })
115 .collect();
116 let line = line.trim_end();
117 if line.is_empty() {
118 blank += 1;
119 if blank > 1 {
120 continue;
121 }
122 } else {
123 blank = 0;
124 }
125 out.push_str(line);
126 out.push('\n');
127 chars += line.chars().count() + 1;
128 if chars > MAX_TEXT {
129 break;
130 }
131 }
132 out.trim_matches('\n').chars().take(MAX_TEXT).collect()
133}
134
135fn is_decimal_number(word: &str) -> bool {
137 word.contains('.')
138 && word.chars().any(|c| c.is_ascii_digit())
139 && word
140 .chars()
141 .all(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E'))
142}
143
144pub async fn extract_file(path: &Path, filename: &str) -> String {
151 if kind_of(filename) == Kind::None {
152 return String::new();
153 }
154 let Ok(mut file) = tokio::fs::File::open(path).await else {
155 return String::new();
156 };
157 match kind_of(filename) {
158 Kind::Pdf => return crate::pdftext::extract_file(file).await,
159 Kind::Html => return crate::htmltext::extract_file(file).await,
160 _ => {}
161 }
162 let len = file.metadata().await.map(|m| m.len()).unwrap_or(0);
163 let mut content = Vec::new();
164 if (&mut file)
165 .take(MAX_READ)
166 .read_to_end(&mut content)
167 .await
168 .is_err()
169 {
170 return String::new();
171 }
172 let tail = if kind_of(filename) == Kind::PlainText && len > 2 * TAIL_READ {
173 read_tail(&mut file, TAIL_READ).await
174 } else {
175 Vec::new()
176 };
177 let filename = filename.to_string();
180 tokio::task::spawn_blocking(move || {
181 let text = extract(&filename, &content);
182 let cut = len > MAX_READ || text.chars().count() >= MAX_TEXT;
183 if cut && tail_usable(&content, &text, &tail) {
184 with_tail(text, &tail)
185 } else {
186 text
187 }
188 })
189 .await
190 .unwrap_or_default()
191}
192
193const TAIL_READ: u64 = 64 * 1024;
195
196fn tail_usable(head: &[u8], head_text: &str, tail: &[u8]) -> bool {
202 let same_encoding = encoding_rs::Encoding::for_bom(head)
203 .is_none_or(|(encoding, _)| encoding == encoding_rs::UTF_8);
204 !tail.is_empty() && !head_text.is_empty() && !tail.contains(&0) && same_encoding
205}
206
207pub const TAIL_MARK: &str = "\n[… middle of the file left out …]\n";
209
210fn with_tail(head_text: String, tail: &[u8]) -> String {
214 let tail_text = plain_text(&crate::htmltext::decode(tail, false));
215 let tail_chars = tail_text.chars().count();
216 let keep = MAX_TEXT / 4;
217 let tail_text: String = tail_text
218 .chars()
219 .skip(tail_chars.saturating_sub(keep))
220 .collect();
221 if tail_text.is_empty() {
222 return head_text;
223 }
224 let room = MAX_TEXT
225 .saturating_sub(tail_text.chars().count())
226 .saturating_sub(TAIL_MARK.chars().count());
227 let mut out: String = head_text.chars().take(room).collect();
228 out.push_str(TAIL_MARK);
229 out.push_str(&tail_text);
230 out
231}
232
233pub async fn read_tail(file: &mut tokio::fs::File, max: u64) -> Vec<u8> {
238 use tokio::io::AsyncSeekExt;
239 let Ok(len) = file.metadata().await.map(|m| m.len()) else {
240 return Vec::new();
241 };
242 let start = len.saturating_sub(max);
243 if file.seek(std::io::SeekFrom::Start(start)).await.is_err() {
244 return Vec::new();
245 }
246 let mut tail = Vec::new();
247 if file.take(max).read_to_end(&mut tail).await.is_err() {
248 return Vec::new();
249 }
250 if start == 0 {
251 return tail;
252 }
253 from_next_line(tail)
254}
255
256fn from_next_line(bytes: Vec<u8>) -> Vec<u8> {
259 match bytes.iter().position(|b| *b == b'\n') {
260 Some(at) => bytes.get(at + 1..).unwrap_or_default().to_vec(),
261 None => Vec::new(),
262 }
263}
264
265fn quoted_strings(text: &str) -> String {
267 let mut out = String::new();
268 let mut chars = text.chars().peekable();
269 while let Some(c) = chars.next() {
270 if c != '\'' {
271 continue;
272 }
273 let mut current = String::new();
274 loop {
275 match chars.next() {
276 Some('\'') if chars.peek() == Some(&'\'') => {
277 chars.next();
278 current.push('\'');
279 }
280 Some('\'') | None => break,
281 Some(ch) => current.push(ch),
282 }
283 }
284 let current = current.trim();
285 if !current.is_empty() && current.chars().any(char::is_alphabetic) {
286 if !out.is_empty() {
287 out.push(' ');
288 }
289 out.push_str(current);
290 }
291 }
292 out
293}
294
295pub fn prefix_query(input: &str) -> Option<String> {
298 let terms: Vec<String> = input
299 .split(|c: char| !c.is_alphanumeric())
300 .filter(|t| !t.is_empty())
301 .take(12)
302 .map(|t| format!("{}:*", t.to_lowercase()))
303 .collect();
304 (!terms.is_empty()).then(|| terms.join(" & "))
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 const STEP: &str = "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION(('Engine mount bracket'),'2;1');\n\
312 FILE_NAME('nx-eng-001.step','2026-11-03T10:00:00',('Erika'),('Example Lab'),'','','');\nENDSEC;\nDATA;\n\
313 #10=PRODUCT('NX-ENG-001','Engine mount','',(#20));\n#11=CARTESIAN_POINT('',(0.,1.5,2.));\n\
314 #12=PRODUCT('M6','Bolt M6x20 DIN 912','It''s steel',(#20));\nENDSEC;\nEND-ISO-10303-21;";
315
316 #[test]
317 fn step_names_are_extracted() {
318 let text = extract("Mount.STEP", STEP.as_bytes());
319 assert!(text.contains("Engine mount bracket"));
320 assert!(text.contains("Bolt M6x20 DIN 912"));
321 assert!(text.contains("It's steel"), "escaped quotes");
322 assert!(text.contains("Example Lab"));
323 assert!(!text.contains("1.5"), "geometry is not indexed");
324 }
325
326 #[test]
327 fn text_and_binary_files() {
328 assert_eq!(
329 extract("notes.md", b"Thrust 2 kN, DIN 912\r\nburn time 4.2 s"),
330 "Thrust 2 kN, DIN 912\nburn time 4.2 s",
331 "text keeps its lines and decimal numbers"
332 );
333 let stl = b"solid hub\n facet normal 0.0 0.0 1.0\n outer loop\n vertex 1.5e+01 -2.25 0.0\n endloop\n endfacet\nendsolid hub";
334 assert_eq!(
335 extract("hub.stl", stl),
336 "solid hub facet normal outer loop vertex endloop endfacet endsolid hub"
337 );
338 assert!(
339 is_decimal_number("1.000000e+00")
340 && !is_decimal_number("M6")
341 && !is_decimal_number("912")
342 );
343 let mut binary_stl = b"solid exported by CAD".to_vec();
344 binary_stl.resize(84, 0);
345 binary_stl.extend_from_slice(&[0x00, 0x00, 0x80, 0x3f, 0x01, 0x00]);
346 assert_eq!(extract("hub.stl", &binary_stl), "", "binary STL");
347 assert_eq!(
348 extract("part.step", b"#1=PRODUCT('Hub\0\x07 v2','',(#2));"),
349 "Hub v2",
350 "no NUL or control characters reach the database"
351 );
352 assert_eq!(extract("drawing.pdf", b"%PDF-1.7 binary"), "");
353 assert_eq!(extract("no_extension", b"text"), "");
354 }
355
356 #[test]
357 fn which_files_are_indexed() {
358 for name in [
359 "a.STEP",
360 "a.stp",
361 "a.p21",
362 "a.IGES",
363 "a.igs",
364 "notes.TXT",
365 "bom.csv",
366 "x.kicad_sch",
367 "motor.eng",
368 "part.scad",
369 "data.plmxml",
370 "report.pdf",
371 ] {
372 assert!(indexes(name), "{name}");
373 }
374 for name in ["a.sldprt", "a.png", "README", "a.step.gz"] {
375 assert!(!indexes(name), "{name}");
376 }
377 }
378
379 #[test]
380 fn iges_and_quoted_string_rules() {
381 assert_eq!(
383 extract("a.igs", b"'1.5' ' ' 'Flange DN50' '1.4404 steel' '42'"),
384 "Flange DN50 1.4404 steel"
385 );
386 assert_eq!(extract("a.stp", b"#1=PRODUCT('Hub"), "Hub");
388 assert_eq!(extract("a.stp", b"(' Bolt ')"), "Bolt");
390 }
391
392 #[test]
393 fn plain_text_is_the_real_text() {
394 assert_eq!(
395 extract("a.csv", b"M6;912;-0.5;1e5;3.;v1.2"),
396 "M6;912;-0.5;1e5;3.;v1.2"
397 );
398 assert_eq!(
399 extract("a.txt", b"len 12.5 mm -3.0 +1.0e-3 ISO 4762"),
400 "len 12.5 mm -3.0 +1.0e-3 ISO 4762",
401 "a report keeps its numbers"
402 );
403 assert_eq!(
404 extract("summary.tsv", b"case\tdp_pa \n\n\n\nA\t388.25\x07\n"),
405 "case\tdp_pa\n\nA\t388.25",
406 "tabs stay, blank runs collapse, control characters go"
407 );
408 assert_eq!(extract("a.txt", b""), "");
409 assert_eq!(
410 extract("a.txt", "Düse \u{fffd}".as_bytes()),
411 "Düse \u{fffd}",
412 "non-ASCII text is kept"
413 );
414 assert_eq!(extract("a.log", b"85\xB0C"), "85°C", "Windows-1252");
415 assert_eq!(
416 extract(
417 "solve.log",
418 &["T = 85 °C, 3 µm, Düse\n".as_bytes(), b"\xFF step 12\n"].concat()
419 ),
420 "T = 85 °C, 3 µm, Düse\n\u{fffd} step 12",
421 "UTF-8 with one stray byte stays UTF-8"
422 );
423 let mut utf16 = vec![0xFF, 0xFE];
424 for u in "p 1.5".encode_utf16() {
425 utf16.extend_from_slice(&u.to_le_bytes());
426 }
427 assert_eq!(extract("a.txt", &utf16), "p 1.5", "UTF-16 with BOM");
428 assert_eq!(extract("a.txt", b"a\0b"), "", "binary content");
429 }
430
431 #[test]
432 fn geometry_text_drops_decimal_numbers() {
433 assert_eq!(
434 extract("board.kicad_pcb", b"(at 12.5 -3.0) (layer F.Cu) DIN 912"),
435 "(at -3.0) (layer F.Cu) DIN 912"
436 );
437 assert!(!is_decimal_number("1.2.3a") && is_decimal_number("1.2.3"));
438 assert!(!is_decimal_number("-") && !is_decimal_number("."));
439 }
440
441 #[test]
442 fn html_is_not_read_in_process() {
443 assert_eq!(extract("report.html", b"<p>1.5</p>"), "");
444 assert!(indexes("report.HTM") && indexes("r.html"));
445 }
446
447 #[test]
448 fn extracted_text_is_bounded() {
449 let big = "word ".repeat(MAX_TEXT);
450 assert_eq!(extract("a.txt", big.as_bytes()).chars().count(), MAX_TEXT);
451 let lines = "word\n".repeat(MAX_TEXT);
452 assert_eq!(extract("a.txt", lines.as_bytes()).chars().count(), MAX_TEXT);
453 assert_eq!(extract("a.stl", big.as_bytes()).chars().count(), MAX_TEXT);
454 }
455
456 #[tokio::test]
457 async fn stored_files_are_read() {
458 let path = std::env::temp_dir().join(format!("teamcraft-text-{}", uuid::Uuid::new_v4()));
459 std::fs::write(&path, STEP).unwrap();
460 assert!(extract_file(&path, "m.step").await.contains("Engine mount"));
461 assert_eq!(
462 extract_file(&path, "m.bin").await,
463 "",
464 "not indexed by name"
465 );
466 std::fs::remove_file(&path).ok();
467 assert_eq!(extract_file(&path, "m.step").await, "", "missing file");
468 }
469
470 #[test]
471 fn prefix_queries_are_bounded() {
472 let many = (0..20)
473 .map(|i| format!("w{i}"))
474 .collect::<Vec<_>>()
475 .join(" ");
476 let q = prefix_query(&many).unwrap();
477 assert_eq!(q.matches(":*").count(), 12, "at most twelve terms");
478 assert_eq!(
479 prefix_query("A:B"),
480 Some("a:* & b:*".into()),
481 "no tsquery syntax passes"
482 );
483 assert_eq!(prefix_query(""), None);
484 }
485
486 #[test]
487 fn prefix_queries() {
488 assert_eq!(prefix_query("Flan M6"), Some("flan:* & m6:*".into()));
489 assert_eq!(
490 prefix_query("nx-eng-001"),
491 Some("nx:* & eng:* & 001:*".into())
492 );
493 assert_eq!(prefix_query(" ' & | ! "), None);
494 assert_eq!(prefix_query("Düse"), Some("düse:*".into()));
495 }
496
497 #[test]
498 fn solver_text_files_are_plain_text() {
499 for name in [
500 "run.trn",
501 "x.F06",
502 "x.f04",
503 "x.pch",
504 "run.jou",
505 "Macro.java",
506 "report-file-0.out",
507 ] {
508 assert!(indexes(name), "{name}");
509 }
510 assert_eq!(
511 extract("run.trn", b" 500 1.234e-05\n"),
512 " 500 1.234e-05"
513 );
514 assert_eq!(extract("a.out", b"\x7fELF\x02\x01\x00\x00"), "", "binary");
515 }
516
517 #[test]
518 fn the_end_of_a_large_text_follows_the_beginning() {
519 let head = "a".repeat(MAX_TEXT);
520 let out = with_tail(head, b"Iteration 500\nsolution is converged\n");
521 assert_eq!(out.chars().count(), MAX_TEXT);
522 assert!(out.ends_with(&format!("{TAIL_MARK}Iteration 500\nsolution is converged")));
523 let long_tail = format!("{}\nlast line", "b".repeat(MAX_TEXT / 2));
525 let out = with_tail("head".into(), long_tail.as_bytes());
526 assert!(out.starts_with("head\n[…"));
527 assert!(out.ends_with("last line"));
528 assert!(out.chars().count() <= MAX_TEXT);
529 assert_eq!(with_tail("head".into(), b""), "head");
531 assert_eq!(from_next_line(b"cut line\nwhole\n".to_vec()), b"whole\n");
532 assert!(from_next_line(b"no line break".to_vec()).is_empty());
533 }
534
535 #[test]
536 fn the_end_follows_only_a_text_beginning_in_the_same_encoding() {
537 assert!(tail_usable(b"Iteration 1\n", "Iteration 1", b"converged\n"));
538 assert!(
539 tail_usable(b"\xEF\xBB\xBFlog\n", "log", b"converged\n"),
540 "UTF-8 BOM"
541 );
542 assert!(!tail_usable(b"\x7fELF\x02\x01\x00", "", b"converged\n"));
544 assert!(!tail_usable(b"log\n", "log", b"end\x00\x00\x00"));
546 assert!(!tail_usable(b"\xFF\xFEl\x00", "l", b"e\x00n\x00d\x00"));
548 assert!(!tail_usable(b"\xFE\xFF\x00l", "l", b"end"));
549 assert!(!tail_usable(b"log\n", "log", b""));
550 }
551
552 #[tokio::test]
553 async fn read_tail_starts_at_a_line_and_reads_at_most_max() {
554 let dir = std::env::temp_dir().join(format!("tc-tail-{}", std::process::id()));
555 std::fs::create_dir_all(&dir).unwrap();
556 let path = dir.join("run.trn");
557 std::fs::write(&path, b"first line\nsecond line\nthird\n").unwrap();
558 let open = || async { tokio::fs::File::open(&path).await.unwrap() };
559 assert_eq!(read_tail(&mut open().await, 12).await, b"third\n");
560 assert_eq!(
561 read_tail(&mut open().await, 1000).await,
562 b"first line\nsecond line\nthird\n"
563 );
564 let mut big = "x 1.5\n".repeat(1_000_000);
566 big.push_str("solution is converged\n");
567 std::fs::write(&path, big.as_bytes()).unwrap();
568 let text = extract_file(&path, "run.trn").await;
569 assert!(text.starts_with("x 1.5\n"));
570 assert!(
571 text.ends_with(&format!("{}solution is converged", "")),
572 "{}",
573 &text[text.len() - 40..]
574 );
575 assert!(text.contains(TAIL_MARK));
576 assert!(text.chars().count() <= MAX_TEXT);
577 std::fs::remove_dir_all(&dir).unwrap();
578 }
579}