Skip to main content

rto_graph/
extract.rs

1//! Extraction: turning the bytes of a source blob into a [`FactSet`].
2//!
3//! Extraction must be a deterministic pure function of `(path, blob_id, bytes)`
4//! so its output can be cached; because the facts are path-dependent (node keys
5//! are path-scoped), the cache is keyed by both path and blob id (see
6//! [`crate::sync`]). [`Registry`] dispatches by file extension to a
7//! language-aware extractor ([`RustExtractor`]), falling back to
8//! [`FileNodeExtractor`] for files with no registered language.
9//!
10//! Language extractors emit `defines`/`contains`/`imports` edges directly, and
11//! record each function's callee names in the caller node's `meta.calls`. Call
12//! *edges* are resolved later, at assembly time, once every file's symbols are
13//! known (see [`crate::sync`]) — a single blob cannot resolve cross-file calls.
14
15use crate::{Edge, EdgeKind, FactSet, Node, NodeKind, Span};
16
17/// Version of the extraction *output* (node/edge shape and captured `meta`).
18/// Bump whenever extraction changes what it produces, so the content-addressed
19/// cache (keyed by blob oid + path) does not serve stale facts for an unchanged
20/// blob — the version is folded into the cache key. See [`crate::sync`].
21///
22/// The `pdf-text`, `image-ocr`, and `image-vision` features change what
23/// PDFs/images extract to, so each occupies a distinct version namespace: a
24/// feature build and a default build never serve each other stale (content-bearing
25/// vs content-free) facts from a shared cache. (Image output also depends on
26/// *which* models are installed; that runtime state is folded into the cache key
27/// separately — see [`image_env_tag`] and [`crate::sync`].)
28pub(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
37/// Max characters of embeddable content (markdown body / doc-comment / PDF text)
38/// captured into a node's `meta.content`, to keep the store small while giving
39/// inference real text to embed.
40const MAX_CONTENT: usize = 1500;
41
42/// PDFs larger than this are not text-extracted — `pdf-extract` builds the full
43/// document text in memory, so cap the work a pathological file can impose.
44#[cfg(feature = "pdf-text")]
45const MAX_PDF_BYTES: usize = 20 * 1024 * 1024;
46
47/// Images larger than this (compressed bytes) are not processed (OCR/VLM).
48#[cfg(any(feature = "image-ocr", feature = "image-vision"))]
49const MAX_IMAGE_BYTES: usize = 20 * 1024 * 1024;
50
51/// Images with more pixels than this are not processed — OCR/VLM time scales with
52/// pixel count, and this also guards against decompression bombs (the dimension is
53/// read from the header before the pixels are decoded).
54#[cfg(any(feature = "image-ocr", feature = "image-vision"))]
55const MAX_IMAGE_PIXELS: u64 = 4096 * 4096;
56
57/// When OCR yields fewer than this many words, the image is treated as
58/// text-sparse (a diagram/photo rather than a text screenshot), so the vision
59/// model is run to describe it (only when `image-vision` is also enabled).
60#[cfg(feature = "image-vision")]
61const MIN_OCR_WORDS: usize = 8;
62
63/// Turns one source blob into the nodes and edges derived from it.
64pub trait Extractor {
65    /// Extract a [`FactSet`] from a blob's `path`, git `blob_id`, and `bytes`.
66    ///
67    /// Implementations must be deterministic: identical inputs must always
68    /// produce an identical fact set.
69    fn extract(&self, path: &str, blob_id: &str, bytes: &[u8]) -> FactSet;
70
71    /// Runtime inputs — beyond `(path, bytes)` — that change extraction output
72    /// and so must be folded into the sync cache key: the installed image-model
73    /// identity (OCR + vision) and any [`IngestConfig`] toggles. The default is
74    /// the image-model tag alone; [`Registry`] additionally folds in its
75    /// ingestion config so toggling content off re-extracts affected blobs
76    /// instead of serving stale, content-bearing facts.
77    fn env_tag(&self) -> u64 {
78        image_env_tag()
79    }
80}
81
82/// Runtime ingestion toggles (ADR-0007 `[ingest]`): which blob content is
83/// extracted for embedding. Every toggle defaults to **on**, and a toggle only
84/// gates content *within a build that supports it* — turning `pdf` on cannot
85/// extract PDF text in a binary built without the `pdf-text` feature, but
86/// turning it off suppresses that content in a binary that has it.
87// Four independent content toggles: a flat bool-per-class struct is the clearest
88// representation (a state enum or bitflags would obscure, not clarify).
89#[allow(clippy::struct_excessive_bools)]
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub struct IngestConfig {
92    /// Embed the UTF-8 body of prose files (Markdown, plain text).
93    pub prose: bool,
94    /// Extract text from PDF documents (needs the `pdf-text` feature).
95    pub pdf: bool,
96    /// OCR literal text from images (needs the `image-ocr` feature).
97    pub ocr: bool,
98    /// Describe images with a vision model (needs the `image-vision` feature).
99    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    /// A cache-key contribution that is **`0` when every toggle is on** (the
115    /// default), so the common case leaves existing cache keys untouched. Each
116    /// disabled toggle sets a distinct bit, so turning content off changes the
117    /// key and re-extracts affected blobs.
118    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/// Dispatches extraction to a language-aware extractor by file extension,
127/// falling back to a plain file node when no language is registered. After the
128/// language extractor runs, [`crate::markers`] appends any intent-debt markers
129/// (TODOs, stubs, deferred-work notes) found in the blob. Carries the runtime
130/// [`IngestConfig`] applied to content extraction.
131#[derive(Debug, Clone, Copy, Default)]
132pub struct Registry {
133    /// Which blob content to extract for embedding.
134    pub ingest: IngestConfig,
135}
136
137impl Registry {
138    /// A registry with the given ingestion toggles.
139    #[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            // All-on default: preserve existing cache keys exactly.
157            img
158        } else {
159            // FNV-1a fold of both components — deterministic and stable. As with
160            // any 64-bit hash a collision with the all-on key is possible but
161            // vanishingly unlikely, and a collision only costs a spurious cache
162            // hit/miss, never incorrect facts.
163            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
173/// Shared extraction dispatch used by [`Registry`] and the standalone
174/// extractors: pick the language extractor by extension, applying `ingest` to
175/// content extraction.
176fn 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
183/// Lowercase file extension of `path`, if any. Lowercasing makes extension
184/// dispatch case-insensitive, so `Guide.PDF` and `README.MD` are recognised.
185fn 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
191/// The natural key of the `file` node for `path`.
192fn file_key(path: &str) -> String {
193    format!("file:{path}")
194}
195
196/// Build the shared `file` node for a source blob. `ingest` gates which content
197/// is embedded (ADR-0007 `[ingest]`): a disabled class yields no content, as if
198/// the file carried none.
199fn 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    // Capture the (capped) body so inference embeds *meaning*, not just the
213    // filename: prose files decode as UTF-8; PDFs go through `pdf_content` (only
214    // when the `pdf-text` feature is on, otherwise it is a no-op). Each class is
215    // gated by its `ingest` toggle so a project can suppress it without a rebuild.
216    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
240/// Strip doc-comment markers from a comment, returning its body — or `None` if
241/// it is not a doc comment. Recognises `///` (but not `////`), `//!`, `/** */`,
242/// and `/*! */`; a plain `//` or `/* */` comment returns `None`.
243fn 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        // Content lies between the 3-char opener (`/**`/`/*!`) and the 2-char
250        // closer (`*/`). Guard the overlap on tiny comments like `/**/`, where
251        // the opener and closer share a `*` — those have no body.
252        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/// Extract the text of a PDF blob for embedding, or `None` when `path` is not a
265/// PDF, the `pdf-text` feature is off, the file is too large, or extraction
266/// yields no usable text.
267///
268/// `pdf-extract` handles fonts/CMaps internally but can panic on some malformed
269/// documents; the call is panic-guarded so a bad PDF degrades to a plain file
270/// node rather than aborting the whole sync.
271#[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/// No-op when the `pdf-text` feature is off: PDFs become plain file nodes.
284#[cfg(not(feature = "pdf-text"))]
285fn pdf_content(_path: &str, _bytes: &[u8]) -> Option<String> {
286    None
287}
288
289/// Embeddable content for an image blob, composing OCR text and an optional
290/// vision-model description (see [`ocr_content`]/[`vlm_content`]), or `None` when
291/// `path` is not an image, the image is too large, no image model is installed,
292/// or nothing is produced.
293///
294/// Both extractors read the *installed* image models — that runtime dependency is
295/// reflected in the cache key via [`image_env_tag`], so installing/upgrading a
296/// model re-extracts affected images instead of serving stale (content-free)
297/// facts.
298#[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    // OCR reads literal text (cheap, accurate); the vision model *describes* the
304    // image (slow). Smart composition (ADR-0005): always OCR; run the VLM only
305    // when OCR text is sparse — a diagram/photo rather than a text screenshot —
306    // and store both when both fire. Each stage is additionally gated by its
307    // `ingest` toggle so a project can disable OCR and/or vision at runtime.
308    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/// No-op when neither image feature is on: images become plain file nodes.
326#[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/// The word count below which OCR output is "sparse" enough to invoke the VLM.
332/// `usize::MAX` when `image-vision` is off, so the VLM is never triggered.
333#[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/// Whether `path` is an image OCR/vision can read.
346#[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/// Whether the image's pixel dimensions (read from its header, without decoding
352/// the pixels — so a decompression bomb is rejected cheaply) are within
353/// [`MAX_IMAGE_PIXELS`]. `false` if the header cannot be parsed or the limit is
354/// exceeded.
355#[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/// OCR an image's text (or `None` when `image-ocr` is off, the models are not
368/// installed, the image is too large, or extraction yields nothing). The `ocrs`
369/// engine can panic on some inputs, so the call is panic-guarded.
370#[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        // Models not installed → OCR is inert (run `roteiro model pull ocrs-text`).
377        return None;
378    }
379    // Borrow `bytes` into the guarded closure — no need to clone the (up to
380    // 20 MiB) image. `&[u8]`/`&Path` are unwind-safe, so no `AssertUnwindSafe`.
381    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/// Run detection + recognition over an image's bytes, returning its text.
393/// Fallible steps collapse to `None` (a bad image yields no content).
394#[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/// Describe an image with the local vision model (or `None` when `image-vision`
418/// is off, the model is not installed, the image is too large, or generation
419/// yields nothing).
420///
421/// The model is loaded fresh per image so its KV cache never carries over between
422/// images. It is a large (~1.4 GiB) model and generation is slow, so this is the
423/// opt-in, slow path (images in a repo are typically few); loading once across a
424/// sync is a future optimisation.
425#[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        // Not installed → vision is inert (run `roteiro model pull moondream2`).
433        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/// A cache-key component reflecting the image extractors' runtime environment:
446/// `0` when neither image feature is on or no models are installed, else a hash
447/// of the installed OCR/vision model identities. Folded into the sync cache key
448/// so installing/upgrading a model re-extracts affected images instead of serving
449/// stale facts (image output is not a pure function of the blob alone). See
450/// [`crate::sync`].
451#[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/// If model `name` is fully installed, fold its host-variant checksums into
467/// `hash` and return `true`. Only the host-selected variant is hashed, so an
468/// unrelated platform variant does not perturb this host's tag.
469#[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/// `0` whenever neither image feature is compiled in.
490#[cfg(not(any(feature = "image-ocr", feature = "image-vision")))]
491pub(crate) fn image_env_tag() -> u64 {
492    0
493}
494
495/// Whether `path` is a prose file whose body is worth embedding.
496fn is_prose(path: &str) -> bool {
497    matches!(
498        extension(path).as_deref(),
499        Some("md" | "markdown" | "txt" | "rst" | "adoc")
500    )
501}
502
503/// Trim and cap `text` to [`MAX_CONTENT`] characters (whitespace-collapsed), so
504/// stored content stays small and deterministic.
505fn cap_content(text: &str) -> String {
506    let mut out = String::with_capacity(text.len().min(MAX_CONTENT));
507    // Track the character count incrementally — `out.chars().count()` per
508    // iteration would make this O(n²) on long inputs.
509    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/// Fallback extractor: emits a single `file` node per blob, tagged with its blob
531/// hash and basic size metadata. Produces no edges. Used for files with no
532/// registered language.
533#[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/// Derived extractor for Rust source, backed by tree-sitter. Emits a `file`
549/// node, one symbol node per `fn`/`struct`/`enum`/`trait`/`mod` (and a few
550/// others) with `defines`/`contains` edges reflecting lexical nesting, and
551/// `imports` edges for `use` declarations. Each function records the simple
552/// names it calls in `meta.calls` for later cross-file resolution.
553#[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
562/// Extract Rust facts, applying `ingest` to the file node's embedded content.
563/// Shared by [`RustExtractor`] (default toggles) and [`Registry`] (its config).
564fn rust_facts(path: &str, blob_id: &str, bytes: &[u8], ingest: IngestConfig) -> FactSet {
565    let mut parser = tree_sitter::Parser::new();
566    // The Rust grammar is compiled in, so this only fails on a version
567    // mismatch — a build-time invariant, not a runtime input error.
568    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    // Deterministic ordering so the cached fact set is byte-stable regardless of
593    // traversal incidentals.
594    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
603/// One entry on the lexical scope stack: a name segment and, when the scope is
604/// itself an emitted symbol, that symbol's key (impl blocks contribute a segment
605/// but no node, so their `key` is `None`).
606struct Scope {
607    seg: String,
608    key: Option<String>,
609}
610
611/// Accumulating state for a single Rust file walk.
612struct 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    /// Visit one AST node under the given lexical scope stack.
622    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            // Recurse through unnamed structural wrappers (e.g. the top-level
636            // `declaration_list` of a module handled in `visit_symbol`).
637            _ => self.visit_children(node, scope),
638        }
639    }
640
641    /// Visit every named child of `node` under the same scope.
642    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    /// Emit a symbol node for a named definition, link it to its containing
651    /// scope, and recurse into its body for nested definitions.
652    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        // Capture the item's doc-comment so inference embeds what it *means*.
676        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        // Recurse into the body so nested items (a fn in a mod, etc.) are found,
693        // pushing this symbol onto the scope stack.
694        let child_scope = extend(scope, &self.simple(node, "name"), Some(key));
695        self.recurse_body(node, &child_scope);
696    }
697
698    /// The doc-comment (`///` / `//!` / `/** … */`) immediately preceding `node`,
699    /// concatenated, or `None`. Attributes between the comment and the item are
700    /// skipped; a non-doc comment (or any other node) ends the block.
701    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    /// An `impl` block emits no node but contributes its type name as a scope
726    /// segment, so methods qualify as `Type::method`.
727    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    /// Record a `use` declaration as an `imports` edge from the file to an
736    /// import-target node keyed by the (whitespace-normalised) import path.
737    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    /// Link a freshly-emitted symbol to its nearest enclosing emitted scope:
765    /// `contains` from that symbol, or `defines` from the file at top level.
766    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    /// Recurse into the `declaration_list` / body of a definition.
783    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    /// Collect the simple names of functions called anywhere within `node`'s
797    /// subtree (used for later call resolution).
798    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    /// The simple callee name for a `call_expression`'s function child:
812    /// `foo()` → `foo`, `a::b::foo()` → `foo`, `x.foo()` → `foo`.
813    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
840/// Byte span of an AST node, clamped to `u32`.
841fn 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
847/// Qualified name for a new symbol: all enclosing scope segments plus `name`.
848fn 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
854/// Push a scope entry, returning the extended stack.
855fn 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        // `open` records that it calls `helper`.
930        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        // file defines top-level items; a module contains its nested fn.
938        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        // the `use` becomes an imports edge.
949        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        // A plain `//` comment is not captured.
988        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        // A non-prose file gets no content.
996        let rs = FileNodeExtractor.extract("notes.bin", "b", b"\x00\x01binary");
997        assert!(rs.nodes[0].meta.get("content").is_none());
998        // Extension matching is case-insensitive: `README.MD` is prose too.
999        let upper = FileNodeExtractor.extract("README.MD", "b", b"# Hi\n");
1000        assert_eq!(upper.nodes[0].meta["content"], "# Hi");
1001    }
1002
1003    /// Build a one-page PDF with a single Helvetica text run, computing exact
1004    /// byte offsets for the xref table so `pdf-extract` can parse it.
1005    #[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        // Extension matching is case-insensitive: `Guide.PDF` extracts too.
1047        let upper = FileNodeExtractor.extract("docs/Guide.PDF", "b", &pdf);
1048        assert!(upper.nodes[0].meta.get("content").is_some());
1049        // A malformed PDF degrades to a plain file node — no panic, no content.
1050        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        // Case-insensitive image detection.
1058        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        // A non-image path returns None without ever looking for models.
1063        assert!(
1064            super::image_content("notes.txt", b"hello", super::IngestConfig::default()).is_none()
1065        );
1066        // An oversized image is rejected by the size guard, before model lookup.
1067        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        // Plain and `////` comments are not docs.
1083        assert_eq!(super::doc_comment_body("// plain"), None);
1084        assert_eq!(super::doc_comment_body("//// header"), None);
1085        // Degenerate block comments have an empty body, never garbage like "/".
1086        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        // Default (prose on) embeds the markdown body; disabling prose drops it.
1118        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        // All-on is the default: its tag must equal a plain `Registry` so existing
1137        // caches are untouched.
1138        let all_on = Registry::new(IngestConfig::default()).env_tag();
1139        assert_eq!(all_on, Registry::default().env_tag());
1140
1141        // Each disabled toggle changes the tag (forcing re-extraction), and
1142        // distinct disabled sets produce distinct tags.
1143        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}