1use crate::{Edge, EdgeKind, FactSet, Node, NodeKind, Span};
16
17pub(crate) const EXTRACT_VERSION: u32 = 3
29 + if cfg!(feature = "pdf-text") { 100 } else { 0 }
30 + if cfg!(feature = "image-ocr") { 200 } else { 0 }
31 + if cfg!(feature = "image-vision") {
32 400
33 } else {
34 0
35 };
36
37const MAX_CONTENT: usize = 1500;
41
42#[cfg(feature = "pdf-text")]
45const MAX_PDF_BYTES: usize = 20 * 1024 * 1024;
46
47#[cfg(any(feature = "image-ocr", feature = "image-vision"))]
49const MAX_IMAGE_BYTES: usize = 20 * 1024 * 1024;
50
51#[cfg(any(feature = "image-ocr", feature = "image-vision"))]
55const MAX_IMAGE_PIXELS: u64 = 4096 * 4096;
56
57#[cfg(feature = "image-vision")]
61const MIN_OCR_WORDS: usize = 8;
62
63pub trait Extractor {
65 fn extract(&self, path: &str, blob_id: &str, bytes: &[u8]) -> FactSet;
70
71 fn env_tag(&self) -> u64 {
78 image_env_tag()
79 }
80}
81
82#[allow(clippy::struct_excessive_bools)]
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub struct IngestConfig {
92 pub prose: bool,
94 pub pdf: bool,
96 pub ocr: bool,
98 pub vision: bool,
100}
101
102impl Default for IngestConfig {
103 fn default() -> Self {
104 Self {
105 prose: true,
106 pdf: true,
107 ocr: true,
108 vision: true,
109 }
110 }
111}
112
113impl IngestConfig {
114 fn disabled_bits(self) -> u64 {
119 u64::from(!self.prose)
120 | (u64::from(!self.pdf) << 1)
121 | (u64::from(!self.ocr) << 2)
122 | (u64::from(!self.vision) << 3)
123 }
124}
125
126#[derive(Debug, Clone, Copy, Default)]
132pub struct Registry {
133 pub ingest: IngestConfig,
135}
136
137impl Registry {
138 #[must_use]
140 pub fn new(ingest: IngestConfig) -> Self {
141 Self { ingest }
142 }
143}
144
145impl Extractor for Registry {
146 fn extract(&self, path: &str, blob_id: &str, bytes: &[u8]) -> FactSet {
147 let mut facts = extract_facts(path, blob_id, bytes, self.ingest);
148 crate::markers::augment(&mut facts, path, blob_id, bytes);
149 facts
150 }
151
152 fn env_tag(&self) -> u64 {
153 let img = image_env_tag();
154 let disabled = self.ingest.disabled_bits();
155 if disabled == 0 {
156 img
158 } else {
159 let mut h = 0xcbf2_9ce4_8422_2325u64;
164 for b in img.to_le_bytes().into_iter().chain(disabled.to_le_bytes()) {
165 h ^= u64::from(b);
166 h = h.wrapping_mul(0x0000_0100_0000_01b3);
167 }
168 h
169 }
170 }
171}
172
173fn extract_facts(path: &str, blob_id: &str, bytes: &[u8], ingest: IngestConfig) -> FactSet {
177 match extension(path).as_deref() {
178 Some("rs") => rust_facts(path, blob_id, bytes, ingest),
179 _ => FactSet::new().with_node(file_node(path, blob_id, bytes, None, ingest)),
180 }
181}
182
183fn extension(path: &str) -> Option<String> {
186 let name = path.rsplit('/').next().unwrap_or(path);
187 name.rsplit_once('.')
188 .map(|(_, ext)| ext.to_ascii_lowercase())
189}
190
191fn file_key(path: &str) -> String {
193 format!("file:{path}")
194}
195
196fn file_node(
200 path: &str,
201 blob_id: &str,
202 bytes: &[u8],
203 lang: Option<&str>,
204 ingest: IngestConfig,
205) -> Node {
206 let name = path.rsplit('/').next().unwrap_or(path).to_owned();
207 let lines = bytes
208 .iter()
209 .fold(0usize, |n, &b| n + usize::from(b == b'\n'));
210 let end = u32::try_from(bytes.len()).unwrap_or(u32::MAX);
211 let mut meta = serde_json::json!({ "bytes": bytes.len(), "lines": lines });
212 let content = if ingest.prose && is_prose(path) {
217 cap_content(&String::from_utf8_lossy(bytes))
218 } else if let Some(text) = ingest.pdf.then(|| pdf_content(path, bytes)).flatten() {
219 cap_content(&text)
220 } else if let Some(text) = image_content(path, bytes, ingest) {
221 cap_content(&text)
222 } else {
223 String::new()
224 };
225 if !content.is_empty() {
226 meta["content"] = serde_json::Value::from(content);
227 }
228 Node {
229 key: file_key(path),
230 kind: NodeKind::File,
231 name,
232 path: Some(path.to_owned()),
233 lang: lang.map(ToOwned::to_owned),
234 blob_hash: Some(blob_id.to_owned()),
235 span: Some(Span::new(0, end)),
236 meta,
237 }
238}
239
240fn doc_comment_body(raw: &str) -> Option<String> {
244 let t = raw.trim();
245 if t.starts_with("//!") || (t.starts_with("///") && !t.starts_with("////")) {
246 return Some(t[3..].trim().to_owned());
247 }
248 if (t.starts_with("/**") || t.starts_with("/*!")) && t.ends_with("*/") {
249 let end = t.len() - 2;
253 let inner = if end >= 3 { &t[3..end] } else { "" };
254 let cleaned: Vec<&str> = inner
255 .lines()
256 .map(|l| l.trim().trim_start_matches('*').trim())
257 .filter(|l| !l.is_empty())
258 .collect();
259 return Some(cleaned.join(" "));
260 }
261 None
262}
263
264#[cfg(feature = "pdf-text")]
272fn pdf_content(path: &str, bytes: &[u8]) -> Option<String> {
273 if extension(path).as_deref() != Some("pdf") || bytes.len() > MAX_PDF_BYTES {
274 return None;
275 }
276 let owned = bytes.to_vec();
277 let text = std::panic::catch_unwind(move || pdf_extract::extract_text_from_mem(&owned).ok())
278 .ok()
279 .flatten()?;
280 (!text.trim().is_empty()).then_some(text)
281}
282
283#[cfg(not(feature = "pdf-text"))]
285fn pdf_content(_path: &str, _bytes: &[u8]) -> Option<String> {
286 None
287}
288
289#[cfg(any(feature = "image-ocr", feature = "image-vision"))]
299fn image_content(path: &str, bytes: &[u8], ingest: IngestConfig) -> Option<String> {
300 if !is_image(path) || bytes.len() > MAX_IMAGE_BYTES {
301 return None;
302 }
303 let ocr = if ingest.ocr { ocr_content(bytes) } else { None };
309 let sparse = ocr
310 .as_deref()
311 .is_none_or(|t| t.split_whitespace().count() < min_ocr_words());
312 let vision = if ingest.vision && sparse {
313 vlm_content(bytes)
314 } else {
315 None
316 };
317 match (ocr, vision) {
318 (Some(o), Some(v)) => Some(format!("{o}\n\n{v}")),
319 (Some(o), None) => Some(o),
320 (None, Some(v)) => Some(v),
321 (None, None) => None,
322 }
323}
324
325#[cfg(not(any(feature = "image-ocr", feature = "image-vision")))]
327fn image_content(_path: &str, _bytes: &[u8], _ingest: IngestConfig) -> Option<String> {
328 None
329}
330
331#[cfg(any(feature = "image-ocr", feature = "image-vision"))]
334fn min_ocr_words() -> usize {
335 #[cfg(feature = "image-vision")]
336 {
337 MIN_OCR_WORDS
338 }
339 #[cfg(not(feature = "image-vision"))]
340 {
341 usize::MAX
342 }
343}
344
345#[cfg(any(feature = "image-ocr", feature = "image-vision"))]
347fn is_image(path: &str) -> bool {
348 matches!(extension(path).as_deref(), Some("png" | "jpg" | "jpeg"))
349}
350
351#[cfg(any(feature = "image-ocr", feature = "image-vision"))]
356fn image_dimensions_ok(bytes: &[u8]) -> bool {
357 let Ok(reader) = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format()
358 else {
359 return false;
360 };
361 match reader.into_dimensions() {
362 Ok((w, h)) => u64::from(w) * u64::from(h) <= MAX_IMAGE_PIXELS,
363 Err(_) => false,
364 }
365}
366
367#[cfg(feature = "image-ocr")]
371fn ocr_content(bytes: &[u8]) -> Option<String> {
372 let dir = crate::models::model_dir("ocrs-text");
373 let detection = dir.join("text-detection.rten");
374 let recognition = dir.join("text-recognition.rten");
375 if !detection.exists() || !recognition.exists() || !image_dimensions_ok(bytes) {
376 return None;
378 }
379 let text = std::panic::catch_unwind(|| run_ocr(&detection, &recognition, bytes))
382 .ok()
383 .flatten()?;
384 (!text.trim().is_empty()).then_some(text)
385}
386
387#[cfg(not(feature = "image-ocr"))]
388fn ocr_content(_bytes: &[u8]) -> Option<String> {
389 None
390}
391
392#[cfg(feature = "image-ocr")]
395fn run_ocr(
396 detection: &std::path::Path,
397 recognition: &std::path::Path,
398 bytes: &[u8],
399) -> Option<String> {
400 use ocrs::{ImageSource, OcrEngine, OcrEngineParams};
401
402 let detection_model = rten::Model::load_file(detection).ok()?;
403 let recognition_model = rten::Model::load_file(recognition).ok()?;
404 let engine = OcrEngine::new(OcrEngineParams {
405 detection_model: Some(detection_model),
406 recognition_model: Some(recognition_model),
407 ..Default::default()
408 })
409 .ok()?;
410
411 let img = image::load_from_memory(bytes).ok()?.into_rgb8();
412 let source = ImageSource::from_bytes(img.as_raw(), img.dimensions()).ok()?;
413 let input = engine.prepare_input(source).ok()?;
414 engine.get_text(&input).ok()
415}
416
417#[cfg(feature = "image-vision")]
426fn vlm_content(bytes: &[u8]) -> Option<String> {
427 let dir = crate::models::model_dir("moondream2");
428 if !dir.join("model.gguf").exists()
429 || !dir.join("tokenizer.json").exists()
430 || !image_dimensions_ok(bytes)
431 {
432 return None;
434 }
435 let mut vlm = crate::localmodel::LocalVlm::load(&dir).ok()?;
436 let text = vlm.describe(bytes).ok()?;
437 (!text.trim().is_empty()).then_some(text)
438}
439
440#[cfg(not(feature = "image-vision"))]
441fn vlm_content(_bytes: &[u8]) -> Option<String> {
442 None
443}
444
445#[cfg(any(feature = "image-ocr", feature = "image-vision"))]
452pub(crate) fn image_env_tag() -> u64 {
453 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
454 let mut any = false;
455 #[cfg(feature = "image-ocr")]
456 {
457 any |= fold_installed_model(&mut hash, "ocrs-text");
458 }
459 #[cfg(feature = "image-vision")]
460 {
461 any |= fold_installed_model(&mut hash, "moondream2");
462 }
463 if any { hash | 1 } else { 0 }
464}
465
466#[cfg(any(feature = "image-ocr", feature = "image-vision"))]
470fn fold_installed_model(hash: &mut u64, name: &str) -> bool {
471 let Some(variant) = crate::models::find(name)
472 .and_then(|spec| spec.variant_for(crate::models::Platform::host()))
473 else {
474 return false;
475 };
476 let dir = crate::models::model_dir(name);
477 if !variant.files.iter().all(|f| dir.join(f.name).exists()) {
478 return false;
479 }
480 for file in variant.files {
481 for b in file.sha256.bytes() {
482 *hash ^= u64::from(b);
483 *hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
484 }
485 }
486 true
487}
488
489#[cfg(not(any(feature = "image-ocr", feature = "image-vision")))]
491pub(crate) fn image_env_tag() -> u64 {
492 0
493}
494
495fn is_prose(path: &str) -> bool {
497 matches!(
498 extension(path).as_deref(),
499 Some("md" | "markdown" | "txt" | "rst" | "adoc")
500 )
501}
502
503fn cap_content(text: &str) -> String {
506 let mut out = String::with_capacity(text.len().min(MAX_CONTENT));
507 let mut chars = 0usize;
510 let mut last_was_space = true;
511 for c in text.chars() {
512 if chars >= MAX_CONTENT {
513 break;
514 }
515 if c.is_whitespace() {
516 if !last_was_space {
517 out.push(' ');
518 chars += 1;
519 last_was_space = true;
520 }
521 } else {
522 out.push(c);
523 chars += 1;
524 last_was_space = false;
525 }
526 }
527 out.trim().to_owned()
528}
529
530#[derive(Debug, Clone, Copy, Default)]
534pub struct FileNodeExtractor;
535
536impl Extractor for FileNodeExtractor {
537 fn extract(&self, path: &str, blob_id: &str, bytes: &[u8]) -> FactSet {
538 FactSet::new().with_node(file_node(
539 path,
540 blob_id,
541 bytes,
542 None,
543 IngestConfig::default(),
544 ))
545 }
546}
547
548#[derive(Debug, Clone, Copy, Default)]
554pub struct RustExtractor;
555
556impl Extractor for RustExtractor {
557 fn extract(&self, path: &str, blob_id: &str, bytes: &[u8]) -> FactSet {
558 rust_facts(path, blob_id, bytes, IngestConfig::default())
559 }
560}
561
562fn rust_facts(path: &str, blob_id: &str, bytes: &[u8], ingest: IngestConfig) -> FactSet {
565 let mut parser = tree_sitter::Parser::new();
566 if parser
569 .set_language(&tree_sitter_rust::LANGUAGE.into())
570 .is_err()
571 {
572 return FactSet::new().with_node(file_node(path, blob_id, bytes, None, ingest));
573 }
574 let Some(tree) = parser.parse(bytes, None) else {
575 return FactSet::new().with_node(file_node(path, blob_id, bytes, None, ingest));
576 };
577
578 let mut walk = RustWalk {
579 path,
580 blob_id,
581 src: bytes,
582 nodes: vec![file_node(path, blob_id, bytes, Some("rust"), ingest)],
583 edges: Vec::new(),
584 };
585 let root = tree.root_node();
586 let mut cursor = root.walk();
587 let children: Vec<_> = root.children(&mut cursor).collect();
588 for child in children {
589 walk.visit(child, &[]);
590 }
591
592 walk.nodes.sort_by(|a, b| a.key.cmp(&b.key));
595 walk.edges
596 .sort_by(|a, b| (a.kind.as_str(), &a.src, &a.dst).cmp(&(b.kind.as_str(), &b.src, &b.dst)));
597 FactSet {
598 nodes: walk.nodes,
599 edges: walk.edges,
600 }
601}
602
603struct Scope {
607 seg: String,
608 key: Option<String>,
609}
610
611struct RustWalk<'a> {
613 path: &'a str,
614 blob_id: &'a str,
615 src: &'a [u8],
616 nodes: Vec<Node>,
617 edges: Vec<Edge>,
618}
619
620impl RustWalk<'_> {
621 fn visit(&mut self, node: tree_sitter::Node, scope: &[Scope]) {
623 match node.kind() {
624 "function_item" => self.visit_symbol(node, scope, NodeKind::Fn, true),
625 "struct_item" | "union_item" => self.visit_symbol(node, scope, NodeKind::Struct, false),
626 "enum_item" => self.visit_symbol(node, scope, NodeKind::Enum, false),
627 "trait_item" => self.visit_symbol(node, scope, NodeKind::Trait, false),
628 "mod_item" => self.visit_symbol(node, scope, NodeKind::Module, false),
629 "type_item" => self.visit_symbol(node, scope, NodeKind::Other("type".into()), false),
630 "macro_definition" => {
631 self.visit_symbol(node, scope, NodeKind::Other("macro".into()), false);
632 }
633 "impl_item" => self.visit_impl(node, scope),
634 "use_declaration" => self.visit_use(node),
635 _ => self.visit_children(node, scope),
638 }
639 }
640
641 fn visit_children(&mut self, node: tree_sitter::Node, scope: &[Scope]) {
643 let mut cursor = node.walk();
644 let children: Vec<_> = node.named_children(&mut cursor).collect();
645 for child in children {
646 self.visit(child, scope);
647 }
648 }
649
650 fn visit_symbol(
653 &mut self,
654 node: tree_sitter::Node,
655 scope: &[Scope],
656 kind: NodeKind,
657 collect_calls: bool,
658 ) {
659 let Some(name) = self.field_text(node, "name") else {
660 return self.visit_children(node, scope);
661 };
662 let qualified = qualify(scope, &name);
663 let key = format!("sym:rust:{}#{qualified}", self.path);
664
665 let mut meta = serde_json::Map::new();
666 if collect_calls {
667 let mut calls = Vec::new();
668 self.collect_calls(node, &mut calls);
669 calls.sort();
670 calls.dedup();
671 if !calls.is_empty() {
672 meta.insert("calls".into(), serde_json::Value::from(calls));
673 }
674 }
675 if let Some(doc) = self.doc_comment(node) {
677 meta.insert("content".into(), serde_json::Value::from(doc));
678 }
679
680 self.nodes.push(Node {
681 key: key.clone(),
682 kind,
683 name,
684 path: Some(self.path.to_owned()),
685 lang: Some("rust".to_owned()),
686 blob_hash: Some(self.blob_id.to_owned()),
687 span: Some(span(node)),
688 meta: serde_json::Value::Object(meta),
689 });
690 self.link_parent(&key, scope);
691
692 let child_scope = extend(scope, &self.simple(node, "name"), Some(key));
695 self.recurse_body(node, &child_scope);
696 }
697
698 fn doc_comment(&self, node: tree_sitter::Node) -> Option<String> {
702 let mut parts: Vec<String> = Vec::new();
703 let mut prev = node.prev_sibling();
704 while let Some(n) = prev {
705 match n.kind() {
706 "line_comment" | "block_comment" => match doc_comment_body(self.text(n)) {
707 Some(body) => {
708 parts.push(body);
709 prev = n.prev_sibling();
710 }
711 None => break,
712 },
713 "attribute_item" => prev = n.prev_sibling(),
714 _ => break,
715 }
716 }
717 if parts.is_empty() {
718 return None;
719 }
720 parts.reverse();
721 let joined = cap_content(&parts.join(" "));
722 (!joined.is_empty()).then_some(joined)
723 }
724
725 fn visit_impl(&mut self, node: tree_sitter::Node, scope: &[Scope]) {
728 let type_name = self
729 .field_text(node, "type")
730 .unwrap_or_else(|| "impl".to_owned());
731 let child_scope = extend(scope, &type_name, None);
732 self.recurse_body(node, &child_scope);
733 }
734
735 fn visit_use(&mut self, node: tree_sitter::Node) {
738 let Some(arg) = node.child_by_field_name("argument") else {
739 return;
740 };
741 let text: String = self
742 .text(arg)
743 .chars()
744 .filter(|c| !c.is_whitespace())
745 .collect();
746 if text.is_empty() {
747 return;
748 }
749 let key = format!("import:rust:{text}");
750 self.nodes.push(Node {
751 key: key.clone(),
752 kind: NodeKind::Other("import".into()),
753 name: text,
754 path: None,
755 lang: Some("rust".to_owned()),
756 blob_hash: None,
757 span: None,
758 meta: serde_json::Value::Null,
759 });
760 self.edges
761 .push(Edge::derived(file_key(self.path), key, EdgeKind::Imports));
762 }
763
764 fn link_parent(&mut self, key: &str, scope: &[Scope]) {
767 if let Some(parent) = scope.iter().rev().find_map(|s| s.key.as_deref()) {
768 self.edges.push(Edge::derived(
769 parent.to_owned(),
770 key.to_owned(),
771 EdgeKind::Contains,
772 ));
773 } else {
774 self.edges.push(Edge::derived(
775 file_key(self.path),
776 key.to_owned(),
777 EdgeKind::Defines,
778 ));
779 }
780 }
781
782 fn recurse_body(&mut self, node: tree_sitter::Node, scope: &[Scope]) {
784 let mut cursor = node.walk();
785 let children: Vec<_> = node.named_children(&mut cursor).collect();
786 for child in children {
787 match child.kind() {
788 "declaration_list" | "field_declaration_list" | "trait_body" => {
789 self.visit_children(child, scope);
790 }
791 _ => {}
792 }
793 }
794 }
795
796 fn collect_calls(&self, node: tree_sitter::Node, out: &mut Vec<String>) {
799 let mut cursor = node.walk();
800 for child in node.named_children(&mut cursor) {
801 if child.kind() == "call_expression"
802 && let Some(func) = child.child_by_field_name("function")
803 && let Some(name) = self.callee_name(func)
804 {
805 out.push(name);
806 }
807 self.collect_calls(child, out);
808 }
809 }
810
811 fn callee_name(&self, func: tree_sitter::Node) -> Option<String> {
814 match func.kind() {
815 "identifier" => Some(self.text(func).to_owned()),
816 "scoped_identifier" => func
817 .child_by_field_name("name")
818 .map(|n| self.text(n).to_owned()),
819 "field_expression" => func
820 .child_by_field_name("field")
821 .map(|n| self.text(n).to_owned()),
822 _ => None,
823 }
824 }
825
826 fn text(&self, node: tree_sitter::Node) -> &str {
827 node.utf8_text(self.src).unwrap_or("")
828 }
829
830 fn field_text(&self, node: tree_sitter::Node, field: &str) -> Option<String> {
831 node.child_by_field_name(field)
832 .map(|n| self.text(n).to_owned())
833 }
834
835 fn simple(&self, node: tree_sitter::Node, field: &str) -> String {
836 self.field_text(node, field).unwrap_or_default()
837 }
838}
839
840fn span(node: tree_sitter::Node) -> Span {
842 let start = u32::try_from(node.start_byte()).unwrap_or(u32::MAX);
843 let end = u32::try_from(node.end_byte()).unwrap_or(u32::MAX);
844 Span::new(start, end)
845}
846
847fn qualify(scope: &[Scope], name: &str) -> String {
849 let mut parts: Vec<&str> = scope.iter().map(|s| s.seg.as_str()).collect();
850 parts.push(name);
851 parts.join("::")
852}
853
854fn extend(scope: &[Scope], seg: &str, key: Option<String>) -> Vec<Scope> {
856 let mut next: Vec<Scope> = scope
857 .iter()
858 .map(|s| Scope {
859 seg: s.seg.clone(),
860 key: s.key.clone(),
861 })
862 .collect();
863 next.push(Scope {
864 seg: seg.to_owned(),
865 key,
866 });
867 next
868}
869
870#[cfg(test)]
871mod tests {
872 use super::{Extractor, FileNodeExtractor, Registry, RustExtractor};
873 use crate::{EdgeKind, NodeKind};
874
875 #[test]
876 fn file_node_extractor_is_deterministic_and_tagged() {
877 let ex = FileNodeExtractor;
878 let a = ex.extract("src/lib.rs", "abc123", b"one\ntwo\n");
879 let b = ex.extract("src/lib.rs", "abc123", b"one\ntwo\n");
880 assert_eq!(a, b, "extraction must be deterministic");
881
882 assert_eq!(a.nodes.len(), 1);
883 assert!(a.edges.is_empty());
884 let node = &a.nodes[0];
885 assert_eq!(node.key, "file:src/lib.rs");
886 assert_eq!(node.kind, NodeKind::File);
887 assert_eq!(node.name, "lib.rs");
888 assert_eq!(node.blob_hash.as_deref(), Some("abc123"));
889 assert_eq!(node.meta["lines"], 2);
890 assert_eq!(node.meta["bytes"], 8);
891 }
892
893 const SAMPLE: &str = r"
894use std::path::Path;
895
896pub struct Store;
897
898impl Store {
899 pub fn open() -> Store {
900 helper();
901 Store
902 }
903}
904
905fn helper() {}
906
907mod inner {
908 pub fn nested() {}
909}
910";
911
912 fn keys(fs: &crate::FactSet) -> Vec<String> {
913 let mut k: Vec<_> = fs.nodes.iter().map(|n| n.key.clone()).collect();
914 k.sort();
915 k
916 }
917
918 #[test]
919 fn rust_extractor_emits_symbols_and_edges() {
920 let fs = RustExtractor.extract("src/lib.rs", "blob1", SAMPLE.as_bytes());
921 let ks = keys(&fs);
922 assert!(ks.contains(&"file:src/lib.rs".to_owned()));
923 assert!(ks.contains(&"sym:rust:src/lib.rs#Store".to_owned()));
924 assert!(ks.contains(&"sym:rust:src/lib.rs#Store::open".to_owned()));
925 assert!(ks.contains(&"sym:rust:src/lib.rs#helper".to_owned()));
926 assert!(ks.contains(&"sym:rust:src/lib.rs#inner".to_owned()));
927 assert!(ks.contains(&"sym:rust:src/lib.rs#inner::nested".to_owned()));
928
929 let open = fs
931 .nodes
932 .iter()
933 .find(|n| n.key == "sym:rust:src/lib.rs#Store::open")
934 .expect("open node");
935 assert_eq!(open.meta["calls"], serde_json::json!(["helper"]));
936
937 let defines: Vec<_> = fs
939 .edges
940 .iter()
941 .filter(|e| e.kind == EdgeKind::Defines && e.dst == "sym:rust:src/lib.rs#helper")
942 .collect();
943 assert_eq!(defines.len(), 1);
944 assert!(fs.edges.iter().any(|e| e.kind == EdgeKind::Contains
945 && e.src == "sym:rust:src/lib.rs#inner"
946 && e.dst == "sym:rust:src/lib.rs#inner::nested"));
947
948 assert!(fs.edges.iter().any(|e| e.kind == EdgeKind::Imports
950 && e.src == "file:src/lib.rs"
951 && e.dst == "import:rust:std::path::Path"));
952 }
953
954 #[test]
955 fn rust_extraction_is_deterministic() {
956 let a = RustExtractor.extract("src/lib.rs", "blob1", SAMPLE.as_bytes());
957 let b = RustExtractor.extract("src/lib.rs", "blob1", SAMPLE.as_bytes());
958 assert_eq!(a, b);
959 }
960
961 #[test]
962 fn rust_extractor_captures_doc_comments() {
963 let src = "/// The central store.\n\
964 pub struct Store;\n\n\
965 /// Opens it.\n\
966 /// Reads the config.\n\
967 pub fn open() {}\n\n\
968 // not a doc comment\n\
969 pub fn plain() {}\n";
970 let fs = RustExtractor.extract("src/lib.rs", "b", src.as_bytes());
971 let content = |key: &str| {
972 fs.nodes
973 .iter()
974 .find(|n| n.key == key)
975 .and_then(|n| n.meta.get("content"))
976 .and_then(|v| v.as_str())
977 .map(ToOwned::to_owned)
978 };
979 assert_eq!(
980 content("sym:rust:src/lib.rs#Store").as_deref(),
981 Some("The central store.")
982 );
983 assert_eq!(
984 content("sym:rust:src/lib.rs#open").as_deref(),
985 Some("Opens it. Reads the config.")
986 );
987 assert_eq!(content("sym:rust:src/lib.rs#plain"), None);
989 }
990
991 #[test]
992 fn prose_file_captures_capped_body() {
993 let md = FileNodeExtractor.extract("docs/x.md", "b", b"# Title\n\nSome prose here.\n");
994 assert_eq!(md.nodes[0].meta["content"], "# Title Some prose here.");
995 let rs = FileNodeExtractor.extract("notes.bin", "b", b"\x00\x01binary");
997 assert!(rs.nodes[0].meta.get("content").is_none());
998 let upper = FileNodeExtractor.extract("README.MD", "b", b"# Hi\n");
1000 assert_eq!(upper.nodes[0].meta["content"], "# Hi");
1001 }
1002
1003 #[cfg(feature = "pdf-text")]
1006 fn minimal_pdf(text: &str) -> Vec<u8> {
1007 let content = format!("BT /F1 24 Tf 72 720 Td ({text}) Tj ET");
1008 let objects = [
1009 "<< /Type /Catalog /Pages 2 0 R >>".to_owned(),
1010 "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(),
1011 "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_owned(),
1012 format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
1013 "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_owned(),
1014 ];
1015 let mut pdf = Vec::new();
1016 pdf.extend_from_slice(b"%PDF-1.4\n");
1017 let mut offsets = Vec::new();
1018 for (i, obj) in objects.iter().enumerate() {
1019 offsets.push(pdf.len());
1020 pdf.extend_from_slice(format!("{} 0 obj\n{obj}\nendobj\n", i + 1).as_bytes());
1021 }
1022 let xref_start = pdf.len();
1023 pdf.extend_from_slice(
1024 format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes(),
1025 );
1026 for off in &offsets {
1027 pdf.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
1028 }
1029 pdf.extend_from_slice(
1030 format!(
1031 "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_start}\n%%EOF\n",
1032 objects.len() + 1
1033 )
1034 .as_bytes(),
1035 );
1036 pdf
1037 }
1038
1039 #[cfg(feature = "pdf-text")]
1040 #[test]
1041 fn pdf_file_captures_text_content() {
1042 let pdf = minimal_pdf("Hello Roteiro");
1043 let facts = FileNodeExtractor.extract("docs/guide.pdf", "b", &pdf);
1044 let content = facts.nodes[0].meta["content"].as_str().unwrap();
1045 assert!(content.contains("Hello Roteiro"), "got: {content:?}");
1046 let upper = FileNodeExtractor.extract("docs/Guide.PDF", "b", &pdf);
1048 assert!(upper.nodes[0].meta.get("content").is_some());
1049 let bad = FileNodeExtractor.extract("docs/bad.pdf", "b", b"%PDF-1.4\ngarbage");
1051 assert!(bad.nodes[0].meta.get("content").is_none());
1052 }
1053
1054 #[cfg(any(feature = "image-ocr", feature = "image-vision"))]
1055 #[test]
1056 fn image_content_guards_before_touching_models() {
1057 assert!(super::is_image("shot.PNG"));
1059 assert!(super::is_image("b.jpeg"));
1060 assert!(super::is_image("c.jpg"));
1061 assert!(!super::is_image("d.gif"));
1062 assert!(
1064 super::image_content("notes.txt", b"hello", super::IngestConfig::default()).is_none()
1065 );
1066 let big = vec![0u8; super::MAX_IMAGE_BYTES + 1];
1068 assert!(super::image_content("shot.png", &big, super::IngestConfig::default()).is_none());
1069 }
1070
1071 #[test]
1072 fn doc_comment_body_recognises_doc_markers() {
1073 assert_eq!(super::doc_comment_body("/// hi").as_deref(), Some("hi"));
1074 assert_eq!(
1075 super::doc_comment_body("//! mod doc").as_deref(),
1076 Some("mod doc")
1077 );
1078 assert_eq!(
1079 super::doc_comment_body("/** block */").as_deref(),
1080 Some("block")
1081 );
1082 assert_eq!(super::doc_comment_body("// plain"), None);
1084 assert_eq!(super::doc_comment_body("//// header"), None);
1085 assert_eq!(super::doc_comment_body("/**/").as_deref(), Some(""));
1087 assert_eq!(super::doc_comment_body("/*!*/").as_deref(), Some(""));
1088 }
1089
1090 #[test]
1091 fn registry_dispatches_by_extension() {
1092 let rs = Registry::default().extract("src/lib.rs", "b", SAMPLE.as_bytes());
1093 assert!(rs.nodes.len() > 1, "rust file yields symbols");
1094 let txt = Registry::default().extract("notes.txt", "b", b"hello\n");
1095 assert_eq!(
1096 txt.nodes.len(),
1097 1,
1098 "non-code file falls back to a file node"
1099 );
1100 assert_eq!(txt.nodes[0].kind, NodeKind::File);
1101 }
1102
1103 #[test]
1104 fn ingest_prose_toggle_gates_embedded_content() {
1105 use super::IngestConfig;
1106
1107 let content = |ingest: IngestConfig| {
1108 Registry::new(ingest)
1109 .extract("notes.md", "b", b"# Title\n\nBody text.\n")
1110 .nodes[0]
1111 .meta
1112 .get("content")
1113 .and_then(|v| v.as_str())
1114 .map(str::to_owned)
1115 };
1116
1117 assert!(
1119 content(IngestConfig::default()).is_some_and(|c| c.contains("Body text")),
1120 "prose content embedded by default"
1121 );
1122 assert_eq!(
1123 content(IngestConfig {
1124 prose: false,
1125 ..IngestConfig::default()
1126 }),
1127 None,
1128 "disabling prose suppresses the embedded body"
1129 );
1130 }
1131
1132 #[test]
1133 fn env_tag_stable_by_default_and_shifts_when_gated() {
1134 use super::IngestConfig;
1135
1136 let all_on = Registry::new(IngestConfig::default()).env_tag();
1139 assert_eq!(all_on, Registry::default().env_tag());
1140
1141 let no_prose = Registry::new(IngestConfig {
1144 prose: false,
1145 ..IngestConfig::default()
1146 })
1147 .env_tag();
1148 let no_pdf = Registry::new(IngestConfig {
1149 pdf: false,
1150 ..IngestConfig::default()
1151 })
1152 .env_tag();
1153 assert_ne!(no_prose, all_on);
1154 assert_ne!(no_pdf, all_on);
1155 assert_ne!(no_prose, no_pdf);
1156 }
1157}