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, Provenance, 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 `audio-metadata` features change what PDFs,
23/// images and audio blobs extract to, so each occupies a distinct version
24/// namespace: a feature build and a default build never serve each other stale
25/// (content-bearing vs content-free) facts from a shared cache. (OCR output also depends on *which* models are
26/// installed; that runtime state is folded into the cache key separately — see
27/// [`media_env_tag`] and [`crate::sync`].)
28///
29/// `image-vision` and `audio-transcribe` deliberately have **no namespace here
30/// any more**: since ADR-0015 they change nothing about extraction output, so
31/// they must not perturb a cache key. What they produce is generated content,
32/// which lives in [`crate::media`].
33///
34/// # Changing this number
35///
36/// **No test pins its value, deliberately.** A bump is the correct response to a
37/// real change in extraction output, so it must not also be a test failure —
38/// pinning it made every legitimate bump land on whoever tripped the guard, who
39/// then had to work out whether they had broken an invariant or merely renumbered
40/// a constant. Tests assert what the version is *for* instead: that it is folded
41/// into the cache key (`sync::tests::cache_key_separates_paths_but_is_stable`),
42/// that a changed identity re-extracts at an unchanged tree, and that work which
43/// is not extraction cannot perturb it
44/// (`tests/sync.rs::memory_writes_do_not_invalidate_the_fact_cache`). So if a
45/// test *does* fail when you bump this, it is reporting a real coupling, not the
46/// number. Record the bump in the history comment below and in the ADR that
47/// motivates it; that record, and review, are what keep bumps honest.
48// Bumped 5 → 6 for config-key nodes (ADR-0009): config files now emit
49// `config_key` nodes, so cached extraction facts must be regenerated. Bumped
50// 6 → 7 for YAML config keys + Dockerfile `image_ref` nodes (ADR-0009 derived
51// deploy-artifact extraction). Bumped 7 → 8 for struct `meta.fields` (the named
52// field list a struct declares) — the signal the config_key→struct follow bridge
53// joins on, so cached struct facts must be regenerated to carry it. Bumped 8 → 9
54// for struct `meta.field_types` / `meta.config_root` and the `config_key` nodes
55// synthesized from a `@rto:config`-marked config-root struct's declared fields
56// (see [`RustWalk::synthesize_config_keys`]), so cached facts regenerate to carry
57// these new nodes/meta. Bumped 9 → 10 for ADR-0015: ASR transcripts and VLM
58// descriptions are no longer written into `meta.content` at all, so every cached
59// fact set that carries one must be regenerated without it. The `image-vision`
60// (+400) and `audio-transcribe` (+800) namespaces are dropped in the same change,
61// because those features no longer affect extraction output. Bumped 10 → 11 for
62// ADR-0016: audio blobs now emit an `audio_stream` node carrying the container's
63// own account of the stream, so cached fact sets must be regenerated. The
64// `audio-metadata` namespace (+400) reoccupies `image-vision`'s retired slot —
65// safe because the base version moved with it, so no historical key can collide,
66// and because the namespaces are powers of ten *bit* values (100/200/400/800)
67// that must stay disjoint: +300 would alias a `pdf-text` + `image-ocr` build.
68// Bumped 11 → 12 for the marker-needle correction: the bare word `placeholder`
69// is no longer a `stub` needle (it scored 0% precision — 36 of 36 findings on
70// this repository named an implemented concept), replaced by the two phrases
71// that predicate incompleteness of an implementation. The next line names them,
72// and so carries the inline opt-out rather than reporting itself — the same
73// reason `markers.rs` carries the file-level one:
74// `placeholder implementation` / `returns a placeholder`.  roteiro:ignore
75// [`crate::markers::augment`] runs inside extraction, so every cached fact set
76// holding one of those 36 must be regenerated without it; without the bump a
77// cached blob keeps serving the phantom marker until its bytes happen to
78// change. No namespace moves: this is a base-version change only, unconditional
79// across every feature combination.
80pub(crate) const EXTRACT_VERSION: u32 = EXTRACT_BASE_VERSION
81    + if cfg!(feature = "pdf-text") { 100 } else { 0 }
82    + if cfg!(feature = "image-ocr") { 200 } else { 0 }
83    + if cfg!(feature = "audio-metadata") {
84        400
85    } else {
86        0
87    };
88
89/// The **generation** half of [`EXTRACT_VERSION`]: what a bump above counts, with
90/// no feature namespace added. Monotone, global, and identical in every build —
91/// which is what makes it, and not [`EXTRACT_VERSION`], the thing an entry's
92/// reachability can be decided against (see [`crate::sync::sweep_superseded`]).
93///
94/// The split was always there, encoded in the arithmetic; naming it only makes
95/// it readable. `EXTRACT_VERSION` is unchanged in every build: this is the same
96/// `12` the sum has always started from.
97pub(crate) const EXTRACT_BASE_VERSION: u32 = 12;
98
99/// The stride between feature namespaces above. Each of the three
100/// extraction-affecting features occupies a distinct power-of-ten *bit* slot
101/// (100/200/400 — see the history above), so a namespace is always a whole
102/// multiple of this and the base is always the remainder.
103pub(crate) const FEATURE_NAMESPACE_STRIDE: u32 = 100;
104
105// The key grammar depends on this: `v{EXTRACT_VERSION}` is decodable back into
106// (base, namespace) only while the base stays below the stride. It has always
107// depended on it — a base of 100 with no features would have written the same
108// `v100` as a base of 0 in a `pdf-text` build, aliasing two generations onto one
109// key — so this asserts an existing invariant rather than adding one. If the
110// base ever approaches 100, widen the stride (and the namespaces with it) in the
111// same change; do not let it wrap.
112const _: () = assert!(
113    EXTRACT_BASE_VERSION < FEATURE_NAMESPACE_STRIDE,
114    "EXTRACT_BASE_VERSION must stay below FEATURE_NAMESPACE_STRIDE, or a version \
115     tag stops decoding into (generation, feature namespace)"
116);
117
118/// Max characters of embeddable content (markdown body / doc-comment / PDF text)
119/// captured into a node's `meta.content`, to keep the store small while giving
120/// inference real text to embed.
121const MAX_CONTENT: usize = 1500;
122
123/// PDFs larger than this are not text-extracted — `pdf-extract` builds the full
124/// document text in memory, so cap the work a pathological file can impose.
125#[cfg(feature = "pdf-text")]
126const MAX_PDF_BYTES: usize = 20 * 1024 * 1024;
127
128/// Images with more pixels than this are not processed — OCR/VLM time scales with
129/// pixel count, and this also guards against decompression bombs (the dimension is
130/// read from the header before the pixels are decoded).
131#[cfg(any(feature = "image-ocr", feature = "image-vision"))]
132const MAX_IMAGE_PIXELS: u64 = 4096 * 4096;
133
134/// Turns one source blob into the nodes and edges derived from it.
135pub trait Extractor {
136    /// Extract a [`FactSet`] from a blob's `path`, git `blob_id`, and `bytes`.
137    ///
138    /// Implementations must be deterministic: identical inputs must always
139    /// produce an identical fact set.
140    fn extract(&self, path: &str, blob_id: &str, bytes: &[u8]) -> FactSet;
141
142    /// Runtime inputs — beyond `(path, bytes)` — that change extraction output
143    /// and so must be folded into the sync cache key: the installed OCR-model
144    /// identity and any [`IngestConfig`] toggles that gate *extraction*. The
145    /// default is the media-model tag alone; [`Registry`] additionally folds in
146    /// its ingestion config so toggling content off re-extracts affected blobs
147    /// instead of serving stale, content-bearing facts.
148    fn env_tag(&self) -> u64 {
149        media_env_tag()
150    }
151}
152
153/// Runtime ingestion toggles (ADR-0007 `[ingest]`). Every toggle defaults to
154/// **on**, and a toggle only gates content *within a build that supports it* —
155/// turning `pdf` on cannot extract PDF text in a binary built without the
156/// `pdf-text` feature, but turning it off suppresses that content in a binary
157/// that has it.
158///
159/// The five toggles split into two groups, and the split is the ADR-0015
160/// boundary:
161///
162/// - `prose`, `pdf` and `ocr` gate **extraction**: what is decoded from the bytes
163///   into `meta.content` as a `derived` fact. They contribute to the extraction
164///   cache key, because turning one off changes what extraction produces.
165/// - `vision` and `audio` gate **generation**: whether `roteiro media build` may
166///   invoke a model at all. They no longer touch extraction, so they contribute
167///   nothing to the cache key — a repository that sets `audio = false` gets
168///   exactly the derived facts it would get with it on.
169// Five independent content toggles: a flat bool-per-class struct is the clearest
170// representation (a state enum or bitflags would obscure, not clarify).
171#[allow(clippy::struct_excessive_bools)]
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub struct IngestConfig {
174    /// Embed the UTF-8 body of prose files (Markdown, plain text).
175    pub prose: bool,
176    /// Extract text from PDF documents (needs the `pdf-text` feature).
177    pub pdf: bool,
178    /// OCR literal text from images (needs the `image-ocr` feature).
179    pub ocr: bool,
180    /// Allow `roteiro media build` to describe images with a vision model (needs
181    /// the `image-vision` feature). Since ADR-0015 this gates *generation*, not
182    /// extraction: a description is never written to `meta.content`.
183    pub vision: bool,
184    /// Allow `roteiro media build` to transcribe spoken-word audio (needs the
185    /// `audio-transcribe` feature). Gates *generation*, as `vision` does.
186    pub audio: bool,
187}
188
189impl Default for IngestConfig {
190    fn default() -> Self {
191        Self {
192            prose: true,
193            pdf: true,
194            ocr: true,
195            vision: true,
196            audio: true,
197        }
198    }
199}
200
201impl IngestConfig {
202    /// A cache-key contribution that is **`0` when every extraction toggle is
203    /// on** (the default), so the common case leaves existing cache keys
204    /// untouched. Each disabled toggle sets a distinct bit, so turning content
205    /// off changes the key and re-extracts affected blobs.
206    ///
207    /// Only the *extraction* toggles appear. `vision` and `audio` gate
208    /// generation, which no consumer of this key can observe (ADR-0015), and
209    /// folding them in would force a full re-extraction for a setting that
210    /// changes no derived fact.
211    fn disabled_bits(self) -> u64 {
212        u64::from(!self.prose) | (u64::from(!self.pdf) << 1) | (u64::from(!self.ocr) << 2)
213    }
214
215    /// Whether this configuration permits `roteiro media build` to run `kind`.
216    /// An operator can disable generation outright without touching the graph.
217    #[must_use]
218    pub fn generates(self, kind: crate::media::MediaKind) -> bool {
219        match kind {
220            crate::media::MediaKind::Audio => self.audio,
221            crate::media::MediaKind::Vision => self.vision,
222        }
223    }
224}
225
226/// Dispatches extraction to a language-aware extractor by file extension,
227/// falling back to a plain file node when no language is registered. After the
228/// language extractor runs, [`crate::markers`] appends any intent-debt markers
229/// (intent-debt markers) found in the blob. Carries the runtime
230/// [`IngestConfig`] applied to content extraction.
231#[derive(Debug, Clone, Copy, Default)]
232pub struct Registry {
233    /// Which blob content to extract for embedding.
234    pub ingest: IngestConfig,
235}
236
237impl Registry {
238    /// A registry with the given ingestion toggles.
239    #[must_use]
240    pub fn new(ingest: IngestConfig) -> Self {
241        Self { ingest }
242    }
243}
244
245impl Extractor for Registry {
246    fn extract(&self, path: &str, blob_id: &str, bytes: &[u8]) -> FactSet {
247        let mut facts = extract_facts(path, blob_id, bytes, self.ingest);
248        crate::markers::augment(&mut facts, path, blob_id, bytes);
249        facts
250    }
251
252    fn env_tag(&self) -> u64 {
253        let media = media_env_tag();
254        let disabled = self.ingest.disabled_bits();
255        if disabled == 0 {
256            // All-on default: preserve existing cache keys exactly.
257            media
258        } else {
259            // FNV-1a fold of both components — deterministic and stable. As with
260            // any 64-bit hash a collision with the all-on key is possible but
261            // vanishingly unlikely, and a collision only costs a spurious cache
262            // hit/miss, never incorrect facts.
263            let mut h = 0xcbf2_9ce4_8422_2325u64;
264            for b in media
265                .to_le_bytes()
266                .into_iter()
267                .chain(disabled.to_le_bytes())
268            {
269                h ^= u64::from(b);
270                h = h.wrapping_mul(0x0000_0100_0000_01b3);
271            }
272            h
273        }
274    }
275}
276
277/// Shared extraction dispatch used by [`Registry`] and the standalone
278/// extractors: pick the language extractor by extension, applying `ingest` to
279/// content extraction.
280fn extract_facts(path: &str, blob_id: &str, bytes: &[u8], ingest: IngestConfig) -> FactSet {
281    // Config files (TOML / JSON / .env) get config-key nodes rather than a plain
282    // file node, so their keys are first-class graph nodes (ADR-0009).
283    if crate::config_keys::is_config_path(path) {
284        return config_facts(path, blob_id, bytes, ingest);
285    }
286    // Dockerfiles yield `image_ref` nodes (the base-image version pin a spoke
287    // deploys) rather than a plain file node (ADR-0009 derived facts).
288    if is_dockerfile(path) {
289        return dockerfile_facts(path, blob_id, bytes, ingest);
290    }
291    // Audio blobs additionally yield an `audio_stream` node carrying what the
292    // container says about them (ADR-0016). Without the `audio-metadata` feature
293    // this produces exactly the plain file node the extension dispatch below
294    // would have produced, so the default build's output is unchanged.
295    if crate::media::is_audio(path) {
296        return audio_facts(path, blob_id, bytes, ingest);
297    }
298    let ext = extension(path);
299    match ext.as_deref() {
300        // Rust keeps its dedicated AST walker (imports, impl scoping, richer calls).
301        Some("rs") => rust_facts(path, blob_id, bytes, ingest),
302        // Every other supported language goes through the generic tags extractor;
303        // an unhandled extension (or a query that fails to compile) falls back to
304        // a plain file node.
305        Some(ext) => tag_facts(path, blob_id, bytes, ext, ingest).unwrap_or_else(|| {
306            FactSet::new().with_node(file_node(path, blob_id, bytes, None, ingest))
307        }),
308        None => FactSet::new().with_node(file_node(path, blob_id, bytes, None, ingest)),
309    }
310}
311
312/// Lowercase file extension of `path`, if any. Lowercasing makes extension
313/// dispatch case-insensitive, so `Guide.PDF` and `README.MD` are recognised.
314///
315/// Shared with [`crate::media`], so the paths `media build` considers and the
316/// paths extraction classifies are decided by one function rather than two that
317/// can drift.
318pub(crate) fn extension(path: &str) -> Option<String> {
319    let name = path.rsplit('/').next().unwrap_or(path);
320    name.rsplit_once('.')
321        .map(|(_, ext)| ext.to_ascii_lowercase())
322}
323
324/// The natural key of the `file` node for `path`.
325fn file_key(path: &str) -> String {
326    format!("file:{path}")
327}
328
329/// Build the shared `file` node for a source blob. `ingest` gates which content
330/// is embedded (ADR-0007 `[ingest]`): a disabled class yields no content, as if
331/// the file carried none.
332fn file_node(
333    path: &str,
334    blob_id: &str,
335    bytes: &[u8],
336    lang: Option<&str>,
337    ingest: IngestConfig,
338) -> Node {
339    let name = path.rsplit('/').next().unwrap_or(path).to_owned();
340    let lines = bytes
341        .iter()
342        .fold(0usize, |n, &b| n + usize::from(b == b'\n'));
343    let end = u32::try_from(bytes.len()).unwrap_or(u32::MAX);
344    let mut meta = serde_json::json!({ "bytes": bytes.len(), "lines": lines });
345    // Capture the (capped) body so inference embeds *meaning*, not just the
346    // filename: prose files decode as UTF-8; PDFs go through `pdf_content` (only
347    // when the `pdf-text` feature is on, otherwise it is a no-op). Each class is
348    // gated by its `ingest` toggle so a project can suppress it without a rebuild.
349    //
350    // Every branch here **decodes text that exists in the bytes** — that is the
351    // whole membership rule (ADR-0015). Prose and PDF text are parses; OCR is
352    // discriminative, and its errors are misreadings correctable against the
353    // image. An ASR transcript and a VLM description are neither: they are
354    // generated, they invent fluent text where there is nothing to read, and they
355    // are therefore not `derived` facts. They are produced by `roteiro media
356    // build` into [`crate::media`] instead, and nothing on this path may
357    // reintroduce them.
358    let content = if ingest.prose && is_prose(path) {
359        cap_content(&String::from_utf8_lossy(bytes))
360    } else if let Some(text) = ingest.pdf.then(|| pdf_content(path, bytes)).flatten() {
361        cap_content(&text)
362    } else if let Some(text) = image_content(path, bytes, ingest) {
363        cap_content(&text)
364    } else {
365        String::new()
366    };
367    if !content.is_empty() {
368        meta["content"] = serde_json::Value::from(content);
369    }
370    Node {
371        key: file_key(path),
372        kind: NodeKind::File,
373        name,
374        path: Some(path.to_owned()),
375        lang: lang.map(ToOwned::to_owned),
376        blob_hash: Some(blob_id.to_owned()),
377        span: Some(Span::new(0, end)),
378        provenance: Provenance::Derived,
379        meta,
380    }
381}
382
383/// Emit config-key facts for a config file (ADR-0009): the `file` node, plus a
384/// `config_key` node per flattened leaf — key `cfgkey:<path>#<dotted>`, name the
385/// dotted path, `meta` carrying the key and value — with a `contains` edge from
386/// the file. Deterministic: keys are de-duplicated (dotenv "last one wins") into
387/// a sorted map. Secret-looking values are redacted before they reach the store.
388fn config_facts(path: &str, blob_id: &str, bytes: &[u8], ingest: IngestConfig) -> FactSet {
389    let mut facts = FactSet::new().with_node(file_node(path, blob_id, bytes, None, ingest));
390    let file = file_key(path);
391    // A config file that repeats a key yields one node with the final value, and
392    // the emission order is deterministic regardless of parse order.
393    let mut by_key: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
394    for ck in crate::config_keys::flatten(path, bytes) {
395        by_key.insert(ck.key, ck.value);
396    }
397    for (key, value) in by_key {
398        let node_key = format!("cfgkey:{path}#{key}");
399        // Redact the value of secret-looking keys so tokens/passwords from
400        // `.env`/config files are never persisted into the (exportable) store.
401        let value = if crate::config_keys::is_secret_key(&key) {
402            crate::config_keys::REDACTED.to_owned()
403        } else {
404            value
405        };
406        let mut node = Node::new(
407            node_key.clone(),
408            NodeKind::Other(crate::config_keys::KIND.into()),
409            key.clone(),
410        );
411        node.path = Some(path.to_owned());
412        node.blob_hash = Some(blob_id.to_owned());
413        node.meta = serde_json::json!({ "key": key, "value": value });
414        facts = facts.with_node(node).with_edge(Edge::derived(
415            file.clone(),
416            node_key,
417            EdgeKind::Contains,
418        ));
419    }
420    facts
421}
422
423/// The `NodeKind::Other` token for a container base-image reference extracted from
424/// a Dockerfile `FROM` (ADR-0009 derived deploy-artifact facts). Its `meta` carries
425/// `{image, tag, digest}` — the version pin a spoke deploys.
426pub(crate) const IMAGE_REF_KIND: &str = "image_ref";
427
428/// Whether `path` is a Dockerfile/Containerfile (by conventional name):
429/// `Dockerfile`, `Containerfile`, `Dockerfile.<x>`, or `*.dockerfile`.
430fn is_dockerfile(path: &str) -> bool {
431    let base = path.rsplit('/').next().unwrap_or(path).to_ascii_lowercase();
432    base == "dockerfile"
433        || base == "containerfile"
434        || base.starts_with("dockerfile.")
435        || base.ends_with(".dockerfile")
436}
437
438/// Extract each Dockerfile `FROM` external base image into an `image_ref` node
439/// (`imageref:<file>#<n>`, `meta {image, tag, digest}`) with a `references` edge
440/// from the file — the version pin a deployment spoke ships. Internal multi-stage
441/// references (`FROM <prior-stage>`) and `FROM scratch` are skipped.
442fn dockerfile_facts(path: &str, blob_id: &str, bytes: &[u8], ingest: IngestConfig) -> FactSet {
443    let mut facts = FactSet::new().with_node(file_node(path, blob_id, bytes, None, ingest));
444    let file = file_key(path);
445    let text = String::from_utf8_lossy(bytes);
446    let mut stages: std::collections::HashSet<String> = std::collections::HashSet::new();
447    let mut idx = 0usize;
448    for line in text.lines() {
449        let Some(rest) = strip_from_prefix(line.trim()) else {
450            continue;
451        };
452        let (image, stage) = parse_from(rest);
453        // Decide whether the image is an earlier stage against the stages seen *so
454        // far*, before recording this line's own alias — otherwise `FROM x AS x`
455        // would wrongly treat the external image `x` as an internal stage.
456        let is_internal_stage = stages.contains(&image.to_ascii_lowercase());
457        if let Some(s) = stage {
458            stages.insert(s.to_ascii_lowercase());
459        }
460        // Skip `scratch` and references to an earlier build stage — neither is an
461        // external image to pin.
462        if image.is_empty() || image.eq_ignore_ascii_case("scratch") || is_internal_stage {
463            continue;
464        }
465        let (name, tag, digest) = split_image(image);
466        let node_key = format!("imageref:{path}#{idx}");
467        idx += 1;
468        let mut node = Node::new(
469            node_key.clone(),
470            NodeKind::Other(IMAGE_REF_KIND.into()),
471            image.to_owned(),
472        );
473        node.path = Some(path.to_owned());
474        node.blob_hash = Some(blob_id.to_owned());
475        node.meta = serde_json::json!({ "image": name, "tag": tag, "digest": digest });
476        facts = facts.with_node(node).with_edge(Edge::derived(
477            file.clone(),
478            node_key,
479            EdgeKind::References,
480        ));
481    }
482    facts
483}
484
485/// Emit the facts for an audio blob (ADR-0016): the usual `file` node, plus — in
486/// an `audio-metadata` build, where the container yielded anything — one
487/// `audio_stream` node under a `contains` edge from the file.
488///
489/// The metadata is a **format read**: codec, sample rate, bit depth, channels,
490/// duration and tags, with no decoder instantiated and no model consulted. That
491/// makes it a deterministic pure function of the bytes, which is what qualifies it
492/// as `derived` at all — the mirror image of ADR-0015, which moved *generated*
493/// text out of this path for failing exactly that test.
494///
495/// A blob the reader cannot make sense of contributes **no node**, rather than a
496/// node full of nulls: absence is recorded as absence.
497fn audio_facts(path: &str, blob_id: &str, bytes: &[u8], ingest: IngestConfig) -> FactSet {
498    let facts = FactSet::new().with_node(file_node(path, blob_id, bytes, None, ingest));
499    let Some(node) = audio_stream_node(path, blob_id, bytes) else {
500        return facts;
501    };
502    let node_key = node.key.clone();
503    facts
504        .with_node(node)
505        .with_edge(Edge::derived(file_key(path), node_key, EdgeKind::Contains))
506}
507
508/// The `audio_stream` node for one audio blob, or `None` when the container had
509/// nothing to say.
510///
511/// The facts land in `meta` as the serialised [`crate::audio::AudioFacts`], plus a
512/// rendered `meta.content` so [`crate::search`] finds them through the **ordinary**
513/// scorer — no new branch, and therefore no new ranking rule. Being `derived`, the
514/// node takes no `authored` boost.
515///
516/// Note what is *not* here: nothing is written to the audio **`file`** node's
517/// `meta.content`. That slot is the one ADR-0015 emptied of transcripts, and
518/// leaving it empty is what keeps "this audio file node carries content" an
519/// unambiguous statement.
520#[cfg(feature = "audio-metadata")]
521fn audio_stream_node(path: &str, blob_id: &str, bytes: &[u8]) -> Option<Node> {
522    let facts = crate::audio::read(bytes, extension(path).as_deref())?;
523    let mut meta = serde_json::to_value(&facts).ok()?;
524    // The searchable rendering, capped like every other `meta.content`. Written
525    // last so it cannot be shadowed by a field of the same name.
526    meta["content"] = serde_json::Value::from(cap_content(&facts.summary()));
527    let name = path.rsplit('/').next().unwrap_or(path).to_owned();
528    let mut node = Node::new(
529        format!("audio:{path}"),
530        NodeKind::Other(crate::audio::AUDIO_STREAM_KIND.into()),
531        name,
532    );
533    node.path = Some(path.to_owned());
534    node.blob_hash = Some(blob_id.to_owned());
535    node.span = Some(Span::new(0, u32::try_from(bytes.len()).unwrap_or(u32::MAX)));
536    node.meta = meta;
537    Some(node)
538}
539
540/// No-op without `audio-metadata`: an audio blob is a plain `file` node, exactly
541/// as it was before ADR-0016.
542#[cfg(not(feature = "audio-metadata"))]
543fn audio_stream_node(_path: &str, _blob_id: &str, _bytes: &[u8]) -> Option<Node> {
544    None
545}
546
547/// The remainder of a `FROM ` line (case-insensitive prefix), or `None`.
548fn strip_from_prefix(line: &str) -> Option<&str> {
549    let b = line.as_bytes();
550    (b.len() >= 5 && b[..4].eq_ignore_ascii_case(b"from") && b[4].is_ascii_whitespace())
551        .then(|| line[5..].trim_start())
552}
553
554/// Parse a `FROM` argument list into `(image, stage-alias)`: the first non-flag
555/// token is the image (leading `--platform=…` flags skipped), and an `AS <name>`
556/// suffix names the build stage.
557fn parse_from(rest: &str) -> (&str, Option<&str>) {
558    let image = rest
559        .split_whitespace()
560        .find(|t| !t.starts_with("--"))
561        .unwrap_or("");
562    let mut toks = rest.split_whitespace();
563    let mut stage = None;
564    while let Some(t) = toks.next() {
565        if t.eq_ignore_ascii_case("as") {
566            stage = toks.next();
567            break;
568        }
569    }
570    (image, stage)
571}
572
573/// Split an image reference into `(name, tag, digest)`. A `@sha256:…` digest wins;
574/// otherwise a tag is the `:`-suffix *after the last path segment* (so a registry
575/// `host:port/` prefix is never mistaken for a tag).
576fn split_image(image: &str) -> (String, Option<String>, Option<String>) {
577    if let Some((name, digest)) = image.split_once('@') {
578        return (name.to_owned(), None, Some(digest.to_owned()));
579    }
580    let seg = image.rfind('/').map_or(0, |i| i + 1);
581    if let Some(colon) = image[seg..].find(':') {
582        let at = seg + colon;
583        return (
584            image[..at].to_owned(),
585            Some(image[at + 1..].to_owned()),
586            None,
587        );
588    }
589    (image.to_owned(), None, None)
590}
591
592/// Strip doc-comment markers from a comment, returning its body — or `None` if
593/// it is not a doc comment. Recognises `///` (but not `////`), `//!`, `/** */`,
594/// and `/*! */`; a plain `//` or `/* */` comment returns `None`.
595fn doc_comment_body(raw: &str) -> Option<String> {
596    let t = raw.trim();
597    if t.starts_with("//!") || (t.starts_with("///") && !t.starts_with("////")) {
598        return Some(t[3..].trim().to_owned());
599    }
600    if (t.starts_with("/**") || t.starts_with("/*!")) && t.ends_with("*/") {
601        // Content lies between the 3-char opener (`/**`/`/*!`) and the 2-char
602        // closer (`*/`). Guard the overlap on tiny comments like `/**/`, where
603        // the opener and closer share a `*` — those have no body.
604        let end = t.len() - 2;
605        let inner = if end >= 3 { &t[3..end] } else { "" };
606        let cleaned: Vec<&str> = inner
607            .lines()
608            .map(|l| l.trim().trim_start_matches('*').trim())
609            .filter(|l| !l.is_empty())
610            .collect();
611        return Some(cleaned.join(" "));
612    }
613    None
614}
615
616/// Extract the text of a PDF blob for embedding, or `None` when `path` is not a
617/// PDF, the `pdf-text` feature is off, the file is too large, or extraction
618/// yields no usable text.
619///
620/// `pdf-extract` handles fonts/CMaps internally but can panic on some malformed
621/// documents; the call is panic-guarded so a bad PDF degrades to a plain file
622/// node rather than aborting the whole sync.
623#[cfg(feature = "pdf-text")]
624fn pdf_content(path: &str, bytes: &[u8]) -> Option<String> {
625    if extension(path).as_deref() != Some("pdf") || bytes.len() > MAX_PDF_BYTES {
626        return None;
627    }
628    let owned = bytes.to_vec();
629    let text = std::panic::catch_unwind(move || pdf_extract::extract_text_from_mem(&owned).ok())
630        .ok()
631        .flatten()?;
632    (!text.trim().is_empty()).then_some(text)
633}
634
635/// No-op when the `pdf-text` feature is off: PDFs become plain file nodes.
636#[cfg(not(feature = "pdf-text"))]
637fn pdf_content(_path: &str, _bytes: &[u8]) -> Option<String> {
638    None
639}
640
641/// Embeddable content for an image blob: the literal text OCR reads out of it,
642/// or `None` when `path` is not an image, the image is too large, the `ocr`
643/// toggle is off, the `image-ocr` feature is off, no OCR model is installed, or
644/// nothing is recognised.
645///
646/// **OCR only.** The vision model used to compose a description into this string
647/// when OCR came back sparse; since ADR-0015 it does not, because a description
648/// is generated rather than decoded. OCR stays because it is discriminative: it
649/// reads text that is *actually present*, and its errors are misreadings a human
650/// can correct against the image. The VLM now runs from `roteiro media build`
651/// into [`crate::media`], where its output is labelled and opt-in.
652///
653/// This reads the *installed* OCR models — that runtime dependency is reflected
654/// in the cache key via [`media_env_tag`], so installing/upgrading a model
655/// re-extracts affected images instead of serving stale (content-free) facts.
656#[cfg(feature = "image-ocr")]
657fn image_content(path: &str, bytes: &[u8], ingest: IngestConfig) -> Option<String> {
658    if !ingest.ocr || !crate::media::is_image(path) || bytes.len() > crate::media::MAX_IMAGE_BYTES {
659        return None;
660    }
661    ocr_content(bytes)
662}
663
664/// No-op without `image-ocr`: images become plain file nodes. An `image-vision`
665/// build lands here too — since ADR-0015 the vision model contributes nothing to
666/// extraction.
667#[cfg(not(feature = "image-ocr"))]
668fn image_content(_path: &str, _bytes: &[u8], _ingest: IngestConfig) -> Option<String> {
669    None
670}
671
672/// Whether the image's pixel dimensions (read from its header, without decoding
673/// the pixels — so a decompression bomb is rejected cheaply) are within
674/// [`MAX_IMAGE_PIXELS`]. `false` if the header cannot be parsed or the limit is
675/// exceeded.
676///
677/// Shared with the vision producer in [`crate::media::producers`], which applies
678/// the same guard before loading the projector.
679#[cfg(any(feature = "image-ocr", feature = "image-vision"))]
680pub(crate) fn image_dimensions_ok(bytes: &[u8]) -> bool {
681    let Ok(reader) = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format()
682    else {
683        return false;
684    };
685    match reader.into_dimensions() {
686        Ok((w, h)) => u64::from(w) * u64::from(h) <= MAX_IMAGE_PIXELS,
687        Err(_) => false,
688    }
689}
690
691/// OCR an image's text (or `None` when `image-ocr` is off, the models are not
692/// installed, the image is too large, or extraction yields nothing). The `ocrs`
693/// engine can panic on some inputs, so the call is panic-guarded.
694#[cfg(feature = "image-ocr")]
695fn ocr_content(bytes: &[u8]) -> Option<String> {
696    // Which OCR model *this repository* uses: `[models] ocr` if it pins one, else
697    // `ocrs-text` (Stage 33). A pin that cannot be honoured resolves to `None`
698    // and OCR goes inert, as it does for a model that is not installed;
699    // `roteiro config` is where the reason is stated, because `sync` walks a
700    // whole tree and repeating one configuration error per image would bury it.
701    let model = crate::model_choice::resolve(crate::model_choice::ModelTask::Ocr)
702        .ok()?
703        .model?;
704    let dir = crate::models::model_dir(model);
705    let detection = dir.join("text-detection.rten");
706    let recognition = dir.join("text-recognition.rten");
707    if !detection.exists() || !recognition.exists() || !image_dimensions_ok(bytes) {
708        // Models not installed → OCR is inert (run `roteiro model pull <model>`).
709        return None;
710    }
711    // Borrow `bytes` into the guarded closure — no need to clone the (up to
712    // 20 MiB) image. `&[u8]`/`&Path` are unwind-safe, so no `AssertUnwindSafe`.
713    let text = std::panic::catch_unwind(|| run_ocr(&detection, &recognition, bytes))
714        .ok()
715        .flatten()?;
716    (!text.trim().is_empty()).then_some(text)
717}
718
719/// Run detection + recognition over an image's bytes, returning its text.
720/// Fallible steps collapse to `None` (a bad image yields no content).
721#[cfg(feature = "image-ocr")]
722fn run_ocr(
723    detection: &std::path::Path,
724    recognition: &std::path::Path,
725    bytes: &[u8],
726) -> Option<String> {
727    use ocrs::{ImageSource, OcrEngine, OcrEngineParams};
728
729    let detection_model = rten::Model::load_file(detection).ok()?;
730    let recognition_model = rten::Model::load_file(recognition).ok()?;
731    let engine = OcrEngine::new(OcrEngineParams {
732        detection_model: Some(detection_model),
733        recognition_model: Some(recognition_model),
734        ..Default::default()
735    })
736    .ok()?;
737
738    let img = image::load_from_memory(bytes).ok()?.into_rgb8();
739    let source = ImageSource::from_bytes(img.as_raw(), img.dimensions()).ok()?;
740    let input = engine.prepare_input(source).ok()?;
741    engine.get_text(&input).ok()
742}
743
744/// Destroy the process-wide media engines (vision, ASR) that extraction loaded
745/// **and then** the llama.cpp backend they shared, returning whether anything was
746/// released.
747///
748/// Extraction loads each GGUF engine once and reuses it for the whole run
749/// (`vlm_engine` / `asr_engine`). Those engines own native llama.cpp/ggml state:
750/// on the Metal backend their GPU buffers stay registered in ggml-metal's device
751/// residency set until the engine is dropped, and if that has not happened by the
752/// time libc's C++ finalizers destroy ggml-metal's global device vector at
753/// `exit()`, `ggml_metal_rsets_free` asserts the set is empty and `abort()`s —
754/// a successful run exits 134 instead of 0 (issue #291).
755///
756/// So the engines are released **explicitly**, at a deterministic point that is
757/// still inside `main`. The `roteiro` binary does this through
758/// [`MediaEngineGuard`]; a library embedder that runs extraction should call this
759/// before its process exits. Idempotent, cheap, and a no-op when no engine was
760/// ever built (or when this build has no media features), so it is safe on every
761/// exit path.
762///
763/// Not a shutdown signal: an engine still borrowed by an in-flight extraction
764/// stays alive until that caller is done. Call it once the work is finished.
765///
766/// **Order matters, and is enforced rather than assumed.** Both engines share one
767/// process-wide llama.cpp backend (issue #296), which llama.cpp requires be freed
768/// *after* every model — so the backend is released last, here. It is not
769/// possible to get that wrong by editing this function: each engine holds an
770/// `Arc` on the backend, and `rto_llama::backend::release_shared_backend`
771/// declines while any handle is outstanding.
772// The return value is a fact about what happened, not a status to handle: exit
773// paths bind it to `_released` and move on, tests assert on it.
774#[must_use]
775pub fn release_media_engines() -> bool {
776    // Every step runs; none short-circuits the others.
777    let vision = release_vlm_engine();
778    let audio = release_asr_engine();
779    // Last, once the engines that borrowed it are gone.
780    let backend = release_llama_backend();
781    vision || audio || backend
782}
783
784/// Release the shared llama.cpp backend, or nothing in a build that has no
785/// llama.cpp at all.
786///
787/// A `serve`-only build reaches `rto-llama` without going through this crate, so
788/// `roteiro`'s `main` additionally holds a `rto_llama::backend::SharedBackendGuard`;
789/// both call the same idempotent release, and each covers the builds the other
790/// cannot see.
791#[cfg(any(feature = "image-vision", feature = "audio-transcribe"))]
792fn release_llama_backend() -> bool {
793    rto_llama::backend::release_shared_backend()
794}
795
796#[cfg(not(any(feature = "image-vision", feature = "audio-transcribe")))]
797fn release_llama_backend() -> bool {
798    false
799}
800
801/// Release the vision engine, or nothing in a build without `image-vision`.
802///
803/// The engine itself moved to [`crate::media::producers`] along with the
804/// generation it serves (ADR-0015). The *release* stays here, because this is the
805/// entry point `roteiro`'s `main` holds for the whole process, and splitting it
806/// would make the exit ordering (#291, #296) something two modules had to agree
807/// on rather than something one function states.
808fn release_vlm_engine() -> bool {
809    crate::media::producers::release_vlm_engine()
810}
811
812/// Release the ASR engine, or nothing in a build without `audio-transcribe`.
813fn release_asr_engine() -> bool {
814    crate::media::producers::release_asr_engine()
815}
816
817/// Ties the lifetime of the process-wide media engines — and, after them, the
818/// llama.cpp backend they share — to a scope: dropping the guard runs
819/// [`release_media_engines`].
820///
821/// Held for the whole of `roteiro`'s `main`, so the engines are destroyed while
822/// Rust is still running destructors — before the C++ finalizers that would
823/// otherwise abort the process (issue #291) — on the normal path, on an early
824/// `?` error, and on an unwinding panic alike.
825///
826/// `std::process::exit` skips destructors, so any path that exits that way must
827/// call [`release_media_engines`] itself first.
828#[derive(Debug)]
829pub struct MediaEngineGuard {
830    // A private field keeps the guard un-constructible except through `hold`,
831    // so it cannot be created (and dropped) by accident mid-run.
832    _private: (),
833}
834
835impl MediaEngineGuard {
836    /// Take ownership of the process-wide media engines for this scope.
837    #[must_use]
838    pub const fn hold() -> Self {
839        Self { _private: () }
840    }
841}
842
843impl Drop for MediaEngineGuard {
844    fn drop(&mut self) {
845        // Whether anything was resident is of no consequence here — the point is
846        // that nothing is, from now on.
847        let _released = release_media_engines();
848    }
849}
850
851// Mirror of the `ocr_content` stub: needed only when the image path is compiled
852// (image-ocr on) with image-vision off, not in an audio-only build.
853#[cfg(all(feature = "image-ocr", not(feature = "image-vision")))]
854fn vlm_content(_bytes: &[u8]) -> Option<String> {
855    None
856}
857
858/// A cache-key component reflecting the **extraction** models' runtime
859/// environment: `0` when no extraction model feature is on or no model is
860/// installed, else a hash of the installed OCR model identity. Folded into the
861/// sync cache key so installing/upgrading a model re-extracts affected images
862/// instead of serving stale facts (OCR output is not a pure function of the blob
863/// alone). See [`crate::sync`].
864///
865/// Only OCR is folded in. The vision and audio models used to be, because they
866/// wrote into `meta.content`; since ADR-0015 they do not, so their presence
867/// changes no derived fact and must not perturb a cache key. A machine that
868/// installs Voxtral no longer re-extracts its whole tree.
869///
870/// The model folded in is the **resolved** one, not the built-in default:
871/// repointing `[models] ocr` changes what extraction reads out of an image, so it
872/// has to move the cache key too, or the repository would keep serving text read
873/// by the model it no longer uses. With the key unset this is byte-identical to
874/// what it was before — the resolver returns `ocrs-text`.
875#[cfg(feature = "image-ocr")]
876pub(crate) fn media_env_tag() -> u64 {
877    let Some(model) = crate::model_choice::resolve(crate::model_choice::ModelTask::Ocr)
878        .ok()
879        .and_then(|choice| choice.model)
880    else {
881        return 0;
882    };
883    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
884    if fold_installed_model(&mut hash, model) {
885        hash | 1
886    } else {
887        0
888    }
889}
890
891/// If model `name` is fully installed, fold its host-variant checksums into
892/// `hash` and return `true`. Only the host-selected variant is hashed, so an
893/// unrelated platform variant does not perturb this host's tag.
894#[cfg(feature = "image-ocr")]
895fn fold_installed_model(hash: &mut u64, name: &str) -> bool {
896    let Some(variant) = crate::models::find(name)
897        .and_then(|spec| spec.variant_for(crate::models::Platform::host()))
898    else {
899        return false;
900    };
901    let dir = crate::models::model_dir(name);
902    if !variant.files.iter().all(|f| dir.join(f.name).exists()) {
903        return false;
904    }
905    for file in variant.files {
906        for b in file.sha256.bytes() {
907            *hash ^= u64::from(b);
908            *hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
909        }
910    }
911    true
912}
913
914/// `0` whenever no extraction-model feature is compiled in.
915#[cfg(not(feature = "image-ocr"))]
916pub(crate) fn media_env_tag() -> u64 {
917    0
918}
919
920/// Whether `path` is a prose file whose body is worth embedding.
921fn is_prose(path: &str) -> bool {
922    matches!(
923        extension(path).as_deref(),
924        Some("md" | "markdown" | "txt" | "rst" | "adoc")
925    )
926}
927
928/// Trim and cap `text` to [`MAX_CONTENT`] characters (whitespace-collapsed), so
929/// stored content stays small and deterministic.
930fn cap_content(text: &str) -> String {
931    let mut out = String::with_capacity(text.len().min(MAX_CONTENT));
932    // Track the character count incrementally — `out.chars().count()` per
933    // iteration would make this O(n²) on long inputs.
934    let mut chars = 0usize;
935    let mut last_was_space = true;
936    for c in text.chars() {
937        if chars >= MAX_CONTENT {
938            break;
939        }
940        if c.is_whitespace() {
941            if !last_was_space {
942                out.push(' ');
943                chars += 1;
944                last_was_space = true;
945            }
946        } else {
947            out.push(c);
948            chars += 1;
949            last_was_space = false;
950        }
951    }
952    out.trim().to_owned()
953}
954
955/// Fallback extractor: emits a single `file` node per blob, tagged with its blob
956/// hash and basic size metadata. Produces no edges. Used for files with no
957/// registered language.
958#[derive(Debug, Clone, Copy, Default)]
959pub struct FileNodeExtractor;
960
961impl Extractor for FileNodeExtractor {
962    fn extract(&self, path: &str, blob_id: &str, bytes: &[u8]) -> FactSet {
963        FactSet::new().with_node(file_node(
964            path,
965            blob_id,
966            bytes,
967            None,
968            IngestConfig::default(),
969        ))
970    }
971}
972
973/// Derived extractor for Rust source, backed by tree-sitter. Emits a `file`
974/// node, one symbol node per `fn`/`struct`/`enum`/`trait`/`mod` (and a few
975/// others) with `defines`/`contains` edges reflecting lexical nesting, and
976/// `imports` edges for `use` declarations. Each function records the (optionally
977/// scope-qualified) names it calls in `meta.calls` for later cross-file
978/// resolution — see [`RustWalk::callee_name`].
979#[derive(Debug, Clone, Copy, Default)]
980pub struct RustExtractor;
981
982impl Extractor for RustExtractor {
983    fn extract(&self, path: &str, blob_id: &str, bytes: &[u8]) -> FactSet {
984        rust_facts(path, blob_id, bytes, IngestConfig::default())
985    }
986}
987
988/// Extract Rust facts, applying `ingest` to the file node's embedded content.
989/// Shared by [`RustExtractor`] (default toggles) and [`Registry`] (its config).
990fn rust_facts(path: &str, blob_id: &str, bytes: &[u8], ingest: IngestConfig) -> FactSet {
991    let mut parser = tree_sitter::Parser::new();
992    // The Rust grammar is compiled in, so this only fails on a version
993    // mismatch — a build-time invariant, not a runtime input error.
994    if parser
995        .set_language(&tree_sitter_rust::LANGUAGE.into())
996        .is_err()
997    {
998        return FactSet::new().with_node(file_node(path, blob_id, bytes, None, ingest));
999    }
1000    let Some(tree) = parser.parse(bytes, None) else {
1001        return FactSet::new().with_node(file_node(path, blob_id, bytes, None, ingest));
1002    };
1003
1004    let mut walk = RustWalk {
1005        path,
1006        blob_id,
1007        src: bytes,
1008        nodes: vec![file_node(path, blob_id, bytes, Some("rust"), ingest)],
1009        edges: Vec::new(),
1010    };
1011    let root = tree.root_node();
1012    let mut cursor = root.walk();
1013    let children: Vec<_> = root.children(&mut cursor).collect();
1014    for child in children {
1015        walk.visit(child, &[]);
1016    }
1017    // Synthesize `config_key` nodes from any `@rto:config`-marked config-root struct
1018    // (ADR-0009): a code-defined config becomes matchable dotted keys without a
1019    // committed `*-example.toml` mirror. Runs after the walk so every struct in the
1020    // file is available to resolve nested field types.
1021    walk.synthesize_config_keys(root);
1022
1023    // Deterministic ordering so the cached fact set is byte-stable regardless of
1024    // traversal incidentals.
1025    walk.nodes.sort_by(|a, b| a.key.cmp(&b.key));
1026    walk.edges
1027        .sort_by(|a, b| (a.kind.as_str(), &a.src, &a.dst).cmp(&(b.kind.as_str(), &b.src, &b.dst)));
1028    FactSet {
1029        nodes: walk.nodes,
1030        edges: walk.edges,
1031    }
1032}
1033
1034/// One entry on the lexical scope stack: a name segment and, when the scope is
1035/// itself an emitted symbol, that symbol's key (impl blocks contribute a segment
1036/// but no node, so their `key` is `None`).
1037struct Scope {
1038    seg: String,
1039    key: Option<String>,
1040}
1041
1042/// One declared struct field: its name and the `type_identifier` tokens of its
1043/// type (outermost first). See [`RustWalk::struct_fields`].
1044struct FieldDef {
1045    name: String,
1046    type_idents: Vec<String>,
1047}
1048
1049/// Single-value **transparent** wrappers whose inner type is the "real" field type
1050/// for config purposes — a `zerobus: Option<ZerobusConfig>` still nests into
1051/// `ZerobusConfig`. Peeled by [`core_type_name`] / [`recursion_target`].
1052const TRANSPARENT_WRAPPERS: &[&str] = &[
1053    "Option", "Box", "Arc", "Rc", "Cow", "RefCell", "Cell", "Mutex", "RwLock",
1054];
1055
1056/// **Collection** wrappers: a `Vec<ItemConfig>` / `HashMap<_, _>` field serialises
1057/// to an array/table keyed by *runtime* index/key, not by nested struct fields, so
1058/// synthesis stops at the field itself (one leaf key) rather than inventing dotted
1059/// paths under it. Detecting one anywhere in a field's type makes it a leaf.
1060const COLLECTION_WRAPPERS: &[&str] = &[
1061    "Vec", "VecDeque", "HashMap", "BTreeMap", "HashSet", "BTreeSet", "IndexMap",
1062];
1063
1064/// The field's **core type name** for `meta.field_types`: the first type token that
1065/// is not a [`TRANSPARENT_WRAPPERS`] wrapper (so `Option<ZerobusConfig>` →
1066/// `ZerobusConfig`, `String` → `String`), or the outermost token if a wrapper is
1067/// all there is. `None` for a type with no identifier (a bare reference, tuple, …).
1068fn core_type_name(type_idents: &[String]) -> Option<String> {
1069    type_idents
1070        .iter()
1071        .find(|t| !TRANSPARENT_WRAPPERS.contains(&t.as_str()))
1072        .or_else(|| type_idents.first())
1073        .cloned()
1074}
1075
1076/// The struct name a field should **recurse into**, given the structs known in this
1077/// file (`known`), or `None` when the field is a config leaf. A collection wrapper
1078/// anywhere short-circuits to a leaf; transparent wrappers are peeled; the first
1079/// remaining token nests only if it names a known struct.
1080fn recursion_target<'a>(
1081    type_idents: &'a [String],
1082    known: &std::collections::BTreeMap<String, StructDef>,
1083) -> Option<&'a str> {
1084    for t in type_idents {
1085        if COLLECTION_WRAPPERS.contains(&t.as_str()) {
1086            return None;
1087        }
1088        if TRANSPARENT_WRAPPERS.contains(&t.as_str()) {
1089            continue;
1090        }
1091        return known.contains_key(t).then_some(t.as_str());
1092    }
1093    None
1094}
1095
1096/// A struct discovered in the file for config synthesis: its fields and whether it
1097/// carries the `@rto:config` root marker.
1098struct StructDef {
1099    fields: Vec<FieldDef>,
1100    is_root: bool,
1101}
1102
1103/// Guard against a pathological or cyclic type graph producing unbounded keys.
1104const MAX_CONFIG_DEPTH: usize = 16;
1105
1106/// Recursively expand a config struct into its dotted **leaf** keys. A field that
1107/// resolves to another known struct ([`recursion_target`]) descends with the field
1108/// name appended to `prefix`; every other field is a leaf recorded in `out`
1109/// (first-writer wins, tagged with the originating `root` for provenance). `visited`
1110/// tracks the current descent path so a cyclic type graph terminates (the cyclic
1111/// field falls back to a leaf) rather than recursing forever.
1112fn expand_config_keys(
1113    table: &std::collections::BTreeMap<String, StructDef>,
1114    struct_name: &str,
1115    prefix: &str,
1116    root: &str,
1117    visited: &mut std::collections::BTreeSet<String>,
1118    depth: usize,
1119    out: &mut std::collections::BTreeMap<String, String>,
1120) {
1121    let Some(def) = table.get(struct_name) else {
1122        return;
1123    };
1124    for f in &def.fields {
1125        let key = if prefix.is_empty() {
1126            f.name.clone()
1127        } else {
1128            format!("{prefix}.{}", f.name)
1129        };
1130        match recursion_target(&f.type_idents, table) {
1131            Some(inner) if depth < MAX_CONFIG_DEPTH && !visited.contains(inner) => {
1132                visited.insert(inner.to_owned());
1133                expand_config_keys(table, inner, &key, root, visited, depth + 1, out);
1134                visited.remove(inner);
1135            }
1136            _ => {
1137                out.entry(key).or_insert_with(|| root.to_owned());
1138            }
1139        }
1140    }
1141}
1142
1143/// Accumulating state for a single Rust file walk.
1144struct RustWalk<'a> {
1145    path: &'a str,
1146    blob_id: &'a str,
1147    src: &'a [u8],
1148    nodes: Vec<Node>,
1149    edges: Vec<Edge>,
1150}
1151
1152impl RustWalk<'_> {
1153    /// Visit one AST node under the given lexical scope stack.
1154    fn visit(&mut self, node: tree_sitter::Node, scope: &[Scope]) {
1155        match node.kind() {
1156            "function_item" => self.visit_symbol(node, scope, NodeKind::Fn, true),
1157            "struct_item" | "union_item" => self.visit_symbol(node, scope, NodeKind::Struct, false),
1158            "enum_item" => self.visit_symbol(node, scope, NodeKind::Enum, false),
1159            "trait_item" => self.visit_symbol(node, scope, NodeKind::Trait, false),
1160            "mod_item" => self.visit_symbol(node, scope, NodeKind::Module, false),
1161            "type_item" => self.visit_symbol(node, scope, NodeKind::Other("type".into()), false),
1162            "macro_definition" => {
1163                self.visit_symbol(node, scope, NodeKind::Other("macro".into()), false);
1164            }
1165            "impl_item" => self.visit_impl(node, scope),
1166            "use_declaration" => self.visit_use(node),
1167            // Recurse through unnamed structural wrappers (e.g. the top-level
1168            // `declaration_list` of a module handled in `visit_symbol`).
1169            _ => self.visit_children(node, scope),
1170        }
1171    }
1172
1173    /// Visit every named child of `node` under the same scope.
1174    fn visit_children(&mut self, node: tree_sitter::Node, scope: &[Scope]) {
1175        let mut cursor = node.walk();
1176        let children: Vec<_> = node.named_children(&mut cursor).collect();
1177        for child in children {
1178            self.visit(child, scope);
1179        }
1180    }
1181
1182    /// Emit a symbol node for a named definition, link it to its containing
1183    /// scope, and recurse into its body for nested definitions.
1184    fn visit_symbol(
1185        &mut self,
1186        node: tree_sitter::Node,
1187        scope: &[Scope],
1188        kind: NodeKind,
1189        collect_calls: bool,
1190    ) {
1191        let Some(name) = self.field_text(node, "name") else {
1192            return self.visit_children(node, scope);
1193        };
1194        let qualified = qualify(scope, &name);
1195        let key = format!("sym:rust:{}#{qualified}", self.path);
1196
1197        let mut meta = serde_json::Map::new();
1198        if collect_calls {
1199            let mut calls = Vec::new();
1200            self.collect_calls(node, &mut calls);
1201            calls.sort();
1202            calls.dedup();
1203            if !calls.is_empty() {
1204                meta.insert("calls".into(), serde_json::Value::from(calls));
1205            }
1206        }
1207        // Capture the item's doc-comment so inference embeds what it *means*.
1208        if let Some(doc) = self.doc_comment(node) {
1209            meta.insert("content".into(), serde_json::Value::from(doc));
1210        }
1211        // A struct/union records its NAMED field identifiers in `meta.fields` — the
1212        // signal the config_key→struct follow bridge joins on (a dotted config key's
1213        // leaf, e.g. `serve.addr`'s `addr`, must be a real field of the matched
1214        // struct before we bridge to it). Tuple/unit structs have no named fields
1215        // and add nothing; the key is omitted rather than emitted empty. Alongside,
1216        // `meta.field_types` maps each named field to its **core type name** (wrapper
1217        // types like `Option`/`Box` peeled — see [`core_type_name`]) so a later,
1218        // cross-file synthesizer can descend into nested config structs from the
1219        // stored graph alone; `meta.config_root` marks a struct authored with the
1220        // `@rto:config` signal as the root of a config tree (see
1221        // [`RustWalk::synthesize_config_keys`]).
1222        if matches!(node.kind(), "struct_item" | "union_item") {
1223            let defs = self.struct_fields(node);
1224            if !defs.is_empty() {
1225                let names: Vec<&str> = defs.iter().map(|f| f.name.as_str()).collect();
1226                meta.insert("fields".into(), serde_json::Value::from(names));
1227                let types: serde_json::Map<String, serde_json::Value> = defs
1228                    .iter()
1229                    .filter_map(|f| {
1230                        core_type_name(&f.type_idents).map(|t| (f.name.clone(), t.into()))
1231                    })
1232                    .collect();
1233                if !types.is_empty() {
1234                    meta.insert("field_types".into(), serde_json::Value::Object(types));
1235                }
1236            }
1237            if self.has_config_marker(node) {
1238                meta.insert("config_root".into(), serde_json::Value::Bool(true));
1239            }
1240        }
1241
1242        self.nodes.push(Node {
1243            key: key.clone(),
1244            kind,
1245            name,
1246            path: Some(self.path.to_owned()),
1247            lang: Some("rust".to_owned()),
1248            blob_hash: Some(self.blob_id.to_owned()),
1249            span: Some(span(node)),
1250            provenance: Provenance::Derived,
1251            meta: serde_json::Value::Object(meta),
1252        });
1253        self.link_parent(&key, scope);
1254
1255        // Recurse into the body so nested items (a fn in a mod, etc.) are found,
1256        // pushing this symbol onto the scope stack.
1257        let child_scope = extend(scope, &self.simple(node, "name"), Some(key));
1258        self.recurse_body(node, &child_scope);
1259    }
1260
1261    /// The doc-comment (`///` / `//!` / `/** … */`) immediately preceding `node`,
1262    /// concatenated, or `None`. Attributes between the comment and the item are
1263    /// skipped; a non-doc comment (or any other node) ends the block.
1264    fn doc_comment(&self, node: tree_sitter::Node) -> Option<String> {
1265        let mut parts: Vec<String> = Vec::new();
1266        let mut prev = node.prev_sibling();
1267        while let Some(n) = prev {
1268            match n.kind() {
1269                "line_comment" | "block_comment" => match doc_comment_body(self.text(n)) {
1270                    Some(body) => {
1271                        parts.push(body);
1272                        prev = n.prev_sibling();
1273                    }
1274                    None => break,
1275                },
1276                "attribute_item" => prev = n.prev_sibling(),
1277                _ => break,
1278            }
1279        }
1280        if parts.is_empty() {
1281            return None;
1282        }
1283        parts.reverse();
1284        let joined = cap_content(&parts.join(" "));
1285        (!joined.is_empty()).then_some(joined)
1286    }
1287
1288    /// An `impl` block emits no node but contributes its type name as a scope
1289    /// segment, so methods qualify as `Type::method`.
1290    fn visit_impl(&mut self, node: tree_sitter::Node, scope: &[Scope]) {
1291        let type_name = self
1292            .field_text(node, "type")
1293            .unwrap_or_else(|| "impl".to_owned());
1294        let child_scope = extend(scope, &type_name, None);
1295        self.recurse_body(node, &child_scope);
1296    }
1297
1298    /// Record a `use` declaration as an `imports` edge from the file to an
1299    /// import-target node keyed by the (whitespace-normalised) import path.
1300    fn visit_use(&mut self, node: tree_sitter::Node) {
1301        let Some(arg) = node.child_by_field_name("argument") else {
1302            return;
1303        };
1304        let text: String = self
1305            .text(arg)
1306            .chars()
1307            .filter(|c| !c.is_whitespace())
1308            .collect();
1309        if text.is_empty() {
1310            return;
1311        }
1312        let key = format!("import:rust:{text}");
1313        self.nodes.push(Node {
1314            key: key.clone(),
1315            kind: NodeKind::Other("import".into()),
1316            name: text,
1317            path: None,
1318            lang: Some("rust".to_owned()),
1319            blob_hash: None,
1320            span: None,
1321            provenance: Provenance::Derived,
1322            meta: serde_json::Value::Null,
1323        });
1324        self.edges
1325            .push(Edge::derived(file_key(self.path), key, EdgeKind::Imports));
1326    }
1327
1328    /// Link a freshly-emitted symbol to its nearest enclosing emitted scope:
1329    /// `contains` from that symbol, or `defines` from the file at top level.
1330    fn link_parent(&mut self, key: &str, scope: &[Scope]) {
1331        if let Some(parent) = scope.iter().rev().find_map(|s| s.key.as_deref()) {
1332            self.edges.push(Edge::derived(
1333                parent.to_owned(),
1334                key.to_owned(),
1335                EdgeKind::Contains,
1336            ));
1337        } else {
1338            self.edges.push(Edge::derived(
1339                file_key(self.path),
1340                key.to_owned(),
1341                EdgeKind::Defines,
1342            ));
1343        }
1344    }
1345
1346    /// The NAMED fields a struct/union declares, in source order — each an entry of
1347    /// its `field_declaration_list` carrying the declared field name plus the
1348    /// type-identifier tokens of its type (outermost first, e.g.
1349    /// `Option<ZerobusConfig>` → `["Option", "ZerobusConfig"]`). A tuple struct's
1350    /// positional fields carry no `name`, and a unit struct has no field list, so
1351    /// both contribute nothing.
1352    fn struct_fields(&self, node: tree_sitter::Node) -> Vec<FieldDef> {
1353        let mut out = Vec::new();
1354        let mut cursor = node.walk();
1355        for child in node.named_children(&mut cursor) {
1356            if child.kind() == "field_declaration_list" {
1357                let mut inner = child.walk();
1358                for field in child.named_children(&mut inner) {
1359                    if field.kind() == "field_declaration"
1360                        && let Some(name) = field.child_by_field_name("name")
1361                    {
1362                        let type_idents = field
1363                            .child_by_field_name("type")
1364                            .map(|t| self.type_idents(t))
1365                            .unwrap_or_default();
1366                        out.push(FieldDef {
1367                            name: self.text(name).to_owned(),
1368                            type_idents,
1369                        });
1370                    }
1371                }
1372            }
1373        }
1374        out
1375    }
1376
1377    /// Every `type_identifier` token in a type subtree, outermost first — so a
1378    /// generic like `Option<Vec<Inner>>` yields `["Option", "Vec", "Inner"]`. The
1379    /// order lets [`core_type_name`] / [`recursion_target`] peel transparent
1380    /// wrappers and stop at a collection.
1381    fn type_idents(&self, ty: tree_sitter::Node) -> Vec<String> {
1382        let mut out = Vec::new();
1383        self.collect_type_idents(ty, &mut out);
1384        out
1385    }
1386
1387    fn collect_type_idents(&self, node: tree_sitter::Node, out: &mut Vec<String>) {
1388        // A named type (`ZerobusConfig`, `String`) or a primitive (`u32`, `bool`) —
1389        // both are field-type tokens; primitives never name a struct, so they only
1390        // ever resolve to a leaf, but they make `meta.field_types` complete.
1391        if matches!(node.kind(), "type_identifier" | "primitive_type") {
1392            out.push(self.text(node).to_owned());
1393        }
1394        let mut cursor = node.walk();
1395        for child in node.named_children(&mut cursor) {
1396            self.collect_type_idents(child, out);
1397        }
1398    }
1399
1400    /// Whether an authored **`@rto:config`** marker precedes `node` — the explicit,
1401    /// opt-in signal that a struct is the root of a config tree
1402    /// [`RustWalk::synthesize_config_keys`] may expand. Scans the immediately
1403    /// preceding run of comments (`//`, `///`, `//!`, or `/* … */` block comments)
1404    /// and attributes, returning `true` as soon as any of them contains the marker
1405    /// token; the first node that is not a comment or attribute ends the run. Unlike
1406    /// [`doc_comment`] this does not require the comments to be *doc* comments and
1407    /// does not stop at a plain `//` comment — a bare `// @rto:config` line is
1408    /// accepted. Requiring an authored marker keeps synthesis conservative — a
1409    /// struct is never guessed to be config.
1410    fn has_config_marker(&self, node: tree_sitter::Node) -> bool {
1411        const MARKER: &str = "@rto:config";
1412        let mut prev = node.prev_sibling();
1413        while let Some(n) = prev {
1414            match n.kind() {
1415                "line_comment" | "block_comment" | "attribute_item" => {
1416                    if self.text(n).contains(MARKER) {
1417                        return true;
1418                    }
1419                    prev = n.prev_sibling();
1420                }
1421                _ => break,
1422            }
1423        }
1424        false
1425    }
1426
1427    /// Recurse into the `declaration_list` / body of a definition.
1428    fn recurse_body(&mut self, node: tree_sitter::Node, scope: &[Scope]) {
1429        let mut cursor = node.walk();
1430        let children: Vec<_> = node.named_children(&mut cursor).collect();
1431        for child in children {
1432            match child.kind() {
1433                "declaration_list" | "field_declaration_list" | "trait_body" => {
1434                    self.visit_children(child, scope);
1435                }
1436                _ => {}
1437            }
1438        }
1439    }
1440
1441    /// Collect the simple names of functions called anywhere within `node`'s
1442    /// subtree (used for later call resolution).
1443    fn collect_calls(&self, node: tree_sitter::Node, out: &mut Vec<String>) {
1444        let mut cursor = node.walk();
1445        for child in node.named_children(&mut cursor) {
1446            if child.kind() == "call_expression"
1447                && let Some(func) = child.child_by_field_name("function")
1448                && let Some(name) = self.callee_name(func)
1449            {
1450                out.push(name);
1451            }
1452            self.collect_calls(child, out);
1453        }
1454    }
1455
1456    /// A callee descriptor for a `call_expression`'s function child, keeping the
1457    /// *immediate* qualifier when the syntax supplies one so [`crate::sync`] can
1458    /// resolve scope-aware (not just by unique simple name):
1459    /// - `foo()` → `foo` (unqualified)
1460    /// - `a::b::foo()` → `b::foo` (immediate module/type qualifier)
1461    /// - `Type::assoc()` → `Type::assoc`
1462    /// - `self.foo()` / `Self::foo()` → `Self::foo` (a same-impl method call,
1463    ///   resolved via the caller's own type)
1464    /// - `x.foo()` on a non-`self` receiver → `foo` (the receiver's type is
1465    ///   unknown without type inference, so no qualifier is claimed)
1466    fn callee_name(&self, func: tree_sitter::Node) -> Option<String> {
1467        match func.kind() {
1468            "identifier" => Some(self.text(func).to_owned()),
1469            "scoped_identifier" => {
1470                let name = func.child_by_field_name("name")?;
1471                // The immediate qualifier is the last segment of the `path` child
1472                // (`a::b` → `b`), which most closely scopes the call.
1473                let qualifier = func
1474                    .child_by_field_name("path")
1475                    .and_then(|p| self.text(p).rsplit("::").next().map(str::to_owned));
1476                Some(qualify_callee(qualifier.as_deref(), self.text(name)))
1477            }
1478            "field_expression" => {
1479                let name = func.child_by_field_name("field")?;
1480                // A call on the `self` receiver targets a method of the caller's
1481                // own impl type; mark it `Self` so the resolver can bind it.
1482                let on_self = func
1483                    .child_by_field_name("value")
1484                    .is_some_and(|v| self.text(v) == "self");
1485                Some(qualify_callee(on_self.then_some("Self"), self.text(name)))
1486            }
1487            _ => None,
1488        }
1489    }
1490
1491    /// Synthesize `config_key` nodes from any **config-root** struct in this file —
1492    /// a struct authored with the `@rto:config` marker (see [`has_config_marker`]).
1493    /// Its declared fields are walked recursively, descending into nested
1494    /// struct-typed fields (resolved by name against the other structs in *this
1495    /// file*), and each config **leaf** becomes a `config_key` node keyed
1496    /// `cfgkey:<path>#<dotted>` — so a code-defined config (`zerobus: ZerobusConfig`
1497    /// with `server_endpoint: String`) yields `zerobus.server_endpoint` **without** a
1498    /// committed `*-example.toml` mirror. The nodes carry `meta.source = "struct"`
1499    /// (and `meta.struct = <root>`) so they stay distinguishable from file-derived
1500    /// keys, while sharing the `config_key` kind so they flow through
1501    /// `Store::config_keys` → `links --infer`/`--matrix`/the explorer unchanged.
1502    ///
1503    /// Deliberately conservative and additive: nothing is emitted unless a root is
1504    /// explicitly marked. Field names are used verbatim as dotted segments; the
1505    /// cross-convention matcher ([`crate::canonicalize_config_key`]) already bridges
1506    /// a `snake_case` field to a `camelCase`/`kebab` infra key, so `serde`
1507    /// `rename_all` conventions match without being parsed here.
1508    ///
1509    /// Known limits (documented, deferred): recursion resolves nested structs by
1510    /// name **within this file only** (a config struct split across modules/files is
1511    /// not descended — those leaves simply stay unsynthesized, as today); an explicit
1512    /// `#[serde(rename = "...")]` to an unrelated spelling is not applied; and
1513    /// collection-typed fields (`Vec`/`Map`) are one leaf, not indexed paths.
1514    fn synthesize_config_keys(&mut self, root: tree_sitter::Node) {
1515        let table = self.collect_struct_defs(root);
1516        // key → the root struct name that produced it (first root wins; deterministic
1517        // because `table` iterates roots by name).
1518        let mut keys: std::collections::BTreeMap<String, String> =
1519            std::collections::BTreeMap::new();
1520        for (name, def) in &table {
1521            if !def.is_root {
1522                continue;
1523            }
1524            let mut visited = std::collections::BTreeSet::new();
1525            visited.insert(name.clone());
1526            expand_config_keys(&table, name, "", name, &mut visited, 0, &mut keys);
1527        }
1528        let file = file_key(self.path);
1529        for (dotted, root_name) in keys {
1530            let node_key = format!("cfgkey:{}#{dotted}", self.path);
1531            let mut node = Node::new(
1532                node_key.clone(),
1533                NodeKind::Other(crate::config_keys::KIND.into()),
1534                dotted.clone(),
1535            );
1536            node.path = Some(self.path.to_owned());
1537            node.blob_hash = Some(self.blob_id.to_owned());
1538            // A struct field declares no literal value, so `meta.value` is OMITTED
1539            // (not `""`): the store reader surfaces this as `value_known = false` so
1540            // value-agreement matching treats the value as *unknown*, never as an
1541            // empty string that could false-match a spoke's genuine empty value.
1542            // `source`/`struct` mark the provenance and keep these distinguishable
1543            // from file-derived config keys.
1544            node.meta = serde_json::json!({
1545                "key": dotted,
1546                "source": "struct",
1547                "struct": root_name,
1548            });
1549            self.edges.push(Edge::derived(
1550                file.clone(),
1551                node_key.clone(),
1552                EdgeKind::Contains,
1553            ));
1554            self.nodes.push(node);
1555        }
1556    }
1557
1558    /// Index every struct/union in the file by its **simple name** (first
1559    /// declaration wins on a collision) for config synthesis — recording its fields,
1560    /// its node key, and whether it is a `@rto:config` root.
1561    fn collect_struct_defs(
1562        &self,
1563        root: tree_sitter::Node,
1564    ) -> std::collections::BTreeMap<String, StructDef> {
1565        let mut out = std::collections::BTreeMap::new();
1566        self.collect_struct_defs_into(root, &mut out);
1567        out
1568    }
1569
1570    fn collect_struct_defs_into(
1571        &self,
1572        node: tree_sitter::Node,
1573        out: &mut std::collections::BTreeMap<String, StructDef>,
1574    ) {
1575        if matches!(node.kind(), "struct_item" | "union_item")
1576            && let Some(name) = self.field_text(node, "name")
1577        {
1578            out.entry(name.clone()).or_insert_with(|| StructDef {
1579                fields: self.struct_fields(node),
1580                is_root: self.has_config_marker(node),
1581            });
1582        }
1583        let mut cursor = node.walk();
1584        for child in node.named_children(&mut cursor) {
1585            self.collect_struct_defs_into(child, out);
1586        }
1587    }
1588
1589    fn text(&self, node: tree_sitter::Node) -> &str {
1590        node.utf8_text(self.src).unwrap_or("")
1591    }
1592
1593    fn field_text(&self, node: tree_sitter::Node, field: &str) -> Option<String> {
1594        node.child_by_field_name(field)
1595            .map(|n| self.text(n).to_owned())
1596    }
1597
1598    fn simple(&self, node: tree_sitter::Node, field: &str) -> String {
1599        self.field_text(node, field).unwrap_or_default()
1600    }
1601}
1602
1603// ======================= Generic tags-query extraction =======================
1604//
1605// One extractor drives every non-Rust language through its tree-sitter `tags.scm`
1606// query (the `@definition.*` / `@reference.*` capture convention). It emits the
1607// same fact shape as the Rust walker — a `file` node, one symbol node per
1608// definition with `defines`/`contains` edges reflecting byte-range nesting, and
1609// each function's callee simple-names in `meta.calls` — so cross-file (and
1610// cross-language) call resolution in `crate::sync` works uniformly. Where the
1611// language has an import query (`import_query_for`), it also emits `imports`
1612// edges (`file → import` target), as the Rust walker does for `use`. A new
1613// language is a row in `tag_lang_for` (and optionally `import_query_for`), not
1614// new code.
1615
1616/// A language dispatched to the generic tags extractor: its label, grammar, and
1617/// `tags.scm` source (from the grammar crate, or vendored under `src/queries/`).
1618struct TagLang {
1619    /// Canonical label — the node `lang` and the `sym:<lang>:` key namespace.
1620    lang: &'static str,
1621    /// Cache key identifying the *grammar* (not just the label): one `lang` can
1622    /// map to more than one grammar — OCaml `.ml` and `.mli` are both `"ocaml"`
1623    /// but use distinct grammars — so the config cache must key on this, not
1624    /// `lang`, to avoid parsing one grammar's blobs with another's parser.
1625    grammar_key: &'static str,
1626    /// The tree-sitter grammar.
1627    language: tree_sitter::Language,
1628    /// The `tags.scm` query source. Usually borrowed from the grammar crate's
1629    /// const; owned when it is assembled (TypeScript's query `inherits` the
1630    /// JavaScript one, which the crate's `TAGS_QUERY` const does not concatenate).
1631    query: std::borrow::Cow<'static, str>,
1632}
1633
1634/// Resolve a lowercase file extension to its tags-extractor language, or `None`
1635/// when no generic extractor handles it (the caller then falls back to a plain
1636/// file node). Rust is intentionally absent — it keeps its richer AST walker.
1637// A flat extension→grammar dispatch table; length is inherent to the breadth.
1638#[allow(clippy::too_many_lines)]
1639fn tag_lang_for(ext: &str) -> Option<TagLang> {
1640    use std::borrow::Cow;
1641    // TypeScript's tags query `inherits` JavaScript's; the crate const ships only
1642    // the TS-specific supplement, so concatenate the two. The JavaScript patterns
1643    // match against the TypeScript superset grammar.
1644    let ts_query = || -> Cow<'static, str> {
1645        Cow::Owned(format!(
1646            "{}\n{}",
1647            tree_sitter_javascript::TAGS_QUERY,
1648            tree_sitter_typescript::TAGS_QUERY
1649        ))
1650    };
1651    let borrowed = |q: &'static str| -> Cow<'static, str> { Cow::Borrowed(q) };
1652
1653    let (lang, language, query): (&str, tree_sitter::Language, Cow<'static, str>) = match ext {
1654        "py" | "pyi" => (
1655            "python",
1656            tree_sitter_python::LANGUAGE.into(),
1657            borrowed(tree_sitter_python::TAGS_QUERY),
1658        ),
1659        "js" | "jsx" | "mjs" | "cjs" => (
1660            "javascript",
1661            tree_sitter_javascript::LANGUAGE.into(),
1662            borrowed(tree_sitter_javascript::TAGS_QUERY),
1663        ),
1664        "ts" | "mts" | "cts" => (
1665            "typescript",
1666            tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1667            ts_query(),
1668        ),
1669        "tsx" => (
1670            "tsx",
1671            tree_sitter_typescript::LANGUAGE_TSX.into(),
1672            ts_query(),
1673        ),
1674        "go" => (
1675            "go",
1676            tree_sitter_go::LANGUAGE.into(),
1677            borrowed(tree_sitter_go::TAGS_QUERY),
1678        ),
1679        "rb" => (
1680            "ruby",
1681            tree_sitter_ruby::LANGUAGE.into(),
1682            borrowed(tree_sitter_ruby::TAGS_QUERY),
1683        ),
1684        "java" => (
1685            "java",
1686            tree_sitter_java::LANGUAGE.into(),
1687            borrowed(tree_sitter_java::TAGS_QUERY),
1688        ),
1689        "c" | "h" => (
1690            "c",
1691            tree_sitter_c::LANGUAGE.into(),
1692            borrowed(tree_sitter_c::TAGS_QUERY),
1693        ),
1694        "cc" | "cpp" | "cxx" | "hpp" | "hh" | "hxx" => (
1695            "cpp",
1696            tree_sitter_cpp::LANGUAGE.into(),
1697            borrowed(tree_sitter_cpp::TAGS_QUERY),
1698        ),
1699        // The crate's TAGS_QUERY has a stray `@module` capture that
1700        // `tree-sitter-tags` rejects, so a corrected copy is vendored.
1701        "cs" => (
1702            "csharp",
1703            tree_sitter_c_sharp::LANGUAGE.into(),
1704            borrowed(include_str!("queries/csharp/tags.scm")),
1705        ),
1706        "php" => (
1707            "php",
1708            tree_sitter_php::LANGUAGE_PHP.into(),
1709            borrowed(tree_sitter_php::TAGS_QUERY),
1710        ),
1711        // Scala's crate bundles a tags.scm but exposes no const, so it is vendored.
1712        "scala" | "sc" => (
1713            "scala",
1714            tree_sitter_scala::LANGUAGE.into(),
1715            borrowed(include_str!("queries/scala/tags.scm")),
1716        ),
1717        "ml" => (
1718            "ocaml",
1719            tree_sitter_ocaml::LANGUAGE_OCAML.into(),
1720            borrowed(tree_sitter_ocaml::TAGS_QUERY),
1721        ),
1722        "mli" => (
1723            "ocaml",
1724            tree_sitter_ocaml::LANGUAGE_OCAML_INTERFACE.into(),
1725            borrowed(tree_sitter_ocaml::TAGS_QUERY),
1726        ),
1727        "ex" | "exs" => (
1728            "elixir",
1729            tree_sitter_elixir::LANGUAGE.into(),
1730            borrowed(tree_sitter_elixir::TAGS_QUERY),
1731        ),
1732        // Bash ships no tags query at all, so one is vendored.
1733        "sh" | "bash" => (
1734            "bash",
1735            tree_sitter_bash::LANGUAGE.into(),
1736            borrowed(include_str!("queries/bash/tags.scm")),
1737        ),
1738        // SQL (tree-sitter-sequel) ships no tags query, so one is vendored.
1739        "sql" => (
1740            "sql",
1741            tree_sitter_sequel::LANGUAGE.into(),
1742            borrowed(include_str!("queries/sql/tags.scm")),
1743        ),
1744        _ => return None,
1745    };
1746    // Distinguish grammars that share a `lang` label: `.ml` and `.mli` are both
1747    // "ocaml" but parse with different grammars, so they must cache separately.
1748    let grammar_key = match ext {
1749        "mli" => "ocaml-interface",
1750        _ => lang,
1751    };
1752    Some(TagLang {
1753        lang,
1754        grammar_key,
1755        language,
1756        query,
1757    })
1758}
1759
1760/// A compiled tags configuration, shared across the blobs of one language.
1761type TagConfig = std::sync::Arc<tree_sitter_tags::TagsConfiguration>;
1762
1763/// Cache of compiled tags configurations, keyed by [`TagLang::grammar_key`] (not
1764/// the `lang` label, since one label can back multiple grammars). Compiling a
1765/// `tags.scm` query is not free, and `sync` extracts many blobs, so each
1766/// grammar's configuration is built once. A grammar whose query fails to compile
1767/// (a grammar/query mismatch — a build-time invariant, not a runtime input)
1768/// caches `None` so it is not retried per file.
1769static TAG_CONFIGS: std::sync::LazyLock<
1770    std::sync::Mutex<std::collections::HashMap<&'static str, Option<TagConfig>>>,
1771> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
1772
1773/// The compiled tags configuration for a language, building and caching it on
1774/// first use. `None` if the query does not compile against the grammar.
1775fn tag_config(def: &TagLang) -> Option<TagConfig> {
1776    let mut cache = TAG_CONFIGS
1777        .lock()
1778        .unwrap_or_else(std::sync::PoisonError::into_inner);
1779    cache
1780        .entry(def.grammar_key)
1781        .or_insert_with(|| {
1782            tree_sitter_tags::TagsConfiguration::new(def.language.clone(), &def.query, "")
1783                .ok()
1784                .map(std::sync::Arc::new)
1785        })
1786        .clone()
1787}
1788
1789/// A per-language tree-sitter query capturing import/include targets as `@path`.
1790/// Run alongside the tags extraction so the generic languages emit `imports`
1791/// edges (`file → import` node) the way the Rust walker does for `use`. `None`
1792/// for a language whose imports we do not yet capture (it simply emits none).
1793///
1794/// Node names are grammar-specific; a query that fails to compile against its
1795/// grammar is cached as absent (see [`import_query`]) rather than retried.
1796fn import_query_for(lang: &str) -> Option<&'static str> {
1797    Some(match lang {
1798        // `import a.b.c`, `import a.b as d`, `from a.b import x`, `from . import x`.
1799        "python" => {
1800            "(import_statement name: (dotted_name) @path)\n\
1801             (import_statement name: (aliased_import name: (dotted_name) @path))\n\
1802             (import_from_statement module_name: (dotted_name) @path)\n\
1803             (import_from_statement module_name: (relative_import) @path)"
1804        }
1805        // `import x from \"mod\"`, `export … from \"mod\"` — the module string.
1806        "javascript" | "typescript" | "tsx" => {
1807            "(import_statement source: (string (string_fragment) @path))\n\
1808             (export_statement source: (string (string_fragment) @path))"
1809        }
1810        // Each spec's quoted path inside an `import ( … )` block or single import.
1811        "go" => "(import_spec path: (interpreted_string_literal) @path)",
1812        // `import a.b.C;` / `import static a.b.C;`.
1813        "java" => {
1814            "(import_declaration (scoped_identifier) @path)\n\
1815             (import_declaration (identifier) @path)"
1816        }
1817        // `#include \"x.h\"` and `#include <x>` (C and, by inheritance, C++).
1818        "c" | "cpp" => {
1819            "(preproc_include path: (string_literal) @path)\n\
1820             (preproc_include path: (system_lib_string) @path)"
1821        }
1822        _ => return None,
1823    })
1824}
1825
1826/// A compiled import query, shared across the blobs of one grammar.
1827type ImportQuery = std::sync::Arc<tree_sitter::Query>;
1828
1829/// Cache of compiled import queries, keyed by [`TagLang::grammar_key`] (as with
1830/// [`TAG_CONFIGS`]). `None` when the language has no import query or it does not
1831/// compile against the grammar, so it is not retried per file.
1832static IMPORT_QUERIES: std::sync::LazyLock<
1833    std::sync::Mutex<std::collections::HashMap<&'static str, Option<ImportQuery>>>,
1834> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
1835
1836/// The compiled import query for a language, building and caching it on first use.
1837fn import_query(def: &TagLang) -> Option<ImportQuery> {
1838    let mut cache = IMPORT_QUERIES
1839        .lock()
1840        .unwrap_or_else(std::sync::PoisonError::into_inner);
1841    cache
1842        .entry(def.grammar_key)
1843        .or_insert_with(|| {
1844            let src = import_query_for(def.lang)?;
1845            tree_sitter::Query::new(&def.language, src)
1846                .ok()
1847                .map(std::sync::Arc::new)
1848        })
1849        .clone()
1850}
1851
1852/// Normalise a captured import target to a bare module string: strip surrounding
1853/// quotes (`"…"`), C system-header brackets (`<…>`), and whitespace.
1854fn normalize_import(raw: &str) -> String {
1855    raw.trim()
1856        .trim_matches(|c| c == '"' || c == '\'' || c == '<' || c == '>')
1857        .trim()
1858        .to_owned()
1859}
1860
1861/// Append `imports` edges for a blob by running its language's import query.
1862/// Emits one `import:<lang>:<module>` node (deduped) and a `file → import`
1863/// `Imports` edge per distinct target, mirroring the Rust walker's `use` handling.
1864fn append_import_facts(
1865    path: &str,
1866    def: &TagLang,
1867    bytes: &[u8],
1868    nodes: &mut Vec<Node>,
1869    edges: &mut Vec<Edge>,
1870) {
1871    use streaming_iterator::StreamingIterator as _;
1872
1873    let Some(query) = import_query(def) else {
1874        return;
1875    };
1876    let mut parser = tree_sitter::Parser::new();
1877    if parser.set_language(&def.language).is_err() {
1878        return;
1879    }
1880    let Some(tree) = parser.parse(bytes, None) else {
1881        return;
1882    };
1883    let mut cursor = tree_sitter::QueryCursor::new();
1884    let mut seen = std::collections::BTreeSet::new();
1885    let mut matches = cursor.matches(&query, tree.root_node(), bytes);
1886    while let Some(m) = matches.next() {
1887        for cap in m.captures {
1888            let Ok(raw) = cap.node.utf8_text(bytes) else {
1889                continue;
1890            };
1891            let module = normalize_import(raw);
1892            if module.is_empty() {
1893                continue;
1894            }
1895            let key = format!("import:{}:{module}", def.lang);
1896            if seen.insert(key.clone()) {
1897                nodes.push(Node {
1898                    key: key.clone(),
1899                    kind: NodeKind::Other("import".into()),
1900                    name: module,
1901                    // The import *target* is not owned by any one file (its key is
1902                    // global): leave `path` unset, as the Rust walker does, so two
1903                    // files importing the same module dedup to one stable node.
1904                    path: None,
1905                    lang: Some(def.lang.to_owned()),
1906                    blob_hash: None,
1907                    span: None,
1908                    provenance: Provenance::Derived,
1909                    meta: serde_json::Value::Null,
1910                });
1911                edges.push(Edge::derived(file_key(path), key, EdgeKind::Imports));
1912            }
1913        }
1914    }
1915}
1916
1917/// Map a `tags.scm` syntax type (the tail of a `@definition.X` capture) to a
1918/// graph node kind. Unrecognised kinds are kept verbatim under `Other`.
1919fn tag_node_kind(syntax_type: &str) -> NodeKind {
1920    match syntax_type {
1921        "function" | "method" | "constructor" => NodeKind::Fn,
1922        "class" | "struct" => NodeKind::Struct,
1923        "interface" | "trait" | "protocol" => NodeKind::Trait,
1924        "enum" => NodeKind::Enum,
1925        // A Scala/Kotlin `object` is a singleton namespace; group it with modules.
1926        "module" | "namespace" | "object" => NodeKind::Module,
1927        other => NodeKind::Other(other.to_owned()),
1928    }
1929}
1930
1931/// A definition captured from a `tags.scm` run, before nesting is resolved.
1932struct TagDef {
1933    name: String,
1934    kind: NodeKind,
1935    range: std::ops::Range<usize>,
1936    docs: Option<String>,
1937}
1938
1939/// Extract facts from a source blob via its language's tags query. Returns `None`
1940/// when the extension has no generic extractor or the query cannot compile, so
1941/// the caller falls back to a plain file node.
1942fn tag_facts(
1943    path: &str,
1944    blob_id: &str,
1945    bytes: &[u8],
1946    ext: &str,
1947    ingest: IngestConfig,
1948) -> Option<FactSet> {
1949    let def = tag_lang_for(ext)?;
1950    let lang = def.lang;
1951    let config = tag_config(&def)?;
1952
1953    let mut ctx = tree_sitter_tags::TagsContext::new();
1954    let (tags, _had_error) = ctx.generate_tags(&config, bytes, None).ok()?;
1955
1956    let mut defs: Vec<TagDef> = Vec::new();
1957    // Call references, as (byte offset of the call, callee simple-name), attached
1958    // later to whichever function definition encloses them.
1959    let mut calls: Vec<(usize, String)> = Vec::new();
1960    for tag in tags {
1961        let Ok(tag) = tag else { continue };
1962        let Some(name) = bytes
1963            .get(tag.name_range.clone())
1964            .and_then(|b| std::str::from_utf8(b).ok())
1965        else {
1966            continue;
1967        };
1968        let syntax = config.syntax_type_name(tag.syntax_type_id);
1969        if tag.is_definition {
1970            defs.push(TagDef {
1971                name: name.to_owned(),
1972                kind: tag_node_kind(syntax),
1973                range: tag.range.clone(),
1974                // The tags machinery already resolves a definition's doc comment.
1975                docs: tag.docs.clone(),
1976            });
1977        } else if syntax == "call" || syntax == "send" {
1978            // `send` is Ruby's message-send; both mean "invokes a name".
1979            calls.push((tag.range.start, name.to_owned()));
1980        }
1981    }
1982
1983    // Resolve nesting purely by byte-range containment: a definition's parent is
1984    // the smallest other definition whose range strictly encloses it. This yields
1985    // `contains` edges (parent→child) and qualified, collision-resistant keys
1986    // without any language-specific scope rules.
1987    let parents: Vec<Option<usize>> = (0..defs.len())
1988        .map(|i| smallest_enclosing(&defs, defs[i].range.clone(), Some(i)))
1989        .collect();
1990
1991    let keys: Vec<String> = (0..defs.len())
1992        .map(|i| {
1993            let qualified = qualified_name(&defs, &parents, i);
1994            format!("sym:{lang}:{path}#{qualified}")
1995        })
1996        .collect();
1997
1998    let mut nodes = vec![file_node(path, blob_id, bytes, Some(lang), ingest)];
1999    let mut edges: Vec<Edge> = Vec::new();
2000
2001    for (i, d) in defs.iter().enumerate() {
2002        let mut meta = serde_json::Map::new();
2003        if let Some(doc) = &d.docs {
2004            let content = cap_content(doc);
2005            if !content.is_empty() {
2006                meta.insert("content".into(), serde_json::Value::from(content));
2007            }
2008        }
2009        // Attach the calls this definition encloses — but only for functions, the
2010        // only kind `crate::sync::resolve_calls` links.
2011        if d.kind == NodeKind::Fn {
2012            let mut names: Vec<String> = calls
2013                .iter()
2014                .filter(|(off, _)| d.range.contains(off))
2015                .filter(|(off, _)| smallest_enclosing_off(&defs, *off) == Some(i))
2016                .map(|(_, name)| name.clone())
2017                .collect();
2018            names.sort();
2019            names.dedup();
2020            if !names.is_empty() {
2021                meta.insert("calls".into(), serde_json::Value::from(names));
2022            }
2023        }
2024
2025        let start = u32::try_from(d.range.start).unwrap_or(u32::MAX);
2026        let end = u32::try_from(d.range.end).unwrap_or(u32::MAX);
2027        nodes.push(Node {
2028            key: keys[i].clone(),
2029            kind: d.kind.clone(),
2030            name: d.name.clone(),
2031            path: Some(path.to_owned()),
2032            lang: Some(lang.to_owned()),
2033            blob_hash: Some(blob_id.to_owned()),
2034            span: Some(Span::new(start, end)),
2035            provenance: Provenance::Derived,
2036            meta: serde_json::Value::Object(meta),
2037        });
2038
2039        match parents[i] {
2040            Some(p) => edges.push(Edge::derived(
2041                keys[p].clone(),
2042                keys[i].clone(),
2043                EdgeKind::Contains,
2044            )),
2045            None => edges.push(Edge::derived(
2046                file_key(path),
2047                keys[i].clone(),
2048                EdgeKind::Defines,
2049            )),
2050        }
2051    }
2052
2053    // Import/include edges (file → import target), where the language has a query.
2054    append_import_facts(path, &def, bytes, &mut nodes, &mut edges);
2055
2056    // Deterministic, duplicate-free output (two query patterns can capture the
2057    // same definition, and distinct symbols can share a qualified name).
2058    nodes.sort_by(|a, b| a.key.cmp(&b.key));
2059    nodes.dedup_by(|a, b| a.key == b.key);
2060    edges.sort_by(|a, b| (a.kind.as_str(), &a.src, &a.dst).cmp(&(b.kind.as_str(), &b.src, &b.dst)));
2061    edges.dedup();
2062    Some(FactSet { nodes, edges })
2063}
2064
2065/// Index of the smallest definition (other than `skip`) whose range strictly
2066/// encloses `range`, or `None` if `range` is top-level.
2067fn smallest_enclosing(
2068    defs: &[TagDef],
2069    range: std::ops::Range<usize>,
2070    skip: Option<usize>,
2071) -> Option<usize> {
2072    let mut best: Option<usize> = None;
2073    for (j, c) in defs.iter().enumerate() {
2074        if Some(j) == skip {
2075            continue;
2076        }
2077        // Strictly encloses: contains both ends and is a larger span.
2078        let encloses = c.range.start <= range.start
2079            && c.range.end >= range.end
2080            && (c.range.end - c.range.start) > (range.end - range.start);
2081        if encloses
2082            && best.is_none_or(|b| {
2083                defs[b].range.end - defs[b].range.start > c.range.end - c.range.start
2084            })
2085        {
2086            best = Some(j);
2087        }
2088    }
2089    best
2090}
2091
2092/// Index of the smallest definition enclosing byte offset `off`.
2093fn smallest_enclosing_off(defs: &[TagDef], off: usize) -> Option<usize> {
2094    let mut best: Option<usize> = None;
2095    for (j, c) in defs.iter().enumerate() {
2096        if c.range.contains(&off)
2097            && best.is_none_or(|b| {
2098                defs[b].range.end - defs[b].range.start > c.range.end - c.range.start
2099            })
2100        {
2101            best = Some(j);
2102        }
2103    }
2104    best
2105}
2106
2107/// A definition's qualified name: its ancestors' names (root→leaf) joined to its
2108/// own by `::`, so nested symbols get distinct, stable keys.
2109fn qualified_name(defs: &[TagDef], parents: &[Option<usize>], i: usize) -> String {
2110    let mut chain: Vec<&str> = vec![defs[i].name.as_str()];
2111    let mut cur = parents[i];
2112    // Bound the walk by the number of definitions — parents form a DAG toward
2113    // smaller-or-equal spans, but guard against any pathological cycle.
2114    let mut guard = defs.len();
2115    while let Some(p) = cur {
2116        if guard == 0 {
2117            break;
2118        }
2119        guard -= 1;
2120        chain.push(defs[p].name.as_str());
2121        cur = parents[p];
2122    }
2123    chain.reverse();
2124    chain.join("::")
2125}
2126
2127/// Byte span of an AST node, clamped to `u32`.
2128fn span(node: tree_sitter::Node) -> Span {
2129    let start = u32::try_from(node.start_byte()).unwrap_or(u32::MAX);
2130    let end = u32::try_from(node.end_byte()).unwrap_or(u32::MAX);
2131    Span::new(start, end)
2132}
2133
2134/// Qualified name for a new symbol: all enclosing scope segments plus `name`.
2135fn qualify(scope: &[Scope], name: &str) -> String {
2136    let mut parts: Vec<&str> = scope.iter().map(|s| s.seg.as_str()).collect();
2137    parts.push(name);
2138    parts.join("::")
2139}
2140
2141/// Combine an optional immediate qualifier with a callee `name` into the stored
2142/// `meta.calls` descriptor. Path-relative qualifiers (`self`/`crate`/`super`) and
2143/// an empty qualifier collapse to the bare name, since they don't scope a
2144/// cross-file target; `Self` is preserved as the marker for a same-impl call.
2145fn qualify_callee(qualifier: Option<&str>, name: &str) -> String {
2146    match qualifier {
2147        Some(q) if !q.is_empty() && !matches!(q, "self" | "crate" | "super") => {
2148            format!("{q}::{name}")
2149        }
2150        _ => name.to_owned(),
2151    }
2152}
2153
2154/// Push a scope entry, returning the extended stack.
2155fn extend(scope: &[Scope], seg: &str, key: Option<String>) -> Vec<Scope> {
2156    let mut next: Vec<Scope> = scope
2157        .iter()
2158        .map(|s| Scope {
2159            seg: s.seg.clone(),
2160            key: s.key.clone(),
2161        })
2162        .collect();
2163    next.push(Scope {
2164        seg: seg.to_owned(),
2165        key,
2166    });
2167    next
2168}
2169
2170#[cfg(test)]
2171mod tests {
2172    use super::{Extractor, FileNodeExtractor, Registry, RustExtractor};
2173    use crate::{EdgeKind, Node, NodeKind};
2174
2175    #[test]
2176    fn file_node_extractor_is_deterministic_and_tagged() {
2177        let ex = FileNodeExtractor;
2178        let a = ex.extract("src/lib.rs", "abc123", b"one\ntwo\n");
2179        let b = ex.extract("src/lib.rs", "abc123", b"one\ntwo\n");
2180        assert_eq!(a, b, "extraction must be deterministic");
2181
2182        assert_eq!(a.nodes.len(), 1);
2183        assert!(a.edges.is_empty());
2184        let node = &a.nodes[0];
2185        assert_eq!(node.key, "file:src/lib.rs");
2186        assert_eq!(node.kind, NodeKind::File);
2187        assert_eq!(node.name, "lib.rs");
2188        assert_eq!(node.blob_hash.as_deref(), Some("abc123"));
2189        assert_eq!(node.meta["lines"], 2);
2190        assert_eq!(node.meta["bytes"], 8);
2191    }
2192
2193    #[test]
2194    fn config_files_emit_config_key_nodes() {
2195        let reg = Registry::new(crate::IngestConfig::default());
2196        let toml = b"[serve]\naddr = \"0.0.0.0:8443\"\ntools = false\n";
2197        let a = reg.extract("config.toml", "cfg1", toml);
2198        let b = reg.extract("config.toml", "cfg1", toml);
2199        assert_eq!(a, b, "config extraction must be deterministic");
2200
2201        // The file node plus a config_key node per leaf.
2202        assert!(a.nodes.iter().any(|n| n.key == "file:config.toml"));
2203        let addr = a
2204            .nodes
2205            .iter()
2206            .find(|n| n.key == "cfgkey:config.toml#serve.addr")
2207            .expect("serve.addr config_key node");
2208        assert_eq!(addr.kind, NodeKind::Other("config_key".into()));
2209        assert_eq!(addr.name, "serve.addr");
2210        assert_eq!(addr.meta["value"], "0.0.0.0:8443"); // unquoted
2211        // A `contains` edge from the file to each config key.
2212        assert!(a.edges.iter().any(|e| {
2213            e.src == "file:config.toml"
2214                && e.dst == "cfgkey:config.toml#serve.addr"
2215                && e.kind == EdgeKind::Contains
2216        }));
2217
2218        // A `.env` (no extension) is recognised by name; a repeated key yields one
2219        // node with the last value; a secret value is redacted.
2220        let env = reg.extract(".env", "env1", b"PORT=8080\nPORT=9090\nAPI_TOKEN=s3cr3t\n");
2221        let port = env
2222            .nodes
2223            .iter()
2224            .find(|n| n.key == "cfgkey:.env#PORT")
2225            .expect("PORT node");
2226        assert_eq!(port.meta["value"], "9090", "dotenv last-one-wins");
2227        assert_eq!(
2228            env.nodes
2229                .iter()
2230                .filter(|n| n.key == "cfgkey:.env#PORT")
2231                .count(),
2232            1
2233        );
2234        let token = env
2235            .nodes
2236            .iter()
2237            .find(|n| n.key == "cfgkey:.env#API_TOKEN")
2238            .expect("API_TOKEN node");
2239        assert_eq!(token.meta["value"], "<redacted>", "secret not persisted");
2240        // A source file is unaffected.
2241        let rs = reg.extract("src/lib.rs", "x", b"pub fn f() {}\n");
2242        assert!(
2243            rs.nodes
2244                .iter()
2245                .all(|n| n.kind != NodeKind::Other("config_key".into()))
2246        );
2247    }
2248
2249    #[test]
2250    fn dockerfile_emits_image_ref_nodes_and_skips_internal_stages() {
2251        let reg = Registry::new(crate::IngestConfig::default());
2252        // Multi-stage: a builder stage (external), an internal `FROM builder`
2253        // (skipped), and a runtime external base pinned by digest.
2254        let df = b"FROM --platform=linux/amd64 rust:1.90 AS builder\nRUN cargo build\n\
2255                   FROM builder AS test\nFROM registry.io/app:1.2@sha256:abc AS run\nFROM scratch\n";
2256        let a = reg.extract("Dockerfile", "d1", df);
2257        let b = reg.extract("Dockerfile", "d1", df);
2258        assert_eq!(a, b, "dockerfile extraction must be deterministic");
2259
2260        let refs: Vec<&Node> = a
2261            .nodes
2262            .iter()
2263            .filter(|n| n.kind == NodeKind::Other("image_ref".into()))
2264            .collect();
2265        // Two external images: rust:1.90 and the app digest. `FROM builder` and
2266        // `FROM scratch` are not pins.
2267        assert_eq!(refs.len(), 2, "got: {refs:?}");
2268        let rust = refs
2269            .iter()
2270            .find(|n| n.meta["image"] == "rust")
2271            .expect("rust");
2272        assert_eq!(rust.meta["tag"], "1.90");
2273        let app = refs
2274            .iter()
2275            .find(|n| n.meta["image"] == "registry.io/app:1.2")
2276            .expect("app digest");
2277        assert_eq!(app.meta["digest"], "sha256:abc");
2278        // A `references` edge from the file to each image_ref.
2279        assert!(
2280            a.edges
2281                .iter()
2282                .any(|e| { e.src == "file:Dockerfile" && e.kind == EdgeKind::References })
2283        );
2284        // `Dockerfile.prod` is recognised too; a plain source file is not.
2285        assert!(
2286            reg.extract("Dockerfile.prod", "d2", b"FROM alpine:3\n")
2287                .nodes
2288                .iter()
2289                .any(|n| n.kind == NodeKind::Other("image_ref".into()))
2290        );
2291
2292        // A stage alias equal to the image name (`FROM alpine AS alpine`) must not
2293        // make the external `alpine` look like an internal stage — it is still a pin.
2294        let c = reg.extract("Dockerfile", "d3", b"FROM alpine AS alpine\n");
2295        assert!(
2296            c.nodes
2297                .iter()
2298                .any(|n| n.kind == NodeKind::Other("image_ref".into())
2299                    && n.meta["image"] == "alpine"),
2300            "FROM x AS x is an external pin, got: {:?}",
2301            c.nodes
2302        );
2303    }
2304
2305    const SAMPLE: &str = r"
2306use std::path::Path;
2307
2308pub struct Store;
2309
2310impl Store {
2311    pub fn open() -> Store {
2312        helper();
2313        Store
2314    }
2315}
2316
2317fn helper() {}
2318
2319mod inner {
2320    pub fn nested() {}
2321}
2322";
2323
2324    fn keys(fs: &crate::FactSet) -> Vec<String> {
2325        let mut k: Vec<_> = fs.nodes.iter().map(|n| n.key.clone()).collect();
2326        k.sort();
2327        k
2328    }
2329
2330    #[test]
2331    fn rust_extractor_emits_symbols_and_edges() {
2332        let fs = RustExtractor.extract("src/lib.rs", "blob1", SAMPLE.as_bytes());
2333        let ks = keys(&fs);
2334        assert!(ks.contains(&"file:src/lib.rs".to_owned()));
2335        assert!(ks.contains(&"sym:rust:src/lib.rs#Store".to_owned()));
2336        assert!(ks.contains(&"sym:rust:src/lib.rs#Store::open".to_owned()));
2337        assert!(ks.contains(&"sym:rust:src/lib.rs#helper".to_owned()));
2338        assert!(ks.contains(&"sym:rust:src/lib.rs#inner".to_owned()));
2339        assert!(ks.contains(&"sym:rust:src/lib.rs#inner::nested".to_owned()));
2340
2341        // `open` records that it calls `helper`.
2342        let open = fs
2343            .nodes
2344            .iter()
2345            .find(|n| n.key == "sym:rust:src/lib.rs#Store::open")
2346            .expect("open node");
2347        assert_eq!(open.meta["calls"], serde_json::json!(["helper"]));
2348
2349        // file defines top-level items; a module contains its nested fn.
2350        let defines: Vec<_> = fs
2351            .edges
2352            .iter()
2353            .filter(|e| e.kind == EdgeKind::Defines && e.dst == "sym:rust:src/lib.rs#helper")
2354            .collect();
2355        assert_eq!(defines.len(), 1);
2356        assert!(fs.edges.iter().any(|e| e.kind == EdgeKind::Contains
2357            && e.src == "sym:rust:src/lib.rs#inner"
2358            && e.dst == "sym:rust:src/lib.rs#inner::nested"));
2359
2360        // the `use` becomes an imports edge.
2361        assert!(fs.edges.iter().any(|e| e.kind == EdgeKind::Imports
2362            && e.src == "file:src/lib.rs"
2363            && e.dst == "import:rust:std::path::Path"));
2364    }
2365
2366    #[test]
2367    fn rust_extractor_records_struct_field_names() {
2368        // A struct with named fields records them in `meta.fields` (the follow
2369        // bridge's join signal); a tuple struct and a unit struct carry none.
2370        let src = "pub struct ServeConfig {\n\
2371                   \x20   pub addr: Option<String>,\n\
2372                   \x20   pub tls_cert: Option<String>,\n\
2373                   }\n\
2374                   pub struct Pair(u8, u8);\n\
2375                   pub struct Marker;\n";
2376        let fs = RustExtractor.extract("src/config.rs", "b", src.as_bytes());
2377        let fields = |key: &str| {
2378            fs.nodes
2379                .iter()
2380                .find(|n| n.key == key)
2381                .and_then(|n| n.meta.get("fields").cloned())
2382        };
2383        assert_eq!(
2384            fields("sym:rust:src/config.rs#ServeConfig"),
2385            Some(serde_json::json!(["addr", "tls_cert"])),
2386            "named fields captured in source order"
2387        );
2388        // Positional (tuple) and unit structs declare no named fields → no key.
2389        assert_eq!(fields("sym:rust:src/config.rs#Pair"), None);
2390        assert_eq!(fields("sym:rust:src/config.rs#Marker"), None);
2391    }
2392
2393    #[test]
2394    fn struct_records_field_types_and_config_root_marker() {
2395        // Field types land in `meta.field_types` (transparent wrappers peeled), and
2396        // the `@rto:config` marker sets `meta.config_root`.
2397        let src = "// @rto:config\n\
2398                   pub struct Config {\n\
2399                   \x20   pub zerobus: ZerobusConfig,\n\
2400                   \x20   pub replicas: Option<u32>,\n\
2401                   }\n\
2402                   pub struct ZerobusConfig {\n\
2403                   \x20   pub server_endpoint: String,\n\
2404                   }\n";
2405        let fs = RustExtractor.extract("src/config.rs", "b", src.as_bytes());
2406        let node = |key: &str| fs.nodes.iter().find(|n| n.key == key).expect("node");
2407        let root = node("sym:rust:src/config.rs#Config");
2408        assert_eq!(root.meta.get("config_root"), Some(&serde_json::json!(true)));
2409        assert_eq!(
2410            root.meta.get("field_types"),
2411            Some(&serde_json::json!({ "zerobus": "ZerobusConfig", "replicas": "u32" })),
2412            "transparent wrappers peeled (Option<u32> → u32)"
2413        );
2414        // An unmarked struct carries no `config_root` flag.
2415        assert_eq!(
2416            node("sym:rust:src/config.rs#ZerobusConfig")
2417                .meta
2418                .get("config_root"),
2419            None
2420        );
2421    }
2422
2423    #[test]
2424    fn config_root_struct_synthesizes_recursive_dotted_config_keys() {
2425        // A `@rto:config` root with a nested struct field yields dotted `config_key`
2426        // nodes for its leaves — no committed `*-example.toml` needed. The nested
2427        // field descends by name into a struct defined in the same file.
2428        let src = "// @rto:config\n\
2429                   pub struct Config {\n\
2430                   \x20   pub zerobus: ZerobusConfig,\n\
2431                   \x20   pub log_level: String,\n\
2432                   }\n\
2433                   pub struct ZerobusConfig {\n\
2434                   \x20   pub server_endpoint: String,\n\
2435                   \x20   pub workspace_url: String,\n\
2436                   }\n";
2437        let fs = RustExtractor.extract("src/config.rs", "b", src.as_bytes());
2438        let cfg = |dotted: &str| {
2439            fs.nodes
2440                .iter()
2441                .find(|n| n.key == format!("cfgkey:src/config.rs#{dotted}"))
2442        };
2443        for dotted in [
2444            "zerobus.server_endpoint",
2445            "zerobus.workspace_url",
2446            "log_level",
2447        ] {
2448            let n = cfg(dotted).unwrap_or_else(|| panic!("missing {dotted}: {:?}", fs.nodes));
2449            assert_eq!(n.kind, NodeKind::Other("config_key".into()));
2450            assert_eq!(n.meta.get("key").and_then(|v| v.as_str()), Some(dotted));
2451            // Provenance marks it struct-derived, distinguishable from file keys.
2452            assert_eq!(
2453                n.meta.get("source").and_then(|v| v.as_str()),
2454                Some("struct")
2455            );
2456            assert_eq!(
2457                n.meta.get("struct").and_then(|v| v.as_str()),
2458                Some("Config")
2459            );
2460        }
2461        // The nested struct's own container name is NOT a leaf (only leaves emit).
2462        assert!(
2463            cfg("zerobus").is_none(),
2464            "intermediate section is not a leaf"
2465        );
2466        // A `contains` edge runs from the file node to each synthesized key.
2467        assert!(fs.edges.iter().any(|e| e.src == "file:src/config.rs"
2468            && e.dst == "cfgkey:src/config.rs#zerobus.server_endpoint"
2469            && e.kind == EdgeKind::Contains));
2470    }
2471
2472    #[test]
2473    fn struct_without_config_marker_synthesizes_no_config_keys() {
2474        // The safety property: an ordinary struct (no `@rto:config`) never produces
2475        // synthetic config keys, so the feature is strictly opt-in and additive.
2476        let src = "pub struct Config {\n\
2477                   \x20   pub zerobus: ZerobusConfig,\n\
2478                   }\n\
2479                   pub struct ZerobusConfig {\n\
2480                   \x20   pub server_endpoint: String,\n\
2481                   }\n";
2482        let fs = RustExtractor.extract("src/config.rs", "b", src.as_bytes());
2483        assert!(
2484            fs.nodes
2485                .iter()
2486                .all(|n| n.kind != NodeKind::Other("config_key".into())),
2487            "no synthetic config_key nodes without the marker: {:?}",
2488            fs.nodes
2489        );
2490    }
2491
2492    #[test]
2493    fn config_root_recursion_terminates_on_a_type_cycle() {
2494        // A self-referential config type must not loop forever: the cyclic field
2495        // falls back to a leaf and synthesis terminates.
2496        let src = "// @rto:config\n\
2497                   pub struct Config {\n\
2498                   \x20   pub addr: String,\n\
2499                   \x20   pub next: Box<Config>,\n\
2500                   }\n";
2501        let fs = RustExtractor.extract("src/config.rs", "b", src.as_bytes());
2502        let has = |dotted: &str| {
2503            fs.nodes
2504                .iter()
2505                .any(|n| n.key == format!("cfgkey:src/config.rs#{dotted}"))
2506        };
2507        assert!(has("addr"));
2508        // The descent path already holds `Config`, so the self-referential `next`
2509        // field is a leaf rather than recursing — synthesis terminates.
2510        assert!(has("next"), "cyclic field falls back to a leaf");
2511        assert!(!has("next.addr"), "no unbounded expansion");
2512    }
2513
2514    #[test]
2515    fn rust_extraction_is_deterministic() {
2516        let a = RustExtractor.extract("src/lib.rs", "blob1", SAMPLE.as_bytes());
2517        let b = RustExtractor.extract("src/lib.rs", "blob1", SAMPLE.as_bytes());
2518        assert_eq!(a, b);
2519    }
2520
2521    #[test]
2522    fn rust_extractor_captures_doc_comments() {
2523        let src = "/// The central store.\n\
2524                   pub struct Store;\n\n\
2525                   /// Opens it.\n\
2526                   /// Reads the config.\n\
2527                   pub fn open() {}\n\n\
2528                   // not a doc comment\n\
2529                   pub fn plain() {}\n";
2530        let fs = RustExtractor.extract("src/lib.rs", "b", src.as_bytes());
2531        let content = |key: &str| {
2532            fs.nodes
2533                .iter()
2534                .find(|n| n.key == key)
2535                .and_then(|n| n.meta.get("content"))
2536                .and_then(|v| v.as_str())
2537                .map(ToOwned::to_owned)
2538        };
2539        assert_eq!(
2540            content("sym:rust:src/lib.rs#Store").as_deref(),
2541            Some("The central store.")
2542        );
2543        assert_eq!(
2544            content("sym:rust:src/lib.rs#open").as_deref(),
2545            Some("Opens it. Reads the config.")
2546        );
2547        // A plain `//` comment is not captured.
2548        assert_eq!(content("sym:rust:src/lib.rs#plain"), None);
2549    }
2550
2551    #[test]
2552    fn prose_file_captures_capped_body() {
2553        let md = FileNodeExtractor.extract("docs/x.md", "b", b"# Title\n\nSome prose   here.\n");
2554        assert_eq!(md.nodes[0].meta["content"], "# Title Some prose here.");
2555        // A non-prose file gets no content.
2556        let rs = FileNodeExtractor.extract("notes.bin", "b", b"\x00\x01binary");
2557        assert!(rs.nodes[0].meta.get("content").is_none());
2558        // Extension matching is case-insensitive: `README.MD` is prose too.
2559        let upper = FileNodeExtractor.extract("README.MD", "b", b"# Hi\n");
2560        assert_eq!(upper.nodes[0].meta["content"], "# Hi");
2561    }
2562
2563    /// Build a one-page PDF with a single Helvetica text run, computing exact
2564    /// byte offsets for the xref table so `pdf-extract` can parse it.
2565    #[cfg(feature = "pdf-text")]
2566    fn minimal_pdf(text: &str) -> Vec<u8> {
2567        let content = format!("BT /F1 24 Tf 72 720 Td ({text}) Tj ET");
2568        let objects = [
2569            "<< /Type /Catalog /Pages 2 0 R >>".to_owned(),
2570            "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(),
2571            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_owned(),
2572            format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
2573            "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_owned(),
2574        ];
2575        let mut pdf = Vec::new();
2576        pdf.extend_from_slice(b"%PDF-1.4\n");
2577        let mut offsets = Vec::new();
2578        for (i, obj) in objects.iter().enumerate() {
2579            offsets.push(pdf.len());
2580            pdf.extend_from_slice(format!("{} 0 obj\n{obj}\nendobj\n", i + 1).as_bytes());
2581        }
2582        let xref_start = pdf.len();
2583        pdf.extend_from_slice(
2584            format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes(),
2585        );
2586        for off in &offsets {
2587            pdf.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
2588        }
2589        pdf.extend_from_slice(
2590            format!(
2591                "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_start}\n%%EOF\n",
2592                objects.len() + 1
2593            )
2594            .as_bytes(),
2595        );
2596        pdf
2597    }
2598
2599    #[cfg(feature = "pdf-text")]
2600    #[test]
2601    fn pdf_file_captures_text_content() {
2602        let pdf = minimal_pdf("Hello Roteiro");
2603        let facts = FileNodeExtractor.extract("docs/guide.pdf", "b", &pdf);
2604        let content = facts.nodes[0].meta["content"].as_str().unwrap();
2605        assert!(content.contains("Hello Roteiro"), "got: {content:?}");
2606        // Extension matching is case-insensitive: `Guide.PDF` extracts too.
2607        let upper = FileNodeExtractor.extract("docs/Guide.PDF", "b", &pdf);
2608        assert!(upper.nodes[0].meta.get("content").is_some());
2609        // A malformed PDF degrades to a plain file node — no panic, no content.
2610        let bad = FileNodeExtractor.extract("docs/bad.pdf", "b", b"%PDF-1.4\ngarbage");
2611        assert!(bad.nodes[0].meta.get("content").is_none());
2612    }
2613
2614    #[cfg(any(feature = "image-ocr", feature = "image-vision"))]
2615    #[test]
2616    fn image_content_guards_before_touching_models() {
2617        // Case-insensitive image detection. The classifier and the byte cap moved
2618        // to `crate::media` with ADR-0015, so `media build` and extraction decide
2619        // what counts as an image with one function rather than two that drift.
2620        use crate::media::{MAX_IMAGE_BYTES, is_image};
2621        assert!(is_image("shot.PNG"));
2622        assert!(is_image("b.jpeg"));
2623        assert!(is_image("c.jpg"));
2624        assert!(!is_image("d.gif"));
2625        // A non-image path returns None without ever looking for models.
2626        assert!(
2627            super::image_content("notes.txt", b"hello", super::IngestConfig::default()).is_none()
2628        );
2629        // An oversized image is rejected by the size guard, before model lookup.
2630        let big = vec![0u8; MAX_IMAGE_BYTES + 1];
2631        assert!(super::image_content("shot.png", &big, super::IngestConfig::default()).is_none());
2632    }
2633
2634    #[test]
2635    fn doc_comment_body_recognises_doc_markers() {
2636        assert_eq!(super::doc_comment_body("/// hi").as_deref(), Some("hi"));
2637        assert_eq!(
2638            super::doc_comment_body("//! mod doc").as_deref(),
2639            Some("mod doc")
2640        );
2641        assert_eq!(
2642            super::doc_comment_body("/** block */").as_deref(),
2643            Some("block")
2644        );
2645        // Plain and `////` comments are not docs.
2646        assert_eq!(super::doc_comment_body("// plain"), None);
2647        assert_eq!(super::doc_comment_body("//// header"), None);
2648        // Degenerate block comments have an empty body, never garbage like "/".
2649        assert_eq!(super::doc_comment_body("/**/").as_deref(), Some(""));
2650        assert_eq!(super::doc_comment_body("/*!*/").as_deref(), Some(""));
2651    }
2652
2653    #[test]
2654    fn registry_dispatches_by_extension() {
2655        let rs = Registry::default().extract("src/lib.rs", "b", SAMPLE.as_bytes());
2656        assert!(rs.nodes.len() > 1, "rust file yields symbols");
2657        let txt = Registry::default().extract("notes.txt", "b", b"hello\n");
2658        assert_eq!(
2659            txt.nodes.len(),
2660            1,
2661            "non-code file falls back to a file node"
2662        );
2663        assert_eq!(txt.nodes[0].kind, NodeKind::File);
2664    }
2665
2666    #[test]
2667    fn tags_extracts_python_symbols_calls_and_nesting() {
2668        let src = "def helper():\n    pass\n\nclass Thing:\n    def run(self):\n        helper()\n";
2669        let fs = Registry::default().extract("app.py", "b", src.as_bytes());
2670
2671        let names: Vec<&str> = fs.nodes.iter().map(|n| n.name.as_str()).collect();
2672        assert!(names.contains(&"helper"), "top-level function");
2673        assert!(names.contains(&"Thing"), "class");
2674        assert!(names.contains(&"run"), "method");
2675
2676        // Every symbol is language-tagged.
2677        assert_eq!(
2678            fs.nodes
2679                .iter()
2680                .find(|n| n.name == "helper")
2681                .and_then(|n| n.lang.as_deref()),
2682            Some("python")
2683        );
2684
2685        // The method is nested in the class: a `contains` edge to `Thing::run`.
2686        assert!(
2687            fs.edges
2688                .iter()
2689                .any(|e| e.kind == EdgeKind::Contains && e.dst.ends_with("#Thing::run")),
2690            "method nested under class via containment"
2691        );
2692
2693        // The method's body calls `helper`, recorded for later resolution.
2694        let run = fs.nodes.iter().find(|n| n.name == "run").unwrap();
2695        let calls = run.meta.get("calls").and_then(|v| v.as_array()).unwrap();
2696        assert!(
2697            calls.iter().any(|c| c.as_str() == Some("helper")),
2698            "enclosed call captured in meta.calls"
2699        );
2700    }
2701
2702    #[test]
2703    fn tags_extraction_is_deterministic() {
2704        let src = b"package main\nfunc Add(a int) int { return a }\n";
2705        let a = Registry::default().extract("m.go", "b", src);
2706        let b = Registry::default().extract("m.go", "b", src);
2707        assert_eq!(a, b, "tags extraction must be deterministic");
2708        assert!(
2709            a.nodes
2710                .iter()
2711                .any(|n| n.name == "Add" && n.kind == NodeKind::Fn)
2712        );
2713    }
2714
2715    #[test]
2716    fn tags_extracts_typescript() {
2717        let ts = Registry::default().extract("svc.ts", "b", b"export class Svc {\n  run() {}\n}\n");
2718        assert!(ts.nodes.iter().any(|n| n.name == "Svc"), "class");
2719        assert!(ts.nodes.iter().any(|n| n.name == "run"), "method");
2720        assert_eq!(
2721            ts.nodes
2722                .iter()
2723                .find(|n| n.name == "Svc")
2724                .and_then(|n| n.lang.as_deref()),
2725            Some("typescript")
2726        );
2727    }
2728
2729    // Extract `src` as `path` and collect the `import:<…>` targets it emits.
2730    // Every import node's key is global, so — like the Rust walker's — it must
2731    // carry no `path`, keeping the node stable when several files import it.
2732    fn import_targets(path: &str, src: &[u8]) -> Vec<String> {
2733        Registry::default()
2734            .extract(path, "b", src)
2735            .nodes
2736            .iter()
2737            .filter(|n| n.kind == NodeKind::Other("import".into()))
2738            .inspect(|n| {
2739                assert!(
2740                    n.path.is_none(),
2741                    "import node must not be file-scoped: {}",
2742                    n.key
2743                );
2744            })
2745            .map(|n| n.key.clone())
2746            .collect()
2747    }
2748
2749    #[test]
2750    fn extracts_imports_edges_per_language() {
2751        // Each case: a file with import statements → the expected `import:` nodes,
2752        // plus a `file → import` Imports edge.
2753        let cases: &[(&str, &[u8], &[&str])] = &[
2754            (
2755                "app.py",
2756                b"import os\nfrom a.b import c\nimport x.y as z\n",
2757                &["import:python:os", "import:python:a.b", "import:python:x.y"],
2758            ),
2759            (
2760                "m.js",
2761                b"import foo from \"./mod.js\";\nexport { y } from \"./y.js\";\n",
2762                &["import:javascript:./mod.js", "import:javascript:./y.js"],
2763            ),
2764            (
2765                "svc.ts",
2766                b"import { A } from \"./a\";\n",
2767                &["import:typescript:./a"],
2768            ),
2769            (
2770                "m.go",
2771                b"package main\nimport (\n\t\"fmt\"\n\t\"os\"\n)\n",
2772                &["import:go:fmt", "import:go:os"],
2773            ),
2774            (
2775                "M.java",
2776                b"import java.util.List;\nimport static a.B.c;\n",
2777                &["import:java:java.util.List", "import:java:a.B.c"],
2778            ),
2779            (
2780                "m.c",
2781                b"#include <stdio.h>\n#include \"local.h\"\n",
2782                &["import:c:stdio.h", "import:c:local.h"],
2783            ),
2784            ("m.cpp", b"#include <vector>\n", &["import:cpp:vector"]),
2785        ];
2786        for (path, src, expected) in cases {
2787            let got = import_targets(path, src);
2788            for want in *expected {
2789                assert!(
2790                    got.iter().any(|k| k == want),
2791                    "{path}: expected import node {want}, got {got:?}"
2792                );
2793            }
2794            // The corresponding file → import edge is derived.
2795            let fs = Registry::default().extract(path, "b", src);
2796            for want in *expected {
2797                assert!(
2798                    fs.edges.iter().any(|e| e.kind == EdgeKind::Imports
2799                        && e.src == format!("file:{path}")
2800                        && &e.dst == want),
2801                    "{path}: expected Imports edge to {want}"
2802                );
2803            }
2804        }
2805    }
2806
2807    #[test]
2808    fn every_registered_language_query_compiles() {
2809        // A grammar/query mismatch (e.g. a future grammar bump) would make a
2810        // language silently fall back to a plain file node; assert each query
2811        // compiles against its grammar so that regression surfaces here instead.
2812        for ext in [
2813            "py", "js", "ts", "tsx", "go", "rb", "java", "c", "cpp", "cs", "php", "scala", "ml",
2814            "mli", "ex", "sh", "sql",
2815        ] {
2816            let def = super::tag_lang_for(ext).unwrap_or_else(|| panic!("no language for .{ext}"));
2817            let lang = def.lang;
2818            assert!(
2819                super::tag_config(&def).is_some(),
2820                "tags query for .{ext} ({lang}) must compile against its grammar"
2821            );
2822        }
2823    }
2824
2825    #[test]
2826    fn ocaml_impl_and_interface_cache_under_distinct_grammars() {
2827        // `.ml` and `.mli` share the `ocaml` label but use different grammars, so
2828        // their config-cache keys must differ or one would parse with the other's
2829        // grammar (see the config cache keyed on `grammar_key`, not `lang`).
2830        let ml = super::tag_lang_for("ml").unwrap();
2831        let mli = super::tag_lang_for("mli").unwrap();
2832        assert_eq!(ml.lang, "ocaml");
2833        assert_eq!(mli.lang, "ocaml");
2834        assert_ne!(
2835            ml.grammar_key, mli.grammar_key,
2836            "distinct grammars must cache separately"
2837        );
2838    }
2839
2840    #[test]
2841    fn tags_extracts_vendored_bash_query() {
2842        let src = "greet() {\n  echo hi\n}\nmain() {\n  greet\n}\n";
2843        let fs = Registry::default().extract("run.sh", "b", src.as_bytes());
2844        let names: Vec<&str> = fs.nodes.iter().map(|n| n.name.as_str()).collect();
2845        assert!(names.contains(&"greet"), "shell function greet");
2846        assert!(names.contains(&"main"), "shell function main");
2847
2848        // `main` invokes `greet` — a command reference captured as a call.
2849        let main = fs.nodes.iter().find(|n| n.name == "main").unwrap();
2850        assert!(
2851            main.meta
2852                .get("calls")
2853                .and_then(|v| v.as_array())
2854                .is_some_and(|c| c.iter().any(|x| x.as_str() == Some("greet"))),
2855            "internal command invocation captured"
2856        );
2857    }
2858
2859    #[test]
2860    fn tags_extracts_vendored_sql_query() {
2861        let src = "CREATE TABLE users (id int);\n\
2862                   CREATE FUNCTION recent() RETURNS int AS $$ SELECT total(id) FROM users $$ LANGUAGE sql;\n";
2863        let fs = Registry::default().extract("schema.sql", "b", src.as_bytes());
2864        let names: Vec<&str> = fs.nodes.iter().map(|n| n.name.as_str()).collect();
2865        assert!(names.contains(&"users"), "table definition");
2866        assert!(names.contains(&"recent"), "function definition");
2867
2868        // The table maps to a non-function kind; the function to `Fn`.
2869        assert_eq!(
2870            fs.nodes.iter().find(|n| n.name == "users").map(|n| &n.kind),
2871            Some(&NodeKind::Other("table".to_owned()))
2872        );
2873        // The function body invokes `total`, captured for resolution.
2874        let f = fs.nodes.iter().find(|n| n.name == "recent").unwrap();
2875        assert!(
2876            f.meta
2877                .get("calls")
2878                .and_then(|v| v.as_array())
2879                .is_some_and(|c| c.iter().any(|x| x.as_str() == Some("total"))),
2880            "invocation inside function captured in meta.calls"
2881        );
2882        assert_eq!(
2883            fs.nodes
2884                .iter()
2885                .find(|n| n.name == "users")
2886                .and_then(|n| n.lang.as_deref()),
2887            Some("sql")
2888        );
2889    }
2890
2891    #[test]
2892    fn ingest_prose_toggle_gates_embedded_content() {
2893        use super::IngestConfig;
2894
2895        let content = |ingest: IngestConfig| {
2896            Registry::new(ingest)
2897                .extract("notes.md", "b", b"# Title\n\nBody text.\n")
2898                .nodes[0]
2899                .meta
2900                .get("content")
2901                .and_then(|v| v.as_str())
2902                .map(str::to_owned)
2903        };
2904
2905        // Default (prose on) embeds the markdown body; disabling prose drops it.
2906        assert!(
2907            content(IngestConfig::default()).is_some_and(|c| c.contains("Body text")),
2908            "prose content embedded by default"
2909        );
2910        assert_eq!(
2911            content(IngestConfig {
2912                prose: false,
2913                ..IngestConfig::default()
2914            }),
2915            None,
2916            "disabling prose suppresses the embedded body"
2917        );
2918    }
2919
2920    #[test]
2921    fn env_tag_stable_by_default_and_shifts_when_gated() {
2922        use super::IngestConfig;
2923
2924        // All-on is the default: its tag must equal a plain `Registry` so existing
2925        // caches are untouched.
2926        let all_on = Registry::new(IngestConfig::default()).env_tag();
2927        assert_eq!(all_on, Registry::default().env_tag());
2928
2929        // Each disabled toggle changes the tag (forcing re-extraction), and
2930        // distinct disabled sets produce distinct tags.
2931        let no_prose = Registry::new(IngestConfig {
2932            prose: false,
2933            ..IngestConfig::default()
2934        })
2935        .env_tag();
2936        let no_pdf = Registry::new(IngestConfig {
2937            pdf: false,
2938            ..IngestConfig::default()
2939        })
2940        .env_tag();
2941        let no_ocr = Registry::new(IngestConfig {
2942            ocr: false,
2943            ..IngestConfig::default()
2944        })
2945        .env_tag();
2946        assert_ne!(no_prose, all_on);
2947        assert_ne!(no_pdf, all_on);
2948        assert_ne!(no_ocr, all_on);
2949        assert_ne!(no_prose, no_pdf);
2950        assert_ne!(no_ocr, no_prose);
2951        assert_ne!(no_ocr, no_pdf);
2952    }
2953
2954    /// The generation toggles must **not** move the extraction cache key.
2955    ///
2956    /// Before ADR-0015 they did, and correctly so: `audio = false` changed what
2957    /// went into `meta.content`. It no longer changes any derived fact, so
2958    /// folding it in would force every user of `[ingest] audio = false` — this
2959    /// repository among them — into a full, pointless re-extraction. This test is
2960    /// the difference between that being a decision and being an oversight.
2961    #[test]
2962    fn generation_toggles_do_not_move_the_extraction_cache_key() {
2963        use super::IngestConfig;
2964
2965        let all_on = Registry::default().env_tag();
2966        for (label, cfg) in [
2967            (
2968                "audio",
2969                IngestConfig {
2970                    audio: false,
2971                    ..IngestConfig::default()
2972                },
2973            ),
2974            (
2975                "vision",
2976                IngestConfig {
2977                    vision: false,
2978                    ..IngestConfig::default()
2979                },
2980            ),
2981            (
2982                "both",
2983                IngestConfig {
2984                    audio: false,
2985                    vision: false,
2986                    ..IngestConfig::default()
2987                },
2988            ),
2989        ] {
2990            assert_eq!(
2991                Registry::new(cfg).env_tag(),
2992                all_on,
2993                "`{label}` gates generation, not extraction, so it must not move the cache key",
2994            );
2995        }
2996    }
2997
2998    /// The two groups of toggle, stated as behaviour: `generates` answers for the
2999    /// generation pair and nothing else consults them.
3000    #[test]
3001    fn generation_toggles_gate_media_build() {
3002        use super::IngestConfig;
3003        use crate::media::MediaKind;
3004
3005        let all_on = IngestConfig::default();
3006        assert!(all_on.generates(MediaKind::Audio));
3007        assert!(all_on.generates(MediaKind::Vision));
3008
3009        let no_audio = IngestConfig {
3010            audio: false,
3011            ..IngestConfig::default()
3012        };
3013        assert!(!no_audio.generates(MediaKind::Audio));
3014        assert!(
3015            no_audio.generates(MediaKind::Vision),
3016            "each modality is gated independently"
3017        );
3018    }
3019}
3020
3021/// A tiny in-memory PNG for the media-engine tests, so they need no fixture file
3022/// on disk. A visible diagonal, so the model has *something* to describe.
3023#[cfg(all(test, feature = "image-vision"))]
3024fn tiny_png() -> Vec<u8> {
3025    let img = image::RgbImage::from_fn(32, 32, |x, y| {
3026        if x == y {
3027            image::Rgb([0, 0, 0])
3028        } else {
3029            image::Rgb([255, 255, 255])
3030        }
3031    });
3032    let mut png = std::io::Cursor::new(Vec::new());
3033    image::DynamicImage::ImageRgb8(img)
3034        .write_to(&mut png, image::ImageFormat::Png)
3035        .expect("encode png");
3036    png.into_inner()
3037}
3038
3039/// Serialises the tests that drive the process-wide media engines.
3040///
3041/// The engine slots and the llama.cpp backend beneath them are process globals,
3042/// and these tests both build and release them; the harness's default parallelism
3043/// would otherwise let one test's [`release_media_engines`] land in the middle of
3044/// another's engine lifetime, making both flaky. A poisoned lock only means an
3045/// earlier test panicked, so recover rather than cascade.
3046#[cfg(all(test, any(feature = "image-vision", feature = "audio-transcribe")))]
3047fn serialise_media_engine_test() -> std::sync::MutexGuard<'static, ()> {
3048    static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3049    LOCK.lock()
3050        .unwrap_or_else(std::sync::PoisonError::into_inner)
3051}
3052
3053/// Teardown cover for the real vision engine (issue #291), on a host that has
3054/// the model installed.
3055///
3056/// Compiled only under `image-vision` and **self-skipping** when
3057/// `smolvlm-500m-gguf` is not in the model store, so CI — Ubuntu, no GPU, no
3058/// models — compiles it and prints a skip rather than failing. On a machine that
3059/// *does* have the model there are two assertions:
3060///
3061/// 1. the explicit one below: after a real description, the cached engine is
3062///    released, exactly once;
3063/// 2. an implicit one that is the whole point of the fix — the **test binary's
3064///    own exit status**. This test loads a llama.cpp engine on the process's
3065///    default backend; if the engine were parked in a never-dropped `static`
3066///    again, this binary would abort in ggml-metal's exit-time teardown
3067///    (SIGABRT) after every test had "passed", exactly as `roteiro sync` did.
3068///
3069/// The mechanism itself — release-once, idempotent, safe when uninitialised — is
3070/// covered without any model or GPU in `rto_llama::slot`'s unit tests.
3071#[cfg(all(test, feature = "image-vision"))]
3072mod vision_engine_teardown {
3073    // The engines and the generation they serve moved to `crate::media::producers`
3074    // with ADR-0015; the *release* stayed in `extract`, which is what `main` holds
3075    // for the process. So this test imports from both, and that split is the thing
3076    // it is guarding.
3077    use super::{release_media_engines, serialise_media_engine_test, tiny_png};
3078    use crate::media::producers::{VLM_MODEL, vlm_content};
3079
3080    #[test]
3081    fn describing_an_image_leaves_a_releasable_engine() {
3082        let _serial = serialise_media_engine_test();
3083        let dir = crate::models::model_dir(VLM_MODEL);
3084        if !dir.join("model.gguf").exists() || !dir.join("mmproj.gguf").exists() {
3085            eprintln!("SKIP: `{VLM_MODEL}` not installed (run `roteiro model pull {VLM_MODEL}`)");
3086            return;
3087        }
3088
3089        // The production path: this is what a `sync` does for every image blob.
3090        // Whether the model finds words for a 32×32 diagonal is not the subject —
3091        // that it loaded, and can now be torn down, is.
3092        let _description = vlm_content(&tiny_png());
3093
3094        assert!(
3095            release_media_engines(),
3096            "the engine `vlm_content` cached must be released, not leaked to exit"
3097        );
3098        assert!(
3099            !release_media_engines(),
3100            "releasing again must be a no-op, so every exit path can call it"
3101        );
3102    }
3103}
3104
3105/// Both modalities in one process (issue #296), on a host that has both models.
3106///
3107/// This is the case the shared backend exists for, and the one that could not be
3108/// written before it: `LlamaBackend::init()` was per-engine, so whichever engine
3109/// a run built second got `BackendAlreadyInitialized`, `.ok()` turned that into
3110/// `None`, and the second modality was quietly missing. The first assertion below
3111/// is that *both* engines now exist.
3112///
3113/// Compiled only when both media features are on, and **self-skipping** when
3114/// either GGUF is absent, so CI — Ubuntu, no GPU, no models — compiles it and
3115/// prints a skip. On a host that has them, three things are checked:
3116///
3117/// 1. both engines build in one process, and are the same backend's;
3118/// 2. both actually run — the vision engine describes a generated PNG and the
3119///    audio engine transcribes a committed WAV fixture, so the audio path is
3120///    exercised end to end (the coverage gap #292 could not close);
3121/// 3. that each modality loads **its own** projector, exactly once (issue #301).
3122///    Two blobs per modality leave each engine at one projector initialisation,
3123///    and the two projectors are separate objects: a cache that ignored *which*
3124///    projector was being asked for would hand the audio engine the vision one,
3125///    whose `support_audio` is false — the failure mode #298 makes possible by
3126///    letting both modalities be live at the same time;
3127/// 4. the **test binary's own exit status**, which is the sharpest guard of all:
3128///    two engines' models — and now their cached projectors — are resident on one
3129///    backend, and if the backend were freed before them, or any of them leaked
3130///    to `exit()`, this binary would abort in ggml-metal's teardown (SIGABRT,
3131///    exit 134) *after* every test had "passed", exactly as `roteiro sync` did in
3132///    #291.
3133#[cfg(all(test, feature = "image-vision", feature = "audio-transcribe"))]
3134mod two_modality_teardown {
3135    use super::{release_media_engines, serialise_media_engine_test, tiny_png};
3136    use crate::media::producers::{
3137        ASR_MODEL, VLM_MODEL, asr_content, asr_engine, vlm_content, vlm_engine,
3138    };
3139
3140    /// Half a second of 16-bit mono 16 kHz PCM in a WAV container: the committed
3141    /// `syllables` fixture, embedded at compile time.
3142    ///
3143    /// This test used to synthesise its own WAV here, which made the workspace
3144    /// carry two hand-written RIFF writers (#302). The other one — in
3145    /// `tests/audio_fixtures.rs` — is the one worth keeping: it is a reusable
3146    /// `encode(rate, samples)` rather than one hardcoded clip, it sits alongside
3147    /// the FLAC and MP3 writers, and it is integer-exact end to end (a Q15 sine
3148    /// table, no `f64::sin` and no `as i16`), so it needs no
3149    /// `cast_possible_truncation` suppression where the generator here did.
3150    ///
3151    /// It cannot simply be *called* from here, though — and a shared helper in
3152    /// `src/` could not be called from there either. Both directions are
3153    /// blocked, for *different* reasons:
3154    ///
3155    /// * `src/` → `tests/`: each file under `tests/` is compiled as its own
3156    ///   crate, which links the library. The library cannot depend on them; they
3157    ///   depend on it. `cfg(test)` has nothing to do with this direction.
3158    /// * `tests/` → `src/`: the library is rebuilt *without* `--cfg test` when
3159    ///   an integration-test crate links it, so a `#[cfg(test)]` helper in
3160    ///   `src/` is simply absent from the artefact those crates see.
3161    ///
3162    /// So what crosses the boundary is the encoder's *output*, not its source:
3163    /// the bytes it already commits under `tests/fixtures/audio/`, whose
3164    /// reproducibility `fixtures_are_byte_reproducible` gates on every run. This
3165    /// test reads the artefact instead of re-implementing the tool, and the
3166    /// workspace is left with exactly one WAV encoder.
3167    ///
3168    /// `include_bytes!` rather than `std::fs::read`, so a renamed or deleted
3169    /// fixture is a build error rather than a panic inside a test whose subject
3170    /// is engine teardown.
3171    ///
3172    /// `syllables` and not `silence` for the reason the old generator picked a
3173    /// tone over silence — near-silence makes an ASR model hallucinate — and over
3174    /// the tone because it is speech-*shaped* (four voiced bursts under a
3175    /// trapezoidal envelope), which is a fairer exercise of decode + projection.
3176    /// It is also the fixture `audio_ingest.rs` already drives through the real
3177    /// projector, so it is known to decode. The point is still to reach the
3178    /// model, not to assert on its words.
3179    /// `pub(super)` so the sibling `projector_binding` test drives the same clip
3180    /// rather than reaching for a second fixture — one committed WAV, read by
3181    /// everything that needs one (#302).
3182    pub(super) const TINY_WAV: &[u8] =
3183        include_bytes!("../tests/fixtures/audio/syllables-16khz-mono-512ms.wav");
3184
3185    /// Whether `name`'s GGUF pair is in the model store.
3186    fn installed(name: &str) -> bool {
3187        let dir = crate::models::model_dir(name);
3188        dir.join("model.gguf").exists() && dir.join("mmproj.gguf").exists()
3189    }
3190
3191    #[test]
3192    fn both_modalities_get_a_working_engine_in_one_process() {
3193        let _serial = serialise_media_engine_test();
3194        if !installed(VLM_MODEL) || !installed(ASR_MODEL) {
3195            eprintln!(
3196                "SKIP: need both `{VLM_MODEL}` and `{ASR_MODEL}` installed \
3197                 (run `roteiro model pull <name>`)"
3198            );
3199            return;
3200        }
3201
3202        // (1) Construction, which is where #296 bit. Order is deliberate: the
3203        // audio engine is the *second* one built, so it is the one that used to
3204        // come back `None`.
3205        assert!(vlm_engine().is_some(), "the vision engine must build");
3206        assert!(
3207            asr_engine().is_some(),
3208            "the second engine must share the first's backend, not be inert (#296)"
3209        );
3210
3211        // (2) Both actually infer. What the models make of a diagonal and four
3212        // voiced bursts is not the subject — that each loaded a model on the
3213        // shared backend and produced a completion is. `*_content` returns `None`
3214        // on a blank result, so this asserts on reaching the model, not on its
3215        // words. Two blobs per modality, because one could not tell a cached
3216        // projector from a rebuilt one.
3217        let png = tiny_png();
3218        let _description = vlm_content(&png);
3219        let _transcript = asr_content(TINY_WAV);
3220        let _description_again = vlm_content(&png);
3221        let _transcript_again = asr_content(TINY_WAV);
3222
3223        // (3) Each modality loaded its own projector, once (#301). Before the
3224        // cache these counts would have been 2 and 2; with a cache that was not
3225        // keyed per projector, the second modality would have been handed the
3226        // first's context and produced nothing at all.
3227        let (vision, audio) = (
3228            vlm_engine().expect("resident").projector_inits(),
3229            asr_engine().expect("resident").projector_inits(),
3230        );
3231        assert_eq!(vision, 1, "two images must load the vision projector once");
3232        assert_eq!(audio, 1, "two clips must load the audio projector once");
3233
3234        // (4) Teardown, in the order llama.cpp requires: both engines, then the
3235        // backend they shared. `release_media_engines` does that, and nothing
3236        // here could have got it wrong — while either engine were alive, the
3237        // backend release would simply have declined.
3238        assert!(
3239            release_media_engines(),
3240            "two engines and a backend must all be released, not leaked to exit"
3241        );
3242        assert!(
3243            !release_media_engines(),
3244            "releasing again must be a no-op, so every exit path can call it"
3245        );
3246    }
3247}
3248
3249/// A cached projector never outlives the model it is bound to (issue #301).
3250///
3251/// This is the hazard caching an `mtmd_context` introduces, and the reason the
3252/// cache is keyed by the model as well as by the `mmproj`: `mtmd_init_from_file`
3253/// keeps the `llama_model *` it was handed and dereferences it on every
3254/// `tokenize`/`eval_chunks`. Models are not permanent — the residency cache
3255/// evicts them — so a projector that survived its model would be a dangling
3256/// pointer waiting for the next blob.
3257///
3258/// The test drives that eviction deliberately: one engine, both models, and the
3259/// default budget, which keeps exactly **one** model resident. Alternating
3260/// modalities therefore unloads and reloads, and the projector count is what
3261/// distinguishes the two designs — a cache keyed on the `mmproj` path alone would
3262/// hand the third call the first call's projector, pointing at freed memory.
3263///
3264/// Self-skipping when either GGUF is absent, like its neighbours, and it uses the
3265/// fixtures they already commit rather than generating new ones. Its own exit
3266/// status is an assertion too: it builds projectors over a model that is then
3267/// freed, which is precisely the sequence that would abort at `exit()` if a
3268/// projector were left behind.
3269#[cfg(all(test, feature = "image-vision", feature = "audio-transcribe"))]
3270mod projector_binding {
3271    use super::two_modality_teardown::TINY_WAV;
3272    use super::{release_media_engines, serialise_media_engine_test, tiny_png};
3273    use crate::media::producers::{ASR_MODEL, VLM_MODEL};
3274    use rto_llama::llama::{LlamaEngine, Served};
3275    use rto_llama::{ChatRequest, Engine, Message};
3276
3277    /// `name`'s installed GGUF pair, or `None` when it is not in the model store.
3278    fn served(name: &str) -> Option<Served> {
3279        let dir = crate::models::model_dir(name);
3280        let (gguf, mmproj) = (dir.join("model.gguf"), dir.join("mmproj.gguf"));
3281        (gguf.exists() && mmproj.exists()).then(|| Served {
3282            name: name.to_owned(),
3283            path: gguf,
3284            mmproj: Some(mmproj),
3285        })
3286    }
3287
3288    /// One media request through `engine`, returning the completion text.
3289    fn media_chat(
3290        engine: &LlamaEngine,
3291        model: &str,
3292        images: Vec<Vec<u8>>,
3293        audio: Vec<Vec<u8>>,
3294    ) -> String {
3295        engine
3296            .chat(&ChatRequest {
3297                model: model.to_owned(),
3298                messages: vec![Message {
3299                    role: "user".to_owned(),
3300                    content: "Describe what you perceive in one short sentence.".to_owned(),
3301                }],
3302                images,
3303                audio,
3304                temperature: 0.0,
3305                max_tokens: 32,
3306            })
3307            .expect("the blob reaches its projector and completes")
3308            .content
3309    }
3310
3311    #[test]
3312    fn evicting_a_model_rebuilds_its_projector_rather_than_reusing_a_stale_one() {
3313        let _serial = serialise_media_engine_test();
3314        let (Some(vlm), Some(asr)) = (served(VLM_MODEL), served(ASR_MODEL)) else {
3315            eprintln!(
3316                "SKIP: need both `{VLM_MODEL}` and `{ASR_MODEL}` installed \
3317                 (run `roteiro model pull <name>`)"
3318            );
3319            return;
3320        };
3321
3322        // Budget 0: one model resident, so each switch of modality evicts the
3323        // other — and takes its projector with it.
3324        let engine = LlamaEngine::new(vec![vlm, asr], 0).expect("engine builds");
3325
3326        let first = media_chat(&engine, ASR_MODEL, Vec::new(), vec![TINY_WAV.to_vec()]);
3327        assert_eq!(engine.projector_inits(), 1, "the audio projector loaded");
3328
3329        let described = media_chat(&engine, VLM_MODEL, vec![tiny_png()], Vec::new());
3330        assert!(
3331            !described.trim().is_empty(),
3332            "a second, different projector must work in the same process (#298)"
3333        );
3334        assert_eq!(
3335            engine.projector_inits(),
3336            2,
3337            "a different mmproj is a different projector — never the first one reused"
3338        );
3339
3340        // The audio model was evicted by the image; asking for it again reloads it
3341        // at a new address, so its projector must be rebuilt against *that* model.
3342        let again = media_chat(&engine, ASR_MODEL, Vec::new(), vec![TINY_WAV.to_vec()]);
3343        assert_eq!(
3344            engine.projector_inits(),
3345            3,
3346            "a reloaded model gets a freshly bound projector, not the evicted model's"
3347        );
3348        assert_eq!(
3349            first, again,
3350            "and the rebuilt projector produces exactly what the original did"
3351        );
3352
3353        // Engine first (its models and their projectors), backend last.
3354        drop(engine);
3355        assert!(
3356            release_media_engines(),
3357            "the backend is releasable once the engine holding it is gone"
3358        );
3359    }
3360}