memstead_base/engine/export_llms_txt.rs
1//! Render one mem as a single agent-readable Markdown document — the
2//! `/llms-full.txt` shape, shared by the served endpoint and
3//! `memstead export --format llms-txt`.
4//!
5//! The document is built to be *swallowed whole*: an agent whose goal is
6//! "understand this graph fast" reads one document instead of walking the
7//! graph entity by entity. That is why the shape is what it is — every
8//! non-stub entity exactly once, in stable order, with its type visible and
9//! its references resolved to links that work from inside the flat document.
10//!
11//! **This lives in the engine so the served and exported documents cannot
12//! drift.** It was previously deployment-local, and matching the deployed
13//! shape by copying it would have made "matches" a snapshot rather than a
14//! property: two copies of a document shape is exactly how a divergence
15//! starts. One renderer, two callers.
16//!
17//! Stubs never appear. A stub is the engine's placeholder for an unresolved
18//! reference, not content, and the document's own header promises every
19//! non-stub entity — so rendering one would make the header false.
20//!
21//! Empty sections are kept verbatim. An explicitly empty slot is signal to an
22//! agent: it says the schema asks for this and nobody has answered, which is
23//! different from the section not existing.
24
25use crate::entity::EntityId;
26use crate::render;
27
28/// Deployment-supplied inputs to the document header.
29///
30/// Everything here is context the *renderer* cannot know: who is serving the
31/// document and what else the reader should be pointed at. A CLI export has no
32/// deployment identity, so these are optional rather than defaulted — a header
33/// that invents an authority would be lying about provenance, which is the one
34/// thing this document exists to state plainly.
35#[derive(Debug, Clone, Default)]
36pub struct LlmsTxtContext {
37 /// The serving authority (a host). `None` for a CLI export, whose header
38 /// names the mem instead — no deployment is vouching for the bytes.
39 pub authority: Option<String>,
40 /// Absolute link prefix (origin + any gate prefix). Empty renders entity
41 /// references as the relative `entity/<id>`, which is what a document
42 /// exported to a file wants.
43 pub href_prefix: String,
44 /// Cross-origin links the serving surface wants an agent to see. Empty
45 /// omits the block entirely — a user exporting their own mem is not
46 /// advertising someone else's project.
47 pub wider_project: Vec<(String, String)>,
48}
49
50/// A markdown link for one entity. `[` / `]` in a title would break the link
51/// text, so they are folded to parentheses rather than escaped — the title is
52/// display text here, not data being round-tripped.
53pub fn entity_md_link(href_prefix: &str, id: &str, title: &str) -> String {
54 let text = title.replace('[', "(").replace(']', ")");
55 if href_prefix.is_empty() {
56 format!("[{text}](entity/{id})")
57 } else {
58 format!("[{text}]({href_prefix}/entity/{id})")
59 }
60}
61
62/// Drop the frontmatter block the shared entity render emits. The flat
63/// document surfaces the type as a visible line instead; the rest of the
64/// frontmatter is agent-budget metadata that would be noise repeated once per
65/// entity.
66pub fn strip_frontmatter(md: &str) -> String {
67 let mut lines = md.lines();
68 if lines.next() == Some("---") {
69 let mut closed = false;
70 let mut body: Vec<&str> = Vec::new();
71 for line in lines {
72 if !closed && line == "---" {
73 closed = true;
74 continue;
75 }
76 if closed {
77 body.push(line);
78 }
79 }
80 if closed {
81 return body.join("\n").trim_start_matches('\n').to_string();
82 }
83 }
84 md.to_string()
85}
86
87/// Rewrite `[[…]]` wiki-links to markdown links, resolving each occurrence
88/// under a three-rule precedence:
89///
90/// 1. **Qualified references** — the dash form (`[[mem--slug]]`) and the
91/// canonical colon form (`[[mem:slug]]`, the grammar's cross-mem shape,
92/// which hierarchical mems can only write this way) — unambiguous,
93/// always resolve; a qualified miss never falls through to the slug
94/// passes, because the author named a mem.
95/// 2. **Local bare slugs** — a slug present in the source mem binds there,
96/// however many other mems reuse it. That is the engine's authoring
97/// semantics: a bare wiki-link is a same-mem reference.
98/// 3. **Foreign bare slugs** — a slug owned by exactly one *other* mem
99/// resolves to it; a slug two foreign mems both own **stays raw text**.
100/// Guessing between them would fabricate a reference the author never
101/// made, and a visibly unresolved `[[slug]]` is the honest output.
102///
103/// **Code is never rewritten.** Resolution scans the engine's masked view —
104/// the same one every other reader uses — and slices from the original, so a
105/// fenced or inline code sample documenting wiki-link syntax comes out
106/// byte-identical. A hand-rolled string walk without masking is exactly the
107/// defect the HTML exporter was fixed for; sharing the masked view is what
108/// stops this renderer reintroducing it.
109///
110/// `id_titles` must contain **no stubs**. A stub is an unresolved reference
111/// the engine materialised, not content — and this document excludes stubs,
112/// so resolving a link to one would emit a link to a page the document itself
113/// does not contain. Worse, because an engine-authored bare wiki-link creates
114/// a *local* stub, stubs in the map make rule 2 match every bare slug and
115/// rules 3's foreign passes unreachable.
116pub fn linkify_wikilinks(
117 body: String,
118 id_titles: &[(String, String)],
119 href_prefix: &str,
120 source_mem: &str,
121) -> String {
122 let mut by_id: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
123 let mut local: std::collections::HashMap<&str, (&str, &str)> = std::collections::HashMap::new();
124 let mut foreign: std::collections::HashMap<&str, Option<(&str, &str)>> =
125 std::collections::HashMap::new();
126 for (id, title) in id_titles {
127 by_id.insert(id.as_str(), title.as_str());
128 if let Some((mem, slug)) = id.split_once("--") {
129 if mem == source_mem {
130 local.insert(slug, (id.as_str(), title.as_str()));
131 } else {
132 foreign
133 .entry(slug)
134 .and_modify(|e| *e = None)
135 .or_insert(Some((id.as_str(), title.as_str())));
136 }
137 }
138 }
139
140 let resolve = |target: &str| -> Option<(String, String)> {
141 if let Some(title) = by_id.get(target) {
142 return Some((target.to_string(), title.to_string()));
143 }
144 // The canonical colon form (`mem:slug`) is the same qualified
145 // reference as the dash form one grammar tier over — the entity
146 // parser and both wiki-link decoders read it, and hierarchical mems
147 // can only be referenced this way. Canonicalise and look up; a miss
148 // stays a miss, because the author named a mem and guessing a slug
149 // pass instead would rebind the reference.
150 if !target.contains("::")
151 && let Some((prefix, slug)) = target.split_once(':')
152 && !prefix.is_empty()
153 && !slug.is_empty()
154 {
155 let id = crate::entity::EntityId::new(prefix, slug);
156 return by_id
157 .get(id.as_ref())
158 .map(|title| (id.to_string(), title.to_string()));
159 }
160 if let Some((id, title)) = local.get(target) {
161 return Some((id.to_string(), title.to_string()));
162 }
163 // `Some(None)` is an ambiguous foreign slug — deliberately unresolved.
164 foreign
165 .get(target)
166 .copied()
167 .flatten()
168 .map(|(id, title)| (id.to_string(), title.to_string()))
169 };
170
171 // Offsets come from the masked view; every byte emitted comes from the
172 // original, so masking can only change WHICH spans are rewritten, never
173 // the bytes of the ones that are not.
174 let masked = crate::markdown::mask_code_blocks_and_spans(&body);
175 let mut out = String::with_capacity(body.len());
176 let mut cursor = 0usize;
177 let bytes = masked.as_bytes();
178 let mut i = 0usize;
179 while i + 1 < bytes.len() {
180 if bytes[i] == b'['
181 && bytes[i + 1] == b'['
182 && let Some(rel) = masked[i + 2..].find("]]")
183 {
184 let inner_start = i + 2;
185 let inner_end = inner_start + rel;
186 // The link text is read from the ORIGINAL: the masked view is
187 // only a map of where code is.
188 let inner = &body[inner_start..inner_end];
189 // Only the TARGET half resolves; an author-supplied label wins
190 // as link text, because that is what the author chose a reader
191 // to see. Target normalisation is the decoders' own
192 // `strip_wiki_link_decorations` — `|label`, `#anchor`, `../`,
193 // `.md` — so this renderer reads exactly the grammar the parser
194 // and validators read, no form less. Two rounds of the same
195 // leak (the aliased form, then the colon form) came from
196 // hand-rolling a subset of it here.
197 let label = inner.split_once('|').map(|(_, l)| l.trim());
198 let target = crate::entity::id::strip_wiki_link_decorations(inner);
199 out.push_str(&body[cursor..i]);
200 match resolve(&target) {
201 Some((id, title)) => {
202 out.push_str(&entity_md_link(href_prefix, &id, label.unwrap_or(&title)))
203 }
204 // Unresolvable: name it as PLAIN TEXT — never a link, never
205 // surviving `[[…]]` syntax.
206 //
207 // A link would invent a target: for an ambiguous foreign slug
208 // it would pick one of two arbitrarily, and for a stub target
209 // it would point at a page this document deliberately
210 // excludes. Leaving the brackets would put internal wiki-link
211 // syntax in front of a reader the header promised a
212 // self-contained document to. Plain text is the third option,
213 // and the only one that is neither a guess nor a leak.
214 // Print the label when the author gave one, else the target —
215 // never the raw `target|label` span, which would put an
216 // internal id and a pipe in front of the reader.
217 None => out.push_str(label.unwrap_or(&target)),
218 }
219 cursor = inner_end + 2;
220 i = inner_end + 2;
221 continue;
222 }
223 i += 1;
224 }
225 out.push_str(&body[cursor..]);
226 out
227}
228
229impl crate::Engine {
230 /// Render `mem` as one Markdown document in the `/llms-full.txt` shape.
231 ///
232 /// Refuses an unmounted mem with [`EngineError::UnknownMem`] rather than
233 /// emitting an empty document — a document that says "Entities: 0" about a
234 /// mem this workspace never mounted is a confident wrong answer, and the
235 /// caller can tell the two apart only if the engine does.
236 ///
237 /// [`EngineError::UnknownMem`]: crate::engine::EngineError::UnknownMem
238 pub fn render_llms_txt(
239 &self,
240 mem: &str,
241 ctx: &LlmsTxtContext,
242 ) -> Result<String, crate::engine::EngineError> {
243 self.render_llms_txt_scoped(mem, ctx, None)
244 }
245
246 /// [`Self::render_llms_txt`] reduced to a chain: only the mem's
247 /// entities in `chain` are rendered, wiki-links to entities outside
248 /// it stay raw (unresolved), and the header names the chain. `None`
249 /// is the whole mem, byte-identical to the unscoped document.
250 pub fn render_llms_txt_scoped(
251 &self,
252 mem: &str,
253 ctx: &LlmsTxtContext,
254 chain: Option<&crate::graph::chain::ChainSet>,
255 ) -> Result<String, crate::engine::EngineError> {
256 let mounted = self
257 .mounts
258 .iter()
259 .find(|m| m.mount.mem == mem)
260 .ok_or_else(|| crate::engine::EngineError::UnknownMem(mem.to_string()))?;
261
262 let schema_pin = mounted
263 .mount
264 .schema
265 .as_ref()
266 .map(|s| s.as_display())
267 .unwrap_or_default();
268 let config = self.mem_config_for(mem);
269 let subject = config
270 .and_then(|c| c.description.clone())
271 .unwrap_or_else(|| mem.to_string());
272 // The provenance line says who vouches. With an authority that is the
273 // deployment; without one it is the workspace the export came from.
274 // Printing "this deployment vouches" into a file exported from a
275 // laptop would make the header's one load-bearing sentence false.
276 let provenance = match (self.mem_origin_class(mem), ctx.authority.is_some()) {
277 (crate::render::OriginClass::FirstParty, true) => {
278 "first-party (this deployment vouches for the content as its own)"
279 }
280 (crate::render::OriginClass::ThirdParty, true) => {
281 "third-party (this deployment does not vouch for the content)"
282 }
283 (crate::render::OriginClass::FirstParty, false) => {
284 "first-party (authored in the workspace this export came from)"
285 }
286 (crate::render::OriginClass::ThirdParty, false) => {
287 "third-party (a read-only mount — someone else's published content)"
288 }
289 };
290
291 // Every non-stub entity of THIS mem, once, in stable id order —
292 // reduced to the chain when one is given. The link table is
293 // reduced the same way, so a link to an entity outside the chain
294 // stays raw text rather than pointing at a page this document
295 // does not contain.
296 let mut id_titles = self.entity_id_titles();
297 if let Some(chain) = chain {
298 id_titles.retain(|(id, _)| chain.contains(&EntityId::canonical(id)));
299 }
300 let mut ids: Vec<String> = self
301 .store
302 .all_entities()
303 .filter(|e| e.mem == mem && !e.stub && chain.is_none_or(|c| c.contains(&e.id)))
304 .map(|e| e.id.to_string())
305 .collect();
306 ids.sort();
307 let count = ids.len();
308 let chain_line = chain
309 .map(|c| format!("Chain: {}\n", c.describe()))
310 .unwrap_or_default();
311
312 // The header names an authority only when one is serving. A CLI export
313 // names the mem: no deployment is vouching for these bytes, and saying
314 // otherwise would put a false provenance line at the top of the one
315 // document written to be read whole and believed.
316 let heading = match &ctx.authority {
317 Some(a) => format!("# {a} — {subject}\n\nAuthority: {a}\n"),
318 // A mem with no description falls back to its own name as the
319 // subject, which would render "# srcmem — srcmem". Say it once.
320 None if subject == mem => format!("# {mem}\n\nMem: {mem}\n"),
321 None => format!("# {mem} — {subject}\n\nMem: {mem}\n"),
322 };
323 let wider = if ctx.wider_project.is_empty() {
324 String::new()
325 } else {
326 let lines: String = ctx
327 .wider_project
328 .iter()
329 .map(|(url, what)| format!("- {url} — {what}.\n"))
330 .collect();
331 // Trailing blank line: the served document has always had one
332 // between the list and the closing sentence, and without it the
333 // sentence becomes a lazy continuation of the last list item in
334 // Markdown. A `contains`-based test cannot see the difference,
335 // which is exactly why it went unnoticed.
336 format!("The wider project:\n{lines}\n")
337 };
338 let links_sentence = if ctx.href_prefix.is_empty() {
339 "Entity references are relative links to that entity's own page."
340 } else {
341 "Entity references are absolute links to that entity's own page."
342 };
343
344 let scope_sentence = if chain.is_some() {
345 "Every non-stub entity of this Memstead graph that the chain above reaches follows, \
346 once, with its type and sections; references to entities outside the chain are left \
347 as raw wiki-links."
348 } else {
349 "Every non-stub entity of this Memstead graph follows, once, with its type and \
350 sections."
351 };
352 let mut out = format!(
353 "{heading}\
354Subject: {subject}\n\
355Schema: {schema_pin}\n\
356{chain_line}\
357Entities: {count}\n\
358Provenance: {provenance}\n\n\
359{wider}\
360{scope_sentence} {links_sentence}\n\n\
361---\n\n"
362 );
363
364 for id in &ids {
365 let Some(entity) = self.get_entity(&EntityId::canonical(id)) else {
366 continue;
367 };
368 let md = strip_frontmatter(&render::render_entity_markdown(entity, None));
369 let typed = match md.split_once('\n') {
370 Some((title_line, rest)) => format!(
371 "{title_line}\n\n_Type: {}_\n\n{}",
372 entity.entity_type,
373 rest.trim_start_matches('\n')
374 ),
375 None => format!("{md}\n\n_Type: {}_", entity.entity_type),
376 };
377 out.push_str(&linkify_wikilinks(typed, &id_titles, &ctx.href_prefix, mem));
378 if !out.ends_with('\n') {
379 out.push('\n');
380 }
381 out.push_str("\n---\n\n");
382 }
383
384 Ok(out)
385 }
386
387 /// `(id, title)` for every entity in the store — the lookup the link
388 /// rewriter needs. Workspace-wide on purpose: a wiki-link may reach into
389 /// another mounted mem, and resolving it is what makes the flat document
390 /// navigable.
391 fn entity_id_titles(&self) -> Vec<(String, String)> {
392 // Stubs are excluded. This document omits stub entities, so linking to
393 // one would point at a page the document does not contain — and since
394 // an engine-authored bare wiki-link materialises a LOCAL stub, leaving
395 // stubs in would make every bare slug resolve locally and the
396 // foreign-slug rules unreachable.
397 let mut out: Vec<(String, String)> = self
398 .store
399 .all_entities()
400 .filter(|e| !e.stub)
401 .map(|e| (e.id.to_string(), e.title.clone()))
402 .collect();
403 out.sort();
404 out
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 fn titles() -> Vec<(String, String)> {
413 vec![
414 ("engine--mem".to_string(), "Mem".to_string()),
415 ("flagship--mem".to_string(), "Mem".to_string()),
416 ("engine--pipeline".to_string(), "Pipeline".to_string()),
417 ]
418 }
419
420 /// The three passes, and their precedence. A full id always resolves; a
421 /// bare slug binds mem-locally first (the engine's authoring semantics);
422 /// and a slug two FOREIGN mems both own stays raw text rather than being
423 /// guessed — a fabricated reference the author never made is worse than a
424 /// visibly unresolved one.
425 #[test]
426 fn wiki_links_resolve_in_three_passes_and_never_guess() {
427 let t = titles();
428
429 // Pass 1 — full id.
430 assert_eq!(
431 linkify_wikilinks("See [[engine--pipeline]].".to_string(), &t, "", "engine"),
432 "See [Pipeline](entity/engine--pipeline)."
433 );
434
435 // Pass 3 wins over pass 2 — the slug exists locally, so it binds
436 // locally however many other mems reuse it.
437 assert_eq!(
438 linkify_wikilinks("A [[mem]].".to_string(), &t, "", "engine"),
439 "A [Mem](entity/engine--mem)."
440 );
441 assert_eq!(
442 linkify_wikilinks("A [[mem]].".to_string(), &t, "", "flagship"),
443 "A [Mem](entity/flagship--mem)."
444 );
445
446 // Pass 2 — unique in exactly one foreign mem, so it resolves.
447 assert_eq!(
448 linkify_wikilinks("A [[pipeline]].".to_string(), &t, "", "flagship"),
449 "A [Pipeline](entity/engine--pipeline)."
450 );
451
452 // Ambiguous across two FOREIGN mems and absent locally: named, not
453 // guessed — and not left as wiki-link syntax either.
454 assert_eq!(
455 linkify_wikilinks("A [[mem]].".to_string(), &t, "", "plugin"),
456 "A mem.",
457 "an ambiguous foreign slug degrades to plain text, never a guess"
458 );
459 }
460
461 /// Exactly two link forms exist, selected by whether a base is given.
462 /// A third — root-relative `/entity/<id>` — must never appear: the
463 /// document is read both from a file and from a served page, and only
464 /// these two work in both places.
465 #[test]
466 fn only_two_link_forms_are_emitted() {
467 let t = titles();
468 let rel = linkify_wikilinks("[[engine--mem]]".to_string(), &t, "", "engine");
469 assert_eq!(rel, "[Mem](entity/engine--mem)");
470 assert!(!rel.contains("(/entity/"), "never root-relative: {rel}");
471
472 let abs = linkify_wikilinks(
473 "[[engine--mem]]".to_string(),
474 &t,
475 "https://example.com",
476 "engine",
477 );
478 assert_eq!(abs, "[Mem](https://example.com/entity/engine--mem)");
479 }
480
481 /// A title carrying brackets would break the markdown link text, so they
482 /// fold to parentheses. The link target is unaffected.
483 #[test]
484 fn bracketed_titles_cannot_break_the_link() {
485 let t = vec![("m--x".to_string(), "A [bracketed] title".to_string())];
486 assert_eq!(
487 linkify_wikilinks("[[m--x]]".to_string(), &t, "", "m"),
488 "[A (bracketed) title](entity/m--x)"
489 );
490 }
491
492 /// Wiki-link syntax inside code is documentation, not a reference. A
493 /// fenced block or inline span showing `[[slug]]` must come out
494 /// byte-identical — the defect the HTML exporter was fixed for, which a
495 /// hand-rolled string walk reintroduces the moment nobody checks.
496 #[test]
497 fn code_spans_and_fences_are_never_rewritten() {
498 let t = titles();
499 let body = "Prose [[engine--mem]] resolves.\n\n\
500 Inline `[[engine--mem]]` does not.\n\n\
501 ```\n[[engine--mem]]\n```\n";
502 let out = linkify_wikilinks(body.to_string(), &t, "", "engine");
503
504 assert!(
505 out.contains("Prose [Mem](entity/engine--mem) resolves."),
506 "prose still resolves: {out}"
507 );
508 assert!(
509 out.contains("Inline `[[engine--mem]]` does not."),
510 "an inline code span is left alone: {out}"
511 );
512 assert!(
513 out.contains("```\n[[engine--mem]]\n```"),
514 "a fenced block is left alone: {out}"
515 );
516 }
517
518 /// The engine's grammar has three wiki-link forms and this renderer must
519 /// read all of them. Only the target half resolves; an author-supplied
520 /// label is what a reader sees, and a `#Section` suffix addresses within
521 /// the target rather than naming a different one.
522 ///
523 /// Passing the whole span to the resolver made every aliased reference
524 /// miss and fall to the plain-text arm — printing the internal id and the
525 /// pipe into prose, precisely where the author had written a label.
526 #[test]
527 fn alias_and_anchor_wiki_link_forms_resolve() {
528 let t = titles();
529
530 // `[[target|label]]` — resolves, and the LABEL is the link text.
531 assert_eq!(
532 linkify_wikilinks("See [[engine--mem|the mem]].".to_string(), &t, "", "engine"),
533 "See [the mem](entity/engine--mem)."
534 );
535 // `[[target#Section]]` — the anchor addresses within the target.
536 assert_eq!(
537 linkify_wikilinks(
538 "See [[engine--mem#Identity]].".to_string(),
539 &t,
540 "",
541 "engine"
542 ),
543 "See [Mem](entity/engine--mem)."
544 );
545 // Both at once, and on a bare slug rather than a full id.
546 assert_eq!(
547 linkify_wikilinks("See [[mem#Identity|here]].".to_string(), &t, "", "engine"),
548 "See [here](entity/engine--mem)."
549 );
550 // Unresolvable WITH a label: the reader gets the label, never the
551 // internal target or the pipe.
552 assert_eq!(
553 linkify_wikilinks("See [[ghost|that thing]].".to_string(), &t, "", "engine"),
554 "See that thing."
555 );
556 }
557
558 /// The canonical colon cross-mem form (`[[mem:slug]]`) is a qualified
559 /// reference like the dash form — the grammar's own shape for it, and
560 /// the only one a hierarchical mem can be written in. It resolves to
561 /// the same target, an author label still wins as link text, and a miss
562 /// degrades to plain text WITHOUT falling through to the slug passes:
563 /// the author named a mem, and rebinding the slug elsewhere would be a
564 /// guess. The body of a live mem carried exactly this form into prose
565 /// as unresolved text when only `|` and `#` were hand-stripped here.
566 #[test]
567 fn colon_cross_mem_form_resolves_as_a_qualified_reference() {
568 let t = titles();
569
570 assert_eq!(
571 linkify_wikilinks("See [[flagship:mem]].".to_string(), &t, "", "engine"),
572 "See [Mem](entity/flagship--mem)."
573 );
574 assert_eq!(
575 linkify_wikilinks(
576 "See [[engine:mem|the mem]].".to_string(),
577 &t,
578 "",
579 "flagship"
580 ),
581 "See [the mem](entity/engine--mem)."
582 );
583 // A colon miss is a miss: `pipeline` is unique in a foreign mem, but
584 // the author qualified it into a mem that does not own it.
585 assert_eq!(
586 linkify_wikilinks("See [[flagship:pipeline]].".to_string(), &t, "", "engine"),
587 "See flagship:pipeline."
588 );
589 }
590
591 /// A reference to nothing is named in plain text — the same treatment an
592 /// ambiguous foreign slug gets, for the same reason: say what could not be
593 /// resolved without inventing a target, and without leaking internal
594 /// wiki-link syntax into a document promised as self-contained.
595 ///
596 /// The one place `[[…]]` legitimately survives is inside code, which is
597 /// never rewritten at all.
598 #[test]
599 fn an_unresolvable_reference_degrades_to_plain_text() {
600 let t = titles();
601 assert_eq!(
602 linkify_wikilinks("See [[ghost]].".to_string(), &t, "", "engine"),
603 "See ghost."
604 );
605 // A full id whose target is a stub is the same case: stubs are absent
606 // from the map by design, so the reference cannot resolve — and this
607 // is the shape the auto-generated `## Relationships` block emits.
608 assert_eq!(
609 linkify_wikilinks(
610 "- **USES**: [[engine--phantom]]".to_string(),
611 &t,
612 "",
613 "engine"
614 ),
615 "- **USES**: engine--phantom"
616 );
617 }
618
619 /// Frontmatter is dropped; a body that has none is returned untouched.
620 #[test]
621 fn frontmatter_is_stripped_only_when_present() {
622 assert_eq!(
623 strip_frontmatter("---\ntype: spec\n---\n\n# Title\n\nBody."),
624 "# Title\n\nBody."
625 );
626 assert_eq!(strip_frontmatter("# Title\n\nBody."), "# Title\n\nBody.");
627 // An unterminated block is not frontmatter — returning the body
628 // half-eaten would silently lose content.
629 assert_eq!(strip_frontmatter("---\nno close"), "---\nno close");
630 }
631}