1use crate::{Edge, EdgeKind, FactSet, Node, NodeKind, Provenance, Span};
16
17pub(crate) const EXTRACT_VERSION: u32 = 8
36 + if cfg!(feature = "pdf-text") { 100 } else { 0 }
37 + if cfg!(feature = "image-ocr") { 200 } else { 0 }
38 + if cfg!(feature = "image-vision") {
39 400
40 } else {
41 0
42 }
43 + if cfg!(feature = "audio-transcribe") {
44 800
45 } else {
46 0
47 };
48
49const MAX_CONTENT: usize = 1500;
53
54#[cfg(feature = "pdf-text")]
57const MAX_PDF_BYTES: usize = 20 * 1024 * 1024;
58
59#[cfg(any(feature = "image-ocr", feature = "image-vision"))]
61const MAX_IMAGE_BYTES: usize = 20 * 1024 * 1024;
62
63#[cfg(any(feature = "image-ocr", feature = "image-vision"))]
67const MAX_IMAGE_PIXELS: u64 = 4096 * 4096;
68
69#[cfg(feature = "image-vision")]
73const MIN_OCR_WORDS: usize = 8;
74
75#[cfg(feature = "audio-transcribe")]
78const MAX_AUDIO_BYTES: usize = 50 * 1024 * 1024;
79
80pub trait Extractor {
82 fn extract(&self, path: &str, blob_id: &str, bytes: &[u8]) -> FactSet;
87
88 fn env_tag(&self) -> u64 {
95 media_env_tag()
96 }
97}
98
99#[allow(clippy::struct_excessive_bools)]
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub struct IngestConfig {
109 pub prose: bool,
111 pub pdf: bool,
113 pub ocr: bool,
115 pub vision: bool,
117 pub audio: bool,
119}
120
121impl Default for IngestConfig {
122 fn default() -> Self {
123 Self {
124 prose: true,
125 pdf: true,
126 ocr: true,
127 vision: true,
128 audio: true,
129 }
130 }
131}
132
133impl IngestConfig {
134 fn disabled_bits(self) -> u64 {
139 u64::from(!self.prose)
140 | (u64::from(!self.pdf) << 1)
141 | (u64::from(!self.ocr) << 2)
142 | (u64::from(!self.vision) << 3)
143 | (u64::from(!self.audio) << 4)
144 }
145}
146
147#[derive(Debug, Clone, Copy, Default)]
153pub struct Registry {
154 pub ingest: IngestConfig,
156}
157
158impl Registry {
159 #[must_use]
161 pub fn new(ingest: IngestConfig) -> Self {
162 Self { ingest }
163 }
164}
165
166impl Extractor for Registry {
167 fn extract(&self, path: &str, blob_id: &str, bytes: &[u8]) -> FactSet {
168 let mut facts = extract_facts(path, blob_id, bytes, self.ingest);
169 crate::markers::augment(&mut facts, path, blob_id, bytes);
170 facts
171 }
172
173 fn env_tag(&self) -> u64 {
174 let media = media_env_tag();
175 let disabled = self.ingest.disabled_bits();
176 if disabled == 0 {
177 media
179 } else {
180 let mut h = 0xcbf2_9ce4_8422_2325u64;
185 for b in media
186 .to_le_bytes()
187 .into_iter()
188 .chain(disabled.to_le_bytes())
189 {
190 h ^= u64::from(b);
191 h = h.wrapping_mul(0x0000_0100_0000_01b3);
192 }
193 h
194 }
195 }
196}
197
198fn extract_facts(path: &str, blob_id: &str, bytes: &[u8], ingest: IngestConfig) -> FactSet {
202 if crate::config_keys::is_config_path(path) {
205 return config_facts(path, blob_id, bytes, ingest);
206 }
207 if is_dockerfile(path) {
210 return dockerfile_facts(path, blob_id, bytes, ingest);
211 }
212 let ext = extension(path);
213 match ext.as_deref() {
214 Some("rs") => rust_facts(path, blob_id, bytes, ingest),
216 Some(ext) => tag_facts(path, blob_id, bytes, ext, ingest).unwrap_or_else(|| {
220 FactSet::new().with_node(file_node(path, blob_id, bytes, None, ingest))
221 }),
222 None => FactSet::new().with_node(file_node(path, blob_id, bytes, None, ingest)),
223 }
224}
225
226fn extension(path: &str) -> Option<String> {
229 let name = path.rsplit('/').next().unwrap_or(path);
230 name.rsplit_once('.')
231 .map(|(_, ext)| ext.to_ascii_lowercase())
232}
233
234fn file_key(path: &str) -> String {
236 format!("file:{path}")
237}
238
239fn file_node(
243 path: &str,
244 blob_id: &str,
245 bytes: &[u8],
246 lang: Option<&str>,
247 ingest: IngestConfig,
248) -> Node {
249 let name = path.rsplit('/').next().unwrap_or(path).to_owned();
250 let lines = bytes
251 .iter()
252 .fold(0usize, |n, &b| n + usize::from(b == b'\n'));
253 let end = u32::try_from(bytes.len()).unwrap_or(u32::MAX);
254 let mut meta = serde_json::json!({ "bytes": bytes.len(), "lines": lines });
255 let content = if ingest.prose && is_prose(path) {
260 cap_content(&String::from_utf8_lossy(bytes))
261 } else if let Some(text) = ingest.pdf.then(|| pdf_content(path, bytes)).flatten() {
262 cap_content(&text)
263 } else if let Some(text) = image_content(path, bytes, ingest) {
264 cap_content(&text)
265 } else if let Some(text) = audio_content(path, bytes, ingest) {
266 cap_content(&text)
267 } else {
268 String::new()
269 };
270 if !content.is_empty() {
271 meta["content"] = serde_json::Value::from(content);
272 }
273 Node {
274 key: file_key(path),
275 kind: NodeKind::File,
276 name,
277 path: Some(path.to_owned()),
278 lang: lang.map(ToOwned::to_owned),
279 blob_hash: Some(blob_id.to_owned()),
280 span: Some(Span::new(0, end)),
281 provenance: Provenance::Derived,
282 meta,
283 }
284}
285
286fn config_facts(path: &str, blob_id: &str, bytes: &[u8], ingest: IngestConfig) -> FactSet {
292 let mut facts = FactSet::new().with_node(file_node(path, blob_id, bytes, None, ingest));
293 let file = file_key(path);
294 let mut by_key: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
297 for ck in crate::config_keys::flatten(path, bytes) {
298 by_key.insert(ck.key, ck.value);
299 }
300 for (key, value) in by_key {
301 let node_key = format!("cfgkey:{path}#{key}");
302 let value = if crate::config_keys::is_secret_key(&key) {
305 "<redacted>".to_owned()
306 } else {
307 value
308 };
309 let mut node = Node::new(
310 node_key.clone(),
311 NodeKind::Other(crate::config_keys::KIND.into()),
312 key.clone(),
313 );
314 node.path = Some(path.to_owned());
315 node.blob_hash = Some(blob_id.to_owned());
316 node.meta = serde_json::json!({ "key": key, "value": value });
317 facts = facts.with_node(node).with_edge(Edge::derived(
318 file.clone(),
319 node_key,
320 EdgeKind::Contains,
321 ));
322 }
323 facts
324}
325
326pub(crate) const IMAGE_REF_KIND: &str = "image_ref";
330
331fn is_dockerfile(path: &str) -> bool {
334 let base = path.rsplit('/').next().unwrap_or(path).to_ascii_lowercase();
335 base == "dockerfile"
336 || base == "containerfile"
337 || base.starts_with("dockerfile.")
338 || base.ends_with(".dockerfile")
339}
340
341fn dockerfile_facts(path: &str, blob_id: &str, bytes: &[u8], ingest: IngestConfig) -> FactSet {
346 let mut facts = FactSet::new().with_node(file_node(path, blob_id, bytes, None, ingest));
347 let file = file_key(path);
348 let text = String::from_utf8_lossy(bytes);
349 let mut stages: std::collections::HashSet<String> = std::collections::HashSet::new();
350 let mut idx = 0usize;
351 for line in text.lines() {
352 let Some(rest) = strip_from_prefix(line.trim()) else {
353 continue;
354 };
355 let (image, stage) = parse_from(rest);
356 let is_internal_stage = stages.contains(&image.to_ascii_lowercase());
360 if let Some(s) = stage {
361 stages.insert(s.to_ascii_lowercase());
362 }
363 if image.is_empty() || image.eq_ignore_ascii_case("scratch") || is_internal_stage {
366 continue;
367 }
368 let (name, tag, digest) = split_image(image);
369 let node_key = format!("imageref:{path}#{idx}");
370 idx += 1;
371 let mut node = Node::new(
372 node_key.clone(),
373 NodeKind::Other(IMAGE_REF_KIND.into()),
374 image.to_owned(),
375 );
376 node.path = Some(path.to_owned());
377 node.blob_hash = Some(blob_id.to_owned());
378 node.meta = serde_json::json!({ "image": name, "tag": tag, "digest": digest });
379 facts = facts.with_node(node).with_edge(Edge::derived(
380 file.clone(),
381 node_key,
382 EdgeKind::References,
383 ));
384 }
385 facts
386}
387
388fn strip_from_prefix(line: &str) -> Option<&str> {
390 let b = line.as_bytes();
391 (b.len() >= 5 && b[..4].eq_ignore_ascii_case(b"from") && b[4].is_ascii_whitespace())
392 .then(|| line[5..].trim_start())
393}
394
395fn parse_from(rest: &str) -> (&str, Option<&str>) {
399 let image = rest
400 .split_whitespace()
401 .find(|t| !t.starts_with("--"))
402 .unwrap_or("");
403 let mut toks = rest.split_whitespace();
404 let mut stage = None;
405 while let Some(t) = toks.next() {
406 if t.eq_ignore_ascii_case("as") {
407 stage = toks.next();
408 break;
409 }
410 }
411 (image, stage)
412}
413
414fn split_image(image: &str) -> (String, Option<String>, Option<String>) {
418 if let Some((name, digest)) = image.split_once('@') {
419 return (name.to_owned(), None, Some(digest.to_owned()));
420 }
421 let seg = image.rfind('/').map_or(0, |i| i + 1);
422 if let Some(colon) = image[seg..].find(':') {
423 let at = seg + colon;
424 return (
425 image[..at].to_owned(),
426 Some(image[at + 1..].to_owned()),
427 None,
428 );
429 }
430 (image.to_owned(), None, None)
431}
432
433fn doc_comment_body(raw: &str) -> Option<String> {
437 let t = raw.trim();
438 if t.starts_with("//!") || (t.starts_with("///") && !t.starts_with("////")) {
439 return Some(t[3..].trim().to_owned());
440 }
441 if (t.starts_with("/**") || t.starts_with("/*!")) && t.ends_with("*/") {
442 let end = t.len() - 2;
446 let inner = if end >= 3 { &t[3..end] } else { "" };
447 let cleaned: Vec<&str> = inner
448 .lines()
449 .map(|l| l.trim().trim_start_matches('*').trim())
450 .filter(|l| !l.is_empty())
451 .collect();
452 return Some(cleaned.join(" "));
453 }
454 None
455}
456
457#[cfg(feature = "pdf-text")]
465fn pdf_content(path: &str, bytes: &[u8]) -> Option<String> {
466 if extension(path).as_deref() != Some("pdf") || bytes.len() > MAX_PDF_BYTES {
467 return None;
468 }
469 let owned = bytes.to_vec();
470 let text = std::panic::catch_unwind(move || pdf_extract::extract_text_from_mem(&owned).ok())
471 .ok()
472 .flatten()?;
473 (!text.trim().is_empty()).then_some(text)
474}
475
476#[cfg(not(feature = "pdf-text"))]
478fn pdf_content(_path: &str, _bytes: &[u8]) -> Option<String> {
479 None
480}
481
482#[cfg(any(feature = "image-ocr", feature = "image-vision"))]
492fn image_content(path: &str, bytes: &[u8], ingest: IngestConfig) -> Option<String> {
493 if !is_image(path) || bytes.len() > MAX_IMAGE_BYTES {
494 return None;
495 }
496 let ocr = if ingest.ocr { ocr_content(bytes) } else { None };
502 let sparse = ocr
503 .as_deref()
504 .is_none_or(|t| t.split_whitespace().count() < min_ocr_words());
505 let vision = if ingest.vision && sparse {
506 vlm_content(bytes)
507 } else {
508 None
509 };
510 match (ocr, vision) {
511 (Some(o), Some(v)) => Some(format!("{o}\n\n{v}")),
512 (Some(o), None) => Some(o),
513 (None, Some(v)) => Some(v),
514 (None, None) => None,
515 }
516}
517
518#[cfg(not(any(feature = "image-ocr", feature = "image-vision")))]
520fn image_content(_path: &str, _bytes: &[u8], _ingest: IngestConfig) -> Option<String> {
521 None
522}
523
524#[cfg(any(feature = "image-ocr", feature = "image-vision"))]
527fn min_ocr_words() -> usize {
528 #[cfg(feature = "image-vision")]
529 {
530 MIN_OCR_WORDS
531 }
532 #[cfg(not(feature = "image-vision"))]
533 {
534 usize::MAX
535 }
536}
537
538#[cfg(any(feature = "image-ocr", feature = "image-vision"))]
540fn is_image(path: &str) -> bool {
541 matches!(extension(path).as_deref(), Some("png" | "jpg" | "jpeg"))
542}
543
544#[cfg(feature = "audio-transcribe")]
553fn audio_content(path: &str, bytes: &[u8], ingest: IngestConfig) -> Option<String> {
554 if !ingest.audio || !is_audio(path) || bytes.len() > MAX_AUDIO_BYTES {
555 return None;
556 }
557 asr_content(bytes)
558}
559
560#[cfg(not(feature = "audio-transcribe"))]
563fn audio_content(_path: &str, _bytes: &[u8], _ingest: IngestConfig) -> Option<String> {
564 None
565}
566
567#[cfg(feature = "audio-transcribe")]
570fn is_audio(path: &str) -> bool {
571 matches!(extension(path).as_deref(), Some("wav" | "mp3" | "flac"))
572}
573
574#[cfg(feature = "audio-transcribe")]
579fn asr_content(bytes: &[u8]) -> Option<String> {
580 use rto_llama::Engine as _;
581
582 let engine = asr_engine()?;
583 let completion = engine
584 .chat(&rto_llama::ChatRequest {
585 model: ASR_MODEL.to_owned(),
586 messages: vec![rto_llama::Message {
587 role: "user".to_owned(),
588 content: "Transcribe this audio recording. Output only the spoken words, verbatim."
589 .to_owned(),
590 }],
591 images: Vec::new(),
592 audio: vec![bytes.to_vec()],
593 temperature: 0.0,
594 max_tokens: 512,
595 })
596 .ok()?;
597 let text = completion.content.trim();
598 (!text.is_empty()).then(|| text.to_owned())
599}
600
601#[cfg(feature = "audio-transcribe")]
603const ASR_MODEL: &str = "voxtral-mini-3b";
604
605#[cfg(feature = "audio-transcribe")]
609fn asr_engine() -> Option<&'static rto_llama::llama::LlamaEngine> {
610 use std::sync::OnceLock;
611 static ENGINE: OnceLock<Option<rto_llama::llama::LlamaEngine>> = OnceLock::new();
612 ENGINE
613 .get_or_init(|| {
614 let dir = crate::models::model_dir(ASR_MODEL);
615 let (gguf, mmproj) = (dir.join("model.gguf"), dir.join("mmproj.gguf"));
616 if !gguf.exists() || !mmproj.exists() {
617 return None;
618 }
619 rto_llama::llama::LlamaEngine::new(
620 vec![rto_llama::llama::Served {
621 name: ASR_MODEL.to_owned(),
622 path: gguf,
623 mmproj: Some(mmproj),
624 }],
625 0,
626 )
627 .ok()
628 })
629 .as_ref()
630}
631
632#[cfg(any(feature = "image-ocr", feature = "image-vision"))]
637fn image_dimensions_ok(bytes: &[u8]) -> bool {
638 let Ok(reader) = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format()
639 else {
640 return false;
641 };
642 match reader.into_dimensions() {
643 Ok((w, h)) => u64::from(w) * u64::from(h) <= MAX_IMAGE_PIXELS,
644 Err(_) => false,
645 }
646}
647
648#[cfg(feature = "image-ocr")]
652fn ocr_content(bytes: &[u8]) -> Option<String> {
653 let dir = crate::models::model_dir("ocrs-text");
654 let detection = dir.join("text-detection.rten");
655 let recognition = dir.join("text-recognition.rten");
656 if !detection.exists() || !recognition.exists() || !image_dimensions_ok(bytes) {
657 return None;
659 }
660 let text = std::panic::catch_unwind(|| run_ocr(&detection, &recognition, bytes))
663 .ok()
664 .flatten()?;
665 (!text.trim().is_empty()).then_some(text)
666}
667
668#[cfg(all(feature = "image-vision", not(feature = "image-ocr")))]
673fn ocr_content(_bytes: &[u8]) -> Option<String> {
674 None
675}
676
677#[cfg(feature = "image-ocr")]
680fn run_ocr(
681 detection: &std::path::Path,
682 recognition: &std::path::Path,
683 bytes: &[u8],
684) -> Option<String> {
685 use ocrs::{ImageSource, OcrEngine, OcrEngineParams};
686
687 let detection_model = rten::Model::load_file(detection).ok()?;
688 let recognition_model = rten::Model::load_file(recognition).ok()?;
689 let engine = OcrEngine::new(OcrEngineParams {
690 detection_model: Some(detection_model),
691 recognition_model: Some(recognition_model),
692 ..Default::default()
693 })
694 .ok()?;
695
696 let img = image::load_from_memory(bytes).ok()?.into_rgb8();
697 let source = ImageSource::from_bytes(img.as_raw(), img.dimensions()).ok()?;
698 let input = engine.prepare_input(source).ok()?;
699 engine.get_text(&input).ok()
700}
701
702#[cfg(feature = "image-vision")]
709fn vlm_content(bytes: &[u8]) -> Option<String> {
710 use rto_llama::Engine as _;
711
712 if !image_dimensions_ok(bytes) {
713 return None;
714 }
715 let engine = vlm_engine()?;
716 let completion = engine
717 .chat(&rto_llama::ChatRequest {
718 model: VLM_MODEL.to_owned(),
719 messages: vec![rto_llama::Message {
720 role: "user".to_owned(),
721 content: "Describe this image in one or two sentences.".to_owned(),
722 }],
723 images: vec![bytes.to_vec()],
724 audio: Vec::new(),
725 temperature: 0.0,
726 max_tokens: 128,
727 })
728 .ok()?;
729 let text = completion.content.trim();
730 (!text.is_empty()).then(|| text.to_owned())
731}
732
733#[cfg(feature = "image-vision")]
735const VLM_MODEL: &str = "smolvlm-500m-gguf";
736
737#[cfg(feature = "image-vision")]
741fn vlm_engine() -> Option<&'static rto_llama::llama::LlamaEngine> {
742 use std::sync::OnceLock;
743 static ENGINE: OnceLock<Option<rto_llama::llama::LlamaEngine>> = OnceLock::new();
744 ENGINE
745 .get_or_init(|| {
746 let dir = crate::models::model_dir(VLM_MODEL);
747 let (gguf, mmproj) = (dir.join("model.gguf"), dir.join("mmproj.gguf"));
748 if !gguf.exists() || !mmproj.exists() {
749 return None;
750 }
751 rto_llama::llama::LlamaEngine::new(
752 vec![rto_llama::llama::Served {
753 name: VLM_MODEL.to_owned(),
754 path: gguf,
755 mmproj: Some(mmproj),
756 }],
757 0,
758 )
759 .ok()
760 })
761 .as_ref()
762}
763
764#[cfg(all(feature = "image-ocr", not(feature = "image-vision")))]
767fn vlm_content(_bytes: &[u8]) -> Option<String> {
768 None
769}
770
771#[cfg(any(
781 feature = "image-ocr",
782 feature = "image-vision",
783 feature = "audio-transcribe"
784))]
785pub(crate) fn media_env_tag() -> u64 {
786 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
787 let mut any = false;
788 #[cfg(feature = "image-ocr")]
789 {
790 any |= fold_installed_model(&mut hash, "ocrs-text");
791 }
792 #[cfg(feature = "image-vision")]
793 {
794 any |= fold_installed_model(&mut hash, "smolvlm-500m-gguf");
795 }
796 #[cfg(feature = "audio-transcribe")]
797 {
798 any |= fold_installed_model(&mut hash, "voxtral-mini-3b");
799 }
800 if any { hash | 1 } else { 0 }
801}
802
803#[cfg(any(
807 feature = "image-ocr",
808 feature = "image-vision",
809 feature = "audio-transcribe"
810))]
811fn fold_installed_model(hash: &mut u64, name: &str) -> bool {
812 let Some(variant) = crate::models::find(name)
813 .and_then(|spec| spec.variant_for(crate::models::Platform::host()))
814 else {
815 return false;
816 };
817 let dir = crate::models::model_dir(name);
818 if !variant.files.iter().all(|f| dir.join(f.name).exists()) {
819 return false;
820 }
821 for file in variant.files {
822 for b in file.sha256.bytes() {
823 *hash ^= u64::from(b);
824 *hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
825 }
826 }
827 true
828}
829
830#[cfg(not(any(
832 feature = "image-ocr",
833 feature = "image-vision",
834 feature = "audio-transcribe"
835)))]
836pub(crate) fn media_env_tag() -> u64 {
837 0
838}
839
840fn is_prose(path: &str) -> bool {
842 matches!(
843 extension(path).as_deref(),
844 Some("md" | "markdown" | "txt" | "rst" | "adoc")
845 )
846}
847
848fn cap_content(text: &str) -> String {
851 let mut out = String::with_capacity(text.len().min(MAX_CONTENT));
852 let mut chars = 0usize;
855 let mut last_was_space = true;
856 for c in text.chars() {
857 if chars >= MAX_CONTENT {
858 break;
859 }
860 if c.is_whitespace() {
861 if !last_was_space {
862 out.push(' ');
863 chars += 1;
864 last_was_space = true;
865 }
866 } else {
867 out.push(c);
868 chars += 1;
869 last_was_space = false;
870 }
871 }
872 out.trim().to_owned()
873}
874
875#[derive(Debug, Clone, Copy, Default)]
879pub struct FileNodeExtractor;
880
881impl Extractor for FileNodeExtractor {
882 fn extract(&self, path: &str, blob_id: &str, bytes: &[u8]) -> FactSet {
883 FactSet::new().with_node(file_node(
884 path,
885 blob_id,
886 bytes,
887 None,
888 IngestConfig::default(),
889 ))
890 }
891}
892
893#[derive(Debug, Clone, Copy, Default)]
900pub struct RustExtractor;
901
902impl Extractor for RustExtractor {
903 fn extract(&self, path: &str, blob_id: &str, bytes: &[u8]) -> FactSet {
904 rust_facts(path, blob_id, bytes, IngestConfig::default())
905 }
906}
907
908fn rust_facts(path: &str, blob_id: &str, bytes: &[u8], ingest: IngestConfig) -> FactSet {
911 let mut parser = tree_sitter::Parser::new();
912 if parser
915 .set_language(&tree_sitter_rust::LANGUAGE.into())
916 .is_err()
917 {
918 return FactSet::new().with_node(file_node(path, blob_id, bytes, None, ingest));
919 }
920 let Some(tree) = parser.parse(bytes, None) else {
921 return FactSet::new().with_node(file_node(path, blob_id, bytes, None, ingest));
922 };
923
924 let mut walk = RustWalk {
925 path,
926 blob_id,
927 src: bytes,
928 nodes: vec![file_node(path, blob_id, bytes, Some("rust"), ingest)],
929 edges: Vec::new(),
930 };
931 let root = tree.root_node();
932 let mut cursor = root.walk();
933 let children: Vec<_> = root.children(&mut cursor).collect();
934 for child in children {
935 walk.visit(child, &[]);
936 }
937
938 walk.nodes.sort_by(|a, b| a.key.cmp(&b.key));
941 walk.edges
942 .sort_by(|a, b| (a.kind.as_str(), &a.src, &a.dst).cmp(&(b.kind.as_str(), &b.src, &b.dst)));
943 FactSet {
944 nodes: walk.nodes,
945 edges: walk.edges,
946 }
947}
948
949struct Scope {
953 seg: String,
954 key: Option<String>,
955}
956
957struct RustWalk<'a> {
959 path: &'a str,
960 blob_id: &'a str,
961 src: &'a [u8],
962 nodes: Vec<Node>,
963 edges: Vec<Edge>,
964}
965
966impl RustWalk<'_> {
967 fn visit(&mut self, node: tree_sitter::Node, scope: &[Scope]) {
969 match node.kind() {
970 "function_item" => self.visit_symbol(node, scope, NodeKind::Fn, true),
971 "struct_item" | "union_item" => self.visit_symbol(node, scope, NodeKind::Struct, false),
972 "enum_item" => self.visit_symbol(node, scope, NodeKind::Enum, false),
973 "trait_item" => self.visit_symbol(node, scope, NodeKind::Trait, false),
974 "mod_item" => self.visit_symbol(node, scope, NodeKind::Module, false),
975 "type_item" => self.visit_symbol(node, scope, NodeKind::Other("type".into()), false),
976 "macro_definition" => {
977 self.visit_symbol(node, scope, NodeKind::Other("macro".into()), false);
978 }
979 "impl_item" => self.visit_impl(node, scope),
980 "use_declaration" => self.visit_use(node),
981 _ => self.visit_children(node, scope),
984 }
985 }
986
987 fn visit_children(&mut self, node: tree_sitter::Node, scope: &[Scope]) {
989 let mut cursor = node.walk();
990 let children: Vec<_> = node.named_children(&mut cursor).collect();
991 for child in children {
992 self.visit(child, scope);
993 }
994 }
995
996 fn visit_symbol(
999 &mut self,
1000 node: tree_sitter::Node,
1001 scope: &[Scope],
1002 kind: NodeKind,
1003 collect_calls: bool,
1004 ) {
1005 let Some(name) = self.field_text(node, "name") else {
1006 return self.visit_children(node, scope);
1007 };
1008 let qualified = qualify(scope, &name);
1009 let key = format!("sym:rust:{}#{qualified}", self.path);
1010
1011 let mut meta = serde_json::Map::new();
1012 if collect_calls {
1013 let mut calls = Vec::new();
1014 self.collect_calls(node, &mut calls);
1015 calls.sort();
1016 calls.dedup();
1017 if !calls.is_empty() {
1018 meta.insert("calls".into(), serde_json::Value::from(calls));
1019 }
1020 }
1021 if let Some(doc) = self.doc_comment(node) {
1023 meta.insert("content".into(), serde_json::Value::from(doc));
1024 }
1025 if matches!(node.kind(), "struct_item" | "union_item") {
1031 let fields = self.struct_field_names(node);
1032 if !fields.is_empty() {
1033 meta.insert("fields".into(), serde_json::Value::from(fields));
1034 }
1035 }
1036
1037 self.nodes.push(Node {
1038 key: key.clone(),
1039 kind,
1040 name,
1041 path: Some(self.path.to_owned()),
1042 lang: Some("rust".to_owned()),
1043 blob_hash: Some(self.blob_id.to_owned()),
1044 span: Some(span(node)),
1045 provenance: Provenance::Derived,
1046 meta: serde_json::Value::Object(meta),
1047 });
1048 self.link_parent(&key, scope);
1049
1050 let child_scope = extend(scope, &self.simple(node, "name"), Some(key));
1053 self.recurse_body(node, &child_scope);
1054 }
1055
1056 fn doc_comment(&self, node: tree_sitter::Node) -> Option<String> {
1060 let mut parts: Vec<String> = Vec::new();
1061 let mut prev = node.prev_sibling();
1062 while let Some(n) = prev {
1063 match n.kind() {
1064 "line_comment" | "block_comment" => match doc_comment_body(self.text(n)) {
1065 Some(body) => {
1066 parts.push(body);
1067 prev = n.prev_sibling();
1068 }
1069 None => break,
1070 },
1071 "attribute_item" => prev = n.prev_sibling(),
1072 _ => break,
1073 }
1074 }
1075 if parts.is_empty() {
1076 return None;
1077 }
1078 parts.reverse();
1079 let joined = cap_content(&parts.join(" "));
1080 (!joined.is_empty()).then_some(joined)
1081 }
1082
1083 fn visit_impl(&mut self, node: tree_sitter::Node, scope: &[Scope]) {
1086 let type_name = self
1087 .field_text(node, "type")
1088 .unwrap_or_else(|| "impl".to_owned());
1089 let child_scope = extend(scope, &type_name, None);
1090 self.recurse_body(node, &child_scope);
1091 }
1092
1093 fn visit_use(&mut self, node: tree_sitter::Node) {
1096 let Some(arg) = node.child_by_field_name("argument") else {
1097 return;
1098 };
1099 let text: String = self
1100 .text(arg)
1101 .chars()
1102 .filter(|c| !c.is_whitespace())
1103 .collect();
1104 if text.is_empty() {
1105 return;
1106 }
1107 let key = format!("import:rust:{text}");
1108 self.nodes.push(Node {
1109 key: key.clone(),
1110 kind: NodeKind::Other("import".into()),
1111 name: text,
1112 path: None,
1113 lang: Some("rust".to_owned()),
1114 blob_hash: None,
1115 span: None,
1116 provenance: Provenance::Derived,
1117 meta: serde_json::Value::Null,
1118 });
1119 self.edges
1120 .push(Edge::derived(file_key(self.path), key, EdgeKind::Imports));
1121 }
1122
1123 fn link_parent(&mut self, key: &str, scope: &[Scope]) {
1126 if let Some(parent) = scope.iter().rev().find_map(|s| s.key.as_deref()) {
1127 self.edges.push(Edge::derived(
1128 parent.to_owned(),
1129 key.to_owned(),
1130 EdgeKind::Contains,
1131 ));
1132 } else {
1133 self.edges.push(Edge::derived(
1134 file_key(self.path),
1135 key.to_owned(),
1136 EdgeKind::Defines,
1137 ));
1138 }
1139 }
1140
1141 fn struct_field_names(&self, node: tree_sitter::Node) -> Vec<String> {
1146 let mut out = Vec::new();
1147 let mut cursor = node.walk();
1148 for child in node.named_children(&mut cursor) {
1149 if child.kind() == "field_declaration_list" {
1150 let mut inner = child.walk();
1151 for field in child.named_children(&mut inner) {
1152 if field.kind() == "field_declaration"
1153 && let Some(name) = field.child_by_field_name("name")
1154 {
1155 out.push(self.text(name).to_owned());
1156 }
1157 }
1158 }
1159 }
1160 out
1161 }
1162
1163 fn recurse_body(&mut self, node: tree_sitter::Node, scope: &[Scope]) {
1165 let mut cursor = node.walk();
1166 let children: Vec<_> = node.named_children(&mut cursor).collect();
1167 for child in children {
1168 match child.kind() {
1169 "declaration_list" | "field_declaration_list" | "trait_body" => {
1170 self.visit_children(child, scope);
1171 }
1172 _ => {}
1173 }
1174 }
1175 }
1176
1177 fn collect_calls(&self, node: tree_sitter::Node, out: &mut Vec<String>) {
1180 let mut cursor = node.walk();
1181 for child in node.named_children(&mut cursor) {
1182 if child.kind() == "call_expression"
1183 && let Some(func) = child.child_by_field_name("function")
1184 && let Some(name) = self.callee_name(func)
1185 {
1186 out.push(name);
1187 }
1188 self.collect_calls(child, out);
1189 }
1190 }
1191
1192 fn callee_name(&self, func: tree_sitter::Node) -> Option<String> {
1203 match func.kind() {
1204 "identifier" => Some(self.text(func).to_owned()),
1205 "scoped_identifier" => {
1206 let name = func.child_by_field_name("name")?;
1207 let qualifier = func
1210 .child_by_field_name("path")
1211 .and_then(|p| self.text(p).rsplit("::").next().map(str::to_owned));
1212 Some(qualify_callee(qualifier.as_deref(), self.text(name)))
1213 }
1214 "field_expression" => {
1215 let name = func.child_by_field_name("field")?;
1216 let on_self = func
1219 .child_by_field_name("value")
1220 .is_some_and(|v| self.text(v) == "self");
1221 Some(qualify_callee(on_self.then_some("Self"), self.text(name)))
1222 }
1223 _ => None,
1224 }
1225 }
1226
1227 fn text(&self, node: tree_sitter::Node) -> &str {
1228 node.utf8_text(self.src).unwrap_or("")
1229 }
1230
1231 fn field_text(&self, node: tree_sitter::Node, field: &str) -> Option<String> {
1232 node.child_by_field_name(field)
1233 .map(|n| self.text(n).to_owned())
1234 }
1235
1236 fn simple(&self, node: tree_sitter::Node, field: &str) -> String {
1237 self.field_text(node, field).unwrap_or_default()
1238 }
1239}
1240
1241struct TagLang {
1257 lang: &'static str,
1259 grammar_key: &'static str,
1264 language: tree_sitter::Language,
1266 query: std::borrow::Cow<'static, str>,
1270}
1271
1272#[allow(clippy::too_many_lines)]
1277fn tag_lang_for(ext: &str) -> Option<TagLang> {
1278 use std::borrow::Cow;
1279 let ts_query = || -> Cow<'static, str> {
1283 Cow::Owned(format!(
1284 "{}\n{}",
1285 tree_sitter_javascript::TAGS_QUERY,
1286 tree_sitter_typescript::TAGS_QUERY
1287 ))
1288 };
1289 let borrowed = |q: &'static str| -> Cow<'static, str> { Cow::Borrowed(q) };
1290
1291 let (lang, language, query): (&str, tree_sitter::Language, Cow<'static, str>) = match ext {
1292 "py" | "pyi" => (
1293 "python",
1294 tree_sitter_python::LANGUAGE.into(),
1295 borrowed(tree_sitter_python::TAGS_QUERY),
1296 ),
1297 "js" | "jsx" | "mjs" | "cjs" => (
1298 "javascript",
1299 tree_sitter_javascript::LANGUAGE.into(),
1300 borrowed(tree_sitter_javascript::TAGS_QUERY),
1301 ),
1302 "ts" | "mts" | "cts" => (
1303 "typescript",
1304 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1305 ts_query(),
1306 ),
1307 "tsx" => (
1308 "tsx",
1309 tree_sitter_typescript::LANGUAGE_TSX.into(),
1310 ts_query(),
1311 ),
1312 "go" => (
1313 "go",
1314 tree_sitter_go::LANGUAGE.into(),
1315 borrowed(tree_sitter_go::TAGS_QUERY),
1316 ),
1317 "rb" => (
1318 "ruby",
1319 tree_sitter_ruby::LANGUAGE.into(),
1320 borrowed(tree_sitter_ruby::TAGS_QUERY),
1321 ),
1322 "java" => (
1323 "java",
1324 tree_sitter_java::LANGUAGE.into(),
1325 borrowed(tree_sitter_java::TAGS_QUERY),
1326 ),
1327 "c" | "h" => (
1328 "c",
1329 tree_sitter_c::LANGUAGE.into(),
1330 borrowed(tree_sitter_c::TAGS_QUERY),
1331 ),
1332 "cc" | "cpp" | "cxx" | "hpp" | "hh" | "hxx" => (
1333 "cpp",
1334 tree_sitter_cpp::LANGUAGE.into(),
1335 borrowed(tree_sitter_cpp::TAGS_QUERY),
1336 ),
1337 "cs" => (
1340 "csharp",
1341 tree_sitter_c_sharp::LANGUAGE.into(),
1342 borrowed(include_str!("queries/csharp/tags.scm")),
1343 ),
1344 "php" => (
1345 "php",
1346 tree_sitter_php::LANGUAGE_PHP.into(),
1347 borrowed(tree_sitter_php::TAGS_QUERY),
1348 ),
1349 "scala" | "sc" => (
1351 "scala",
1352 tree_sitter_scala::LANGUAGE.into(),
1353 borrowed(include_str!("queries/scala/tags.scm")),
1354 ),
1355 "ml" => (
1356 "ocaml",
1357 tree_sitter_ocaml::LANGUAGE_OCAML.into(),
1358 borrowed(tree_sitter_ocaml::TAGS_QUERY),
1359 ),
1360 "mli" => (
1361 "ocaml",
1362 tree_sitter_ocaml::LANGUAGE_OCAML_INTERFACE.into(),
1363 borrowed(tree_sitter_ocaml::TAGS_QUERY),
1364 ),
1365 "ex" | "exs" => (
1366 "elixir",
1367 tree_sitter_elixir::LANGUAGE.into(),
1368 borrowed(tree_sitter_elixir::TAGS_QUERY),
1369 ),
1370 "sh" | "bash" => (
1372 "bash",
1373 tree_sitter_bash::LANGUAGE.into(),
1374 borrowed(include_str!("queries/bash/tags.scm")),
1375 ),
1376 "sql" => (
1378 "sql",
1379 tree_sitter_sequel::LANGUAGE.into(),
1380 borrowed(include_str!("queries/sql/tags.scm")),
1381 ),
1382 _ => return None,
1383 };
1384 let grammar_key = match ext {
1387 "mli" => "ocaml-interface",
1388 _ => lang,
1389 };
1390 Some(TagLang {
1391 lang,
1392 grammar_key,
1393 language,
1394 query,
1395 })
1396}
1397
1398type TagConfig = std::sync::Arc<tree_sitter_tags::TagsConfiguration>;
1400
1401static TAG_CONFIGS: std::sync::LazyLock<
1408 std::sync::Mutex<std::collections::HashMap<&'static str, Option<TagConfig>>>,
1409> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
1410
1411fn tag_config(def: &TagLang) -> Option<TagConfig> {
1414 let mut cache = TAG_CONFIGS
1415 .lock()
1416 .unwrap_or_else(std::sync::PoisonError::into_inner);
1417 cache
1418 .entry(def.grammar_key)
1419 .or_insert_with(|| {
1420 tree_sitter_tags::TagsConfiguration::new(def.language.clone(), &def.query, "")
1421 .ok()
1422 .map(std::sync::Arc::new)
1423 })
1424 .clone()
1425}
1426
1427fn import_query_for(lang: &str) -> Option<&'static str> {
1435 Some(match lang {
1436 "python" => {
1438 "(import_statement name: (dotted_name) @path)\n\
1439 (import_statement name: (aliased_import name: (dotted_name) @path))\n\
1440 (import_from_statement module_name: (dotted_name) @path)\n\
1441 (import_from_statement module_name: (relative_import) @path)"
1442 }
1443 "javascript" | "typescript" | "tsx" => {
1445 "(import_statement source: (string (string_fragment) @path))\n\
1446 (export_statement source: (string (string_fragment) @path))"
1447 }
1448 "go" => "(import_spec path: (interpreted_string_literal) @path)",
1450 "java" => {
1452 "(import_declaration (scoped_identifier) @path)\n\
1453 (import_declaration (identifier) @path)"
1454 }
1455 "c" | "cpp" => {
1457 "(preproc_include path: (string_literal) @path)\n\
1458 (preproc_include path: (system_lib_string) @path)"
1459 }
1460 _ => return None,
1461 })
1462}
1463
1464type ImportQuery = std::sync::Arc<tree_sitter::Query>;
1466
1467static IMPORT_QUERIES: std::sync::LazyLock<
1471 std::sync::Mutex<std::collections::HashMap<&'static str, Option<ImportQuery>>>,
1472> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
1473
1474fn import_query(def: &TagLang) -> Option<ImportQuery> {
1476 let mut cache = IMPORT_QUERIES
1477 .lock()
1478 .unwrap_or_else(std::sync::PoisonError::into_inner);
1479 cache
1480 .entry(def.grammar_key)
1481 .or_insert_with(|| {
1482 let src = import_query_for(def.lang)?;
1483 tree_sitter::Query::new(&def.language, src)
1484 .ok()
1485 .map(std::sync::Arc::new)
1486 })
1487 .clone()
1488}
1489
1490fn normalize_import(raw: &str) -> String {
1493 raw.trim()
1494 .trim_matches(|c| c == '"' || c == '\'' || c == '<' || c == '>')
1495 .trim()
1496 .to_owned()
1497}
1498
1499fn append_import_facts(
1503 path: &str,
1504 def: &TagLang,
1505 bytes: &[u8],
1506 nodes: &mut Vec<Node>,
1507 edges: &mut Vec<Edge>,
1508) {
1509 use streaming_iterator::StreamingIterator as _;
1510
1511 let Some(query) = import_query(def) else {
1512 return;
1513 };
1514 let mut parser = tree_sitter::Parser::new();
1515 if parser.set_language(&def.language).is_err() {
1516 return;
1517 }
1518 let Some(tree) = parser.parse(bytes, None) else {
1519 return;
1520 };
1521 let mut cursor = tree_sitter::QueryCursor::new();
1522 let mut seen = std::collections::BTreeSet::new();
1523 let mut matches = cursor.matches(&query, tree.root_node(), bytes);
1524 while let Some(m) = matches.next() {
1525 for cap in m.captures {
1526 let Ok(raw) = cap.node.utf8_text(bytes) else {
1527 continue;
1528 };
1529 let module = normalize_import(raw);
1530 if module.is_empty() {
1531 continue;
1532 }
1533 let key = format!("import:{}:{module}", def.lang);
1534 if seen.insert(key.clone()) {
1535 nodes.push(Node {
1536 key: key.clone(),
1537 kind: NodeKind::Other("import".into()),
1538 name: module,
1539 path: None,
1543 lang: Some(def.lang.to_owned()),
1544 blob_hash: None,
1545 span: None,
1546 provenance: Provenance::Derived,
1547 meta: serde_json::Value::Null,
1548 });
1549 edges.push(Edge::derived(file_key(path), key, EdgeKind::Imports));
1550 }
1551 }
1552 }
1553}
1554
1555fn tag_node_kind(syntax_type: &str) -> NodeKind {
1558 match syntax_type {
1559 "function" | "method" | "constructor" => NodeKind::Fn,
1560 "class" | "struct" => NodeKind::Struct,
1561 "interface" | "trait" | "protocol" => NodeKind::Trait,
1562 "enum" => NodeKind::Enum,
1563 "module" | "namespace" | "object" => NodeKind::Module,
1565 other => NodeKind::Other(other.to_owned()),
1566 }
1567}
1568
1569struct TagDef {
1571 name: String,
1572 kind: NodeKind,
1573 range: std::ops::Range<usize>,
1574 docs: Option<String>,
1575}
1576
1577fn tag_facts(
1581 path: &str,
1582 blob_id: &str,
1583 bytes: &[u8],
1584 ext: &str,
1585 ingest: IngestConfig,
1586) -> Option<FactSet> {
1587 let def = tag_lang_for(ext)?;
1588 let lang = def.lang;
1589 let config = tag_config(&def)?;
1590
1591 let mut ctx = tree_sitter_tags::TagsContext::new();
1592 let (tags, _had_error) = ctx.generate_tags(&config, bytes, None).ok()?;
1593
1594 let mut defs: Vec<TagDef> = Vec::new();
1595 let mut calls: Vec<(usize, String)> = Vec::new();
1598 for tag in tags {
1599 let Ok(tag) = tag else { continue };
1600 let Some(name) = bytes
1601 .get(tag.name_range.clone())
1602 .and_then(|b| std::str::from_utf8(b).ok())
1603 else {
1604 continue;
1605 };
1606 let syntax = config.syntax_type_name(tag.syntax_type_id);
1607 if tag.is_definition {
1608 defs.push(TagDef {
1609 name: name.to_owned(),
1610 kind: tag_node_kind(syntax),
1611 range: tag.range.clone(),
1612 docs: tag.docs.clone(),
1614 });
1615 } else if syntax == "call" || syntax == "send" {
1616 calls.push((tag.range.start, name.to_owned()));
1618 }
1619 }
1620
1621 let parents: Vec<Option<usize>> = (0..defs.len())
1626 .map(|i| smallest_enclosing(&defs, defs[i].range.clone(), Some(i)))
1627 .collect();
1628
1629 let keys: Vec<String> = (0..defs.len())
1630 .map(|i| {
1631 let qualified = qualified_name(&defs, &parents, i);
1632 format!("sym:{lang}:{path}#{qualified}")
1633 })
1634 .collect();
1635
1636 let mut nodes = vec![file_node(path, blob_id, bytes, Some(lang), ingest)];
1637 let mut edges: Vec<Edge> = Vec::new();
1638
1639 for (i, d) in defs.iter().enumerate() {
1640 let mut meta = serde_json::Map::new();
1641 if let Some(doc) = &d.docs {
1642 let content = cap_content(doc);
1643 if !content.is_empty() {
1644 meta.insert("content".into(), serde_json::Value::from(content));
1645 }
1646 }
1647 if d.kind == NodeKind::Fn {
1650 let mut names: Vec<String> = calls
1651 .iter()
1652 .filter(|(off, _)| d.range.contains(off))
1653 .filter(|(off, _)| smallest_enclosing_off(&defs, *off) == Some(i))
1654 .map(|(_, name)| name.clone())
1655 .collect();
1656 names.sort();
1657 names.dedup();
1658 if !names.is_empty() {
1659 meta.insert("calls".into(), serde_json::Value::from(names));
1660 }
1661 }
1662
1663 let start = u32::try_from(d.range.start).unwrap_or(u32::MAX);
1664 let end = u32::try_from(d.range.end).unwrap_or(u32::MAX);
1665 nodes.push(Node {
1666 key: keys[i].clone(),
1667 kind: d.kind.clone(),
1668 name: d.name.clone(),
1669 path: Some(path.to_owned()),
1670 lang: Some(lang.to_owned()),
1671 blob_hash: Some(blob_id.to_owned()),
1672 span: Some(Span::new(start, end)),
1673 provenance: Provenance::Derived,
1674 meta: serde_json::Value::Object(meta),
1675 });
1676
1677 match parents[i] {
1678 Some(p) => edges.push(Edge::derived(
1679 keys[p].clone(),
1680 keys[i].clone(),
1681 EdgeKind::Contains,
1682 )),
1683 None => edges.push(Edge::derived(
1684 file_key(path),
1685 keys[i].clone(),
1686 EdgeKind::Defines,
1687 )),
1688 }
1689 }
1690
1691 append_import_facts(path, &def, bytes, &mut nodes, &mut edges);
1693
1694 nodes.sort_by(|a, b| a.key.cmp(&b.key));
1697 nodes.dedup_by(|a, b| a.key == b.key);
1698 edges.sort_by(|a, b| (a.kind.as_str(), &a.src, &a.dst).cmp(&(b.kind.as_str(), &b.src, &b.dst)));
1699 edges.dedup();
1700 Some(FactSet { nodes, edges })
1701}
1702
1703fn smallest_enclosing(
1706 defs: &[TagDef],
1707 range: std::ops::Range<usize>,
1708 skip: Option<usize>,
1709) -> Option<usize> {
1710 let mut best: Option<usize> = None;
1711 for (j, c) in defs.iter().enumerate() {
1712 if Some(j) == skip {
1713 continue;
1714 }
1715 let encloses = c.range.start <= range.start
1717 && c.range.end >= range.end
1718 && (c.range.end - c.range.start) > (range.end - range.start);
1719 if encloses
1720 && best.is_none_or(|b| {
1721 defs[b].range.end - defs[b].range.start > c.range.end - c.range.start
1722 })
1723 {
1724 best = Some(j);
1725 }
1726 }
1727 best
1728}
1729
1730fn smallest_enclosing_off(defs: &[TagDef], off: usize) -> Option<usize> {
1732 let mut best: Option<usize> = None;
1733 for (j, c) in defs.iter().enumerate() {
1734 if c.range.contains(&off)
1735 && best.is_none_or(|b| {
1736 defs[b].range.end - defs[b].range.start > c.range.end - c.range.start
1737 })
1738 {
1739 best = Some(j);
1740 }
1741 }
1742 best
1743}
1744
1745fn qualified_name(defs: &[TagDef], parents: &[Option<usize>], i: usize) -> String {
1748 let mut chain: Vec<&str> = vec![defs[i].name.as_str()];
1749 let mut cur = parents[i];
1750 let mut guard = defs.len();
1753 while let Some(p) = cur {
1754 if guard == 0 {
1755 break;
1756 }
1757 guard -= 1;
1758 chain.push(defs[p].name.as_str());
1759 cur = parents[p];
1760 }
1761 chain.reverse();
1762 chain.join("::")
1763}
1764
1765fn span(node: tree_sitter::Node) -> Span {
1767 let start = u32::try_from(node.start_byte()).unwrap_or(u32::MAX);
1768 let end = u32::try_from(node.end_byte()).unwrap_or(u32::MAX);
1769 Span::new(start, end)
1770}
1771
1772fn qualify(scope: &[Scope], name: &str) -> String {
1774 let mut parts: Vec<&str> = scope.iter().map(|s| s.seg.as_str()).collect();
1775 parts.push(name);
1776 parts.join("::")
1777}
1778
1779fn qualify_callee(qualifier: Option<&str>, name: &str) -> String {
1784 match qualifier {
1785 Some(q) if !q.is_empty() && !matches!(q, "self" | "crate" | "super") => {
1786 format!("{q}::{name}")
1787 }
1788 _ => name.to_owned(),
1789 }
1790}
1791
1792fn extend(scope: &[Scope], seg: &str, key: Option<String>) -> Vec<Scope> {
1794 let mut next: Vec<Scope> = scope
1795 .iter()
1796 .map(|s| Scope {
1797 seg: s.seg.clone(),
1798 key: s.key.clone(),
1799 })
1800 .collect();
1801 next.push(Scope {
1802 seg: seg.to_owned(),
1803 key,
1804 });
1805 next
1806}
1807
1808#[cfg(test)]
1809mod tests {
1810 use super::{Extractor, FileNodeExtractor, Registry, RustExtractor};
1811 use crate::{EdgeKind, Node, NodeKind};
1812
1813 #[test]
1814 fn file_node_extractor_is_deterministic_and_tagged() {
1815 let ex = FileNodeExtractor;
1816 let a = ex.extract("src/lib.rs", "abc123", b"one\ntwo\n");
1817 let b = ex.extract("src/lib.rs", "abc123", b"one\ntwo\n");
1818 assert_eq!(a, b, "extraction must be deterministic");
1819
1820 assert_eq!(a.nodes.len(), 1);
1821 assert!(a.edges.is_empty());
1822 let node = &a.nodes[0];
1823 assert_eq!(node.key, "file:src/lib.rs");
1824 assert_eq!(node.kind, NodeKind::File);
1825 assert_eq!(node.name, "lib.rs");
1826 assert_eq!(node.blob_hash.as_deref(), Some("abc123"));
1827 assert_eq!(node.meta["lines"], 2);
1828 assert_eq!(node.meta["bytes"], 8);
1829 }
1830
1831 #[test]
1832 fn config_files_emit_config_key_nodes() {
1833 let reg = Registry::new(crate::IngestConfig::default());
1834 let toml = b"[serve]\naddr = \"0.0.0.0:8443\"\ntools = false\n";
1835 let a = reg.extract("config.toml", "cfg1", toml);
1836 let b = reg.extract("config.toml", "cfg1", toml);
1837 assert_eq!(a, b, "config extraction must be deterministic");
1838
1839 assert!(a.nodes.iter().any(|n| n.key == "file:config.toml"));
1841 let addr = a
1842 .nodes
1843 .iter()
1844 .find(|n| n.key == "cfgkey:config.toml#serve.addr")
1845 .expect("serve.addr config_key node");
1846 assert_eq!(addr.kind, NodeKind::Other("config_key".into()));
1847 assert_eq!(addr.name, "serve.addr");
1848 assert_eq!(addr.meta["value"], "0.0.0.0:8443"); assert!(a.edges.iter().any(|e| {
1851 e.src == "file:config.toml"
1852 && e.dst == "cfgkey:config.toml#serve.addr"
1853 && e.kind == EdgeKind::Contains
1854 }));
1855
1856 let env = reg.extract(".env", "env1", b"PORT=8080\nPORT=9090\nAPI_TOKEN=s3cr3t\n");
1859 let port = env
1860 .nodes
1861 .iter()
1862 .find(|n| n.key == "cfgkey:.env#PORT")
1863 .expect("PORT node");
1864 assert_eq!(port.meta["value"], "9090", "dotenv last-one-wins");
1865 assert_eq!(
1866 env.nodes
1867 .iter()
1868 .filter(|n| n.key == "cfgkey:.env#PORT")
1869 .count(),
1870 1
1871 );
1872 let token = env
1873 .nodes
1874 .iter()
1875 .find(|n| n.key == "cfgkey:.env#API_TOKEN")
1876 .expect("API_TOKEN node");
1877 assert_eq!(token.meta["value"], "<redacted>", "secret not persisted");
1878 let rs = reg.extract("src/lib.rs", "x", b"pub fn f() {}\n");
1880 assert!(
1881 rs.nodes
1882 .iter()
1883 .all(|n| n.kind != NodeKind::Other("config_key".into()))
1884 );
1885 }
1886
1887 #[test]
1888 fn dockerfile_emits_image_ref_nodes_and_skips_internal_stages() {
1889 let reg = Registry::new(crate::IngestConfig::default());
1890 let df = b"FROM --platform=linux/amd64 rust:1.90 AS builder\nRUN cargo build\n\
1893 FROM builder AS test\nFROM registry.io/app:1.2@sha256:abc AS run\nFROM scratch\n";
1894 let a = reg.extract("Dockerfile", "d1", df);
1895 let b = reg.extract("Dockerfile", "d1", df);
1896 assert_eq!(a, b, "dockerfile extraction must be deterministic");
1897
1898 let refs: Vec<&Node> = a
1899 .nodes
1900 .iter()
1901 .filter(|n| n.kind == NodeKind::Other("image_ref".into()))
1902 .collect();
1903 assert_eq!(refs.len(), 2, "got: {refs:?}");
1906 let rust = refs
1907 .iter()
1908 .find(|n| n.meta["image"] == "rust")
1909 .expect("rust");
1910 assert_eq!(rust.meta["tag"], "1.90");
1911 let app = refs
1912 .iter()
1913 .find(|n| n.meta["image"] == "registry.io/app:1.2")
1914 .expect("app digest");
1915 assert_eq!(app.meta["digest"], "sha256:abc");
1916 assert!(
1918 a.edges
1919 .iter()
1920 .any(|e| { e.src == "file:Dockerfile" && e.kind == EdgeKind::References })
1921 );
1922 assert!(
1924 reg.extract("Dockerfile.prod", "d2", b"FROM alpine:3\n")
1925 .nodes
1926 .iter()
1927 .any(|n| n.kind == NodeKind::Other("image_ref".into()))
1928 );
1929
1930 let c = reg.extract("Dockerfile", "d3", b"FROM alpine AS alpine\n");
1933 assert!(
1934 c.nodes
1935 .iter()
1936 .any(|n| n.kind == NodeKind::Other("image_ref".into())
1937 && n.meta["image"] == "alpine"),
1938 "FROM x AS x is an external pin, got: {:?}",
1939 c.nodes
1940 );
1941 }
1942
1943 const SAMPLE: &str = r"
1944use std::path::Path;
1945
1946pub struct Store;
1947
1948impl Store {
1949 pub fn open() -> Store {
1950 helper();
1951 Store
1952 }
1953}
1954
1955fn helper() {}
1956
1957mod inner {
1958 pub fn nested() {}
1959}
1960";
1961
1962 fn keys(fs: &crate::FactSet) -> Vec<String> {
1963 let mut k: Vec<_> = fs.nodes.iter().map(|n| n.key.clone()).collect();
1964 k.sort();
1965 k
1966 }
1967
1968 #[test]
1969 fn rust_extractor_emits_symbols_and_edges() {
1970 let fs = RustExtractor.extract("src/lib.rs", "blob1", SAMPLE.as_bytes());
1971 let ks = keys(&fs);
1972 assert!(ks.contains(&"file:src/lib.rs".to_owned()));
1973 assert!(ks.contains(&"sym:rust:src/lib.rs#Store".to_owned()));
1974 assert!(ks.contains(&"sym:rust:src/lib.rs#Store::open".to_owned()));
1975 assert!(ks.contains(&"sym:rust:src/lib.rs#helper".to_owned()));
1976 assert!(ks.contains(&"sym:rust:src/lib.rs#inner".to_owned()));
1977 assert!(ks.contains(&"sym:rust:src/lib.rs#inner::nested".to_owned()));
1978
1979 let open = fs
1981 .nodes
1982 .iter()
1983 .find(|n| n.key == "sym:rust:src/lib.rs#Store::open")
1984 .expect("open node");
1985 assert_eq!(open.meta["calls"], serde_json::json!(["helper"]));
1986
1987 let defines: Vec<_> = fs
1989 .edges
1990 .iter()
1991 .filter(|e| e.kind == EdgeKind::Defines && e.dst == "sym:rust:src/lib.rs#helper")
1992 .collect();
1993 assert_eq!(defines.len(), 1);
1994 assert!(fs.edges.iter().any(|e| e.kind == EdgeKind::Contains
1995 && e.src == "sym:rust:src/lib.rs#inner"
1996 && e.dst == "sym:rust:src/lib.rs#inner::nested"));
1997
1998 assert!(fs.edges.iter().any(|e| e.kind == EdgeKind::Imports
2000 && e.src == "file:src/lib.rs"
2001 && e.dst == "import:rust:std::path::Path"));
2002 }
2003
2004 #[test]
2005 fn rust_extractor_records_struct_field_names() {
2006 let src = "pub struct ServeConfig {\n\
2009 \x20 pub addr: Option<String>,\n\
2010 \x20 pub tls_cert: Option<String>,\n\
2011 }\n\
2012 pub struct Pair(u8, u8);\n\
2013 pub struct Marker;\n";
2014 let fs = RustExtractor.extract("src/config.rs", "b", src.as_bytes());
2015 let fields = |key: &str| {
2016 fs.nodes
2017 .iter()
2018 .find(|n| n.key == key)
2019 .and_then(|n| n.meta.get("fields").cloned())
2020 };
2021 assert_eq!(
2022 fields("sym:rust:src/config.rs#ServeConfig"),
2023 Some(serde_json::json!(["addr", "tls_cert"])),
2024 "named fields captured in source order"
2025 );
2026 assert_eq!(fields("sym:rust:src/config.rs#Pair"), None);
2028 assert_eq!(fields("sym:rust:src/config.rs#Marker"), None);
2029 }
2030
2031 #[test]
2032 fn rust_extraction_is_deterministic() {
2033 let a = RustExtractor.extract("src/lib.rs", "blob1", SAMPLE.as_bytes());
2034 let b = RustExtractor.extract("src/lib.rs", "blob1", SAMPLE.as_bytes());
2035 assert_eq!(a, b);
2036 }
2037
2038 #[test]
2039 fn rust_extractor_captures_doc_comments() {
2040 let src = "/// The central store.\n\
2041 pub struct Store;\n\n\
2042 /// Opens it.\n\
2043 /// Reads the config.\n\
2044 pub fn open() {}\n\n\
2045 // not a doc comment\n\
2046 pub fn plain() {}\n";
2047 let fs = RustExtractor.extract("src/lib.rs", "b", src.as_bytes());
2048 let content = |key: &str| {
2049 fs.nodes
2050 .iter()
2051 .find(|n| n.key == key)
2052 .and_then(|n| n.meta.get("content"))
2053 .and_then(|v| v.as_str())
2054 .map(ToOwned::to_owned)
2055 };
2056 assert_eq!(
2057 content("sym:rust:src/lib.rs#Store").as_deref(),
2058 Some("The central store.")
2059 );
2060 assert_eq!(
2061 content("sym:rust:src/lib.rs#open").as_deref(),
2062 Some("Opens it. Reads the config.")
2063 );
2064 assert_eq!(content("sym:rust:src/lib.rs#plain"), None);
2066 }
2067
2068 #[test]
2069 fn prose_file_captures_capped_body() {
2070 let md = FileNodeExtractor.extract("docs/x.md", "b", b"# Title\n\nSome prose here.\n");
2071 assert_eq!(md.nodes[0].meta["content"], "# Title Some prose here.");
2072 let rs = FileNodeExtractor.extract("notes.bin", "b", b"\x00\x01binary");
2074 assert!(rs.nodes[0].meta.get("content").is_none());
2075 let upper = FileNodeExtractor.extract("README.MD", "b", b"# Hi\n");
2077 assert_eq!(upper.nodes[0].meta["content"], "# Hi");
2078 }
2079
2080 #[cfg(feature = "pdf-text")]
2083 fn minimal_pdf(text: &str) -> Vec<u8> {
2084 let content = format!("BT /F1 24 Tf 72 720 Td ({text}) Tj ET");
2085 let objects = [
2086 "<< /Type /Catalog /Pages 2 0 R >>".to_owned(),
2087 "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(),
2088 "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_owned(),
2089 format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
2090 "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_owned(),
2091 ];
2092 let mut pdf = Vec::new();
2093 pdf.extend_from_slice(b"%PDF-1.4\n");
2094 let mut offsets = Vec::new();
2095 for (i, obj) in objects.iter().enumerate() {
2096 offsets.push(pdf.len());
2097 pdf.extend_from_slice(format!("{} 0 obj\n{obj}\nendobj\n", i + 1).as_bytes());
2098 }
2099 let xref_start = pdf.len();
2100 pdf.extend_from_slice(
2101 format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes(),
2102 );
2103 for off in &offsets {
2104 pdf.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
2105 }
2106 pdf.extend_from_slice(
2107 format!(
2108 "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_start}\n%%EOF\n",
2109 objects.len() + 1
2110 )
2111 .as_bytes(),
2112 );
2113 pdf
2114 }
2115
2116 #[cfg(feature = "pdf-text")]
2117 #[test]
2118 fn pdf_file_captures_text_content() {
2119 let pdf = minimal_pdf("Hello Roteiro");
2120 let facts = FileNodeExtractor.extract("docs/guide.pdf", "b", &pdf);
2121 let content = facts.nodes[0].meta["content"].as_str().unwrap();
2122 assert!(content.contains("Hello Roteiro"), "got: {content:?}");
2123 let upper = FileNodeExtractor.extract("docs/Guide.PDF", "b", &pdf);
2125 assert!(upper.nodes[0].meta.get("content").is_some());
2126 let bad = FileNodeExtractor.extract("docs/bad.pdf", "b", b"%PDF-1.4\ngarbage");
2128 assert!(bad.nodes[0].meta.get("content").is_none());
2129 }
2130
2131 #[cfg(any(feature = "image-ocr", feature = "image-vision"))]
2132 #[test]
2133 fn image_content_guards_before_touching_models() {
2134 assert!(super::is_image("shot.PNG"));
2136 assert!(super::is_image("b.jpeg"));
2137 assert!(super::is_image("c.jpg"));
2138 assert!(!super::is_image("d.gif"));
2139 assert!(
2141 super::image_content("notes.txt", b"hello", super::IngestConfig::default()).is_none()
2142 );
2143 let big = vec![0u8; super::MAX_IMAGE_BYTES + 1];
2145 assert!(super::image_content("shot.png", &big, super::IngestConfig::default()).is_none());
2146 }
2147
2148 #[test]
2149 fn doc_comment_body_recognises_doc_markers() {
2150 assert_eq!(super::doc_comment_body("/// hi").as_deref(), Some("hi"));
2151 assert_eq!(
2152 super::doc_comment_body("//! mod doc").as_deref(),
2153 Some("mod doc")
2154 );
2155 assert_eq!(
2156 super::doc_comment_body("/** block */").as_deref(),
2157 Some("block")
2158 );
2159 assert_eq!(super::doc_comment_body("// plain"), None);
2161 assert_eq!(super::doc_comment_body("//// header"), None);
2162 assert_eq!(super::doc_comment_body("/**/").as_deref(), Some(""));
2164 assert_eq!(super::doc_comment_body("/*!*/").as_deref(), Some(""));
2165 }
2166
2167 #[test]
2168 fn registry_dispatches_by_extension() {
2169 let rs = Registry::default().extract("src/lib.rs", "b", SAMPLE.as_bytes());
2170 assert!(rs.nodes.len() > 1, "rust file yields symbols");
2171 let txt = Registry::default().extract("notes.txt", "b", b"hello\n");
2172 assert_eq!(
2173 txt.nodes.len(),
2174 1,
2175 "non-code file falls back to a file node"
2176 );
2177 assert_eq!(txt.nodes[0].kind, NodeKind::File);
2178 }
2179
2180 #[test]
2181 fn tags_extracts_python_symbols_calls_and_nesting() {
2182 let src = "def helper():\n pass\n\nclass Thing:\n def run(self):\n helper()\n";
2183 let fs = Registry::default().extract("app.py", "b", src.as_bytes());
2184
2185 let names: Vec<&str> = fs.nodes.iter().map(|n| n.name.as_str()).collect();
2186 assert!(names.contains(&"helper"), "top-level function");
2187 assert!(names.contains(&"Thing"), "class");
2188 assert!(names.contains(&"run"), "method");
2189
2190 assert_eq!(
2192 fs.nodes
2193 .iter()
2194 .find(|n| n.name == "helper")
2195 .and_then(|n| n.lang.as_deref()),
2196 Some("python")
2197 );
2198
2199 assert!(
2201 fs.edges
2202 .iter()
2203 .any(|e| e.kind == EdgeKind::Contains && e.dst.ends_with("#Thing::run")),
2204 "method nested under class via containment"
2205 );
2206
2207 let run = fs.nodes.iter().find(|n| n.name == "run").unwrap();
2209 let calls = run.meta.get("calls").and_then(|v| v.as_array()).unwrap();
2210 assert!(
2211 calls.iter().any(|c| c.as_str() == Some("helper")),
2212 "enclosed call captured in meta.calls"
2213 );
2214 }
2215
2216 #[test]
2217 fn tags_extraction_is_deterministic() {
2218 let src = b"package main\nfunc Add(a int) int { return a }\n";
2219 let a = Registry::default().extract("m.go", "b", src);
2220 let b = Registry::default().extract("m.go", "b", src);
2221 assert_eq!(a, b, "tags extraction must be deterministic");
2222 assert!(
2223 a.nodes
2224 .iter()
2225 .any(|n| n.name == "Add" && n.kind == NodeKind::Fn)
2226 );
2227 }
2228
2229 #[test]
2230 fn tags_extracts_typescript() {
2231 let ts = Registry::default().extract("svc.ts", "b", b"export class Svc {\n run() {}\n}\n");
2232 assert!(ts.nodes.iter().any(|n| n.name == "Svc"), "class");
2233 assert!(ts.nodes.iter().any(|n| n.name == "run"), "method");
2234 assert_eq!(
2235 ts.nodes
2236 .iter()
2237 .find(|n| n.name == "Svc")
2238 .and_then(|n| n.lang.as_deref()),
2239 Some("typescript")
2240 );
2241 }
2242
2243 fn import_targets(path: &str, src: &[u8]) -> Vec<String> {
2247 Registry::default()
2248 .extract(path, "b", src)
2249 .nodes
2250 .iter()
2251 .filter(|n| n.kind == NodeKind::Other("import".into()))
2252 .inspect(|n| {
2253 assert!(
2254 n.path.is_none(),
2255 "import node must not be file-scoped: {}",
2256 n.key
2257 );
2258 })
2259 .map(|n| n.key.clone())
2260 .collect()
2261 }
2262
2263 #[test]
2264 fn extracts_imports_edges_per_language() {
2265 let cases: &[(&str, &[u8], &[&str])] = &[
2268 (
2269 "app.py",
2270 b"import os\nfrom a.b import c\nimport x.y as z\n",
2271 &["import:python:os", "import:python:a.b", "import:python:x.y"],
2272 ),
2273 (
2274 "m.js",
2275 b"import foo from \"./mod.js\";\nexport { y } from \"./y.js\";\n",
2276 &["import:javascript:./mod.js", "import:javascript:./y.js"],
2277 ),
2278 (
2279 "svc.ts",
2280 b"import { A } from \"./a\";\n",
2281 &["import:typescript:./a"],
2282 ),
2283 (
2284 "m.go",
2285 b"package main\nimport (\n\t\"fmt\"\n\t\"os\"\n)\n",
2286 &["import:go:fmt", "import:go:os"],
2287 ),
2288 (
2289 "M.java",
2290 b"import java.util.List;\nimport static a.B.c;\n",
2291 &["import:java:java.util.List", "import:java:a.B.c"],
2292 ),
2293 (
2294 "m.c",
2295 b"#include <stdio.h>\n#include \"local.h\"\n",
2296 &["import:c:stdio.h", "import:c:local.h"],
2297 ),
2298 ("m.cpp", b"#include <vector>\n", &["import:cpp:vector"]),
2299 ];
2300 for (path, src, expected) in cases {
2301 let got = import_targets(path, src);
2302 for want in *expected {
2303 assert!(
2304 got.iter().any(|k| k == want),
2305 "{path}: expected import node {want}, got {got:?}"
2306 );
2307 }
2308 let fs = Registry::default().extract(path, "b", src);
2310 for want in *expected {
2311 assert!(
2312 fs.edges.iter().any(|e| e.kind == EdgeKind::Imports
2313 && e.src == format!("file:{path}")
2314 && &e.dst == want),
2315 "{path}: expected Imports edge to {want}"
2316 );
2317 }
2318 }
2319 }
2320
2321 #[test]
2322 fn every_registered_language_query_compiles() {
2323 for ext in [
2327 "py", "js", "ts", "tsx", "go", "rb", "java", "c", "cpp", "cs", "php", "scala", "ml",
2328 "mli", "ex", "sh", "sql",
2329 ] {
2330 let def = super::tag_lang_for(ext).unwrap_or_else(|| panic!("no language for .{ext}"));
2331 let lang = def.lang;
2332 assert!(
2333 super::tag_config(&def).is_some(),
2334 "tags query for .{ext} ({lang}) must compile against its grammar"
2335 );
2336 }
2337 }
2338
2339 #[test]
2340 fn ocaml_impl_and_interface_cache_under_distinct_grammars() {
2341 let ml = super::tag_lang_for("ml").unwrap();
2345 let mli = super::tag_lang_for("mli").unwrap();
2346 assert_eq!(ml.lang, "ocaml");
2347 assert_eq!(mli.lang, "ocaml");
2348 assert_ne!(
2349 ml.grammar_key, mli.grammar_key,
2350 "distinct grammars must cache separately"
2351 );
2352 }
2353
2354 #[test]
2355 fn tags_extracts_vendored_bash_query() {
2356 let src = "greet() {\n echo hi\n}\nmain() {\n greet\n}\n";
2357 let fs = Registry::default().extract("run.sh", "b", src.as_bytes());
2358 let names: Vec<&str> = fs.nodes.iter().map(|n| n.name.as_str()).collect();
2359 assert!(names.contains(&"greet"), "shell function greet");
2360 assert!(names.contains(&"main"), "shell function main");
2361
2362 let main = fs.nodes.iter().find(|n| n.name == "main").unwrap();
2364 assert!(
2365 main.meta
2366 .get("calls")
2367 .and_then(|v| v.as_array())
2368 .is_some_and(|c| c.iter().any(|x| x.as_str() == Some("greet"))),
2369 "internal command invocation captured"
2370 );
2371 }
2372
2373 #[test]
2374 fn tags_extracts_vendored_sql_query() {
2375 let src = "CREATE TABLE users (id int);\n\
2376 CREATE FUNCTION recent() RETURNS int AS $$ SELECT total(id) FROM users $$ LANGUAGE sql;\n";
2377 let fs = Registry::default().extract("schema.sql", "b", src.as_bytes());
2378 let names: Vec<&str> = fs.nodes.iter().map(|n| n.name.as_str()).collect();
2379 assert!(names.contains(&"users"), "table definition");
2380 assert!(names.contains(&"recent"), "function definition");
2381
2382 assert_eq!(
2384 fs.nodes.iter().find(|n| n.name == "users").map(|n| &n.kind),
2385 Some(&NodeKind::Other("table".to_owned()))
2386 );
2387 let f = fs.nodes.iter().find(|n| n.name == "recent").unwrap();
2389 assert!(
2390 f.meta
2391 .get("calls")
2392 .and_then(|v| v.as_array())
2393 .is_some_and(|c| c.iter().any(|x| x.as_str() == Some("total"))),
2394 "invocation inside function captured in meta.calls"
2395 );
2396 assert_eq!(
2397 fs.nodes
2398 .iter()
2399 .find(|n| n.name == "users")
2400 .and_then(|n| n.lang.as_deref()),
2401 Some("sql")
2402 );
2403 }
2404
2405 #[test]
2406 fn ingest_prose_toggle_gates_embedded_content() {
2407 use super::IngestConfig;
2408
2409 let content = |ingest: IngestConfig| {
2410 Registry::new(ingest)
2411 .extract("notes.md", "b", b"# Title\n\nBody text.\n")
2412 .nodes[0]
2413 .meta
2414 .get("content")
2415 .and_then(|v| v.as_str())
2416 .map(str::to_owned)
2417 };
2418
2419 assert!(
2421 content(IngestConfig::default()).is_some_and(|c| c.contains("Body text")),
2422 "prose content embedded by default"
2423 );
2424 assert_eq!(
2425 content(IngestConfig {
2426 prose: false,
2427 ..IngestConfig::default()
2428 }),
2429 None,
2430 "disabling prose suppresses the embedded body"
2431 );
2432 }
2433
2434 #[test]
2435 fn env_tag_stable_by_default_and_shifts_when_gated() {
2436 use super::IngestConfig;
2437
2438 let all_on = Registry::new(IngestConfig::default()).env_tag();
2441 assert_eq!(all_on, Registry::default().env_tag());
2442
2443 let no_prose = Registry::new(IngestConfig {
2446 prose: false,
2447 ..IngestConfig::default()
2448 })
2449 .env_tag();
2450 let no_pdf = Registry::new(IngestConfig {
2451 pdf: false,
2452 ..IngestConfig::default()
2453 })
2454 .env_tag();
2455 let no_audio = Registry::new(IngestConfig {
2456 audio: false,
2457 ..IngestConfig::default()
2458 })
2459 .env_tag();
2460 assert_ne!(no_prose, all_on);
2461 assert_ne!(no_pdf, all_on);
2462 assert_ne!(no_audio, all_on);
2463 assert_ne!(no_prose, no_pdf);
2464 assert_ne!(no_audio, no_prose);
2465 assert_ne!(no_audio, no_pdf);
2466 }
2467}