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