1use std::collections::BTreeMap;
22use std::fmt::Write as _;
23
24use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
25
26use crate::engine::Engine;
27use crate::entity::Entity;
28use crate::workspace::MountCapability;
29
30fn esc(s: &str) -> String {
32 s.replace('&', "&")
33 .replace('<', "<")
34 .replace('>', ">")
35 .replace('"', """)
36}
37
38fn resolve_wiki_links(body: &str, mem: &str, exported_ids: &[String]) -> String {
53 let masked = crate::markdown::mask_code_blocks_and_spans(body);
54 let mut out = String::with_capacity(body.len());
55 let mut cursor = 0usize;
56 while let Some(rel) = masked[cursor..].find("[[") {
57 let start = cursor + rel;
58 out.push_str(&body[cursor..start]);
59 let after_start = start + 2;
60 match masked[after_start..].find("]]") {
61 None => {
62 out.push_str(&body[start..]);
63 cursor = body.len();
64 break;
65 }
66 Some(rel_end) => {
67 let end = after_start + rel_end;
68 let target = &body[after_start..end];
69 let full_id = if target.contains("--") {
70 target.to_string()
71 } else {
72 format!("{mem}--{target}")
73 };
74 if exported_ids.iter().any(|id| id == &full_id) {
75 let _ = write!(out, "[{target}](#{full_id})");
78 } else if full_id.starts_with(&format!("{mem}--")) {
79 let _ = write!(out, "{target} *(unresolved)*");
81 } else {
82 let _ = write!(out, "{full_id} *(other mem)*");
84 }
85 cursor = end + 2;
86 }
87 }
88 }
89 out.push_str(&body[cursor..]);
90 out
91}
92
93fn percent_decode(s: &str) -> String {
98 let bytes = s.as_bytes();
99 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
100 let mut i = 0;
101 while i < bytes.len() {
102 if bytes[i] == b'%'
103 && i + 2 < bytes.len()
104 && let (Some(h), Some(l)) = (
105 (bytes[i + 1] as char).to_digit(16),
106 (bytes[i + 2] as char).to_digit(16),
107 )
108 {
109 out.push((h * 16 + l) as u8);
110 i += 3;
111 } else {
112 out.push(bytes[i]);
113 i += 1;
114 }
115 }
116 String::from_utf8_lossy(&out).into_owned()
117}
118
119fn link_dest_allowed(dest: &str, exported_ids: &[String]) -> bool {
127 if let Some(frag) = dest.strip_prefix('#') {
128 let decoded = percent_decode(frag);
129 return exported_ids.iter().any(|id| id == &decoded);
130 }
131 let lower = dest.trim().to_ascii_lowercase();
132 lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("mailto:")
133}
134
135fn markdown_to_safe_html(md: &str, exported_ids: &[String]) -> String {
142 let options = crate::markdown::parser_options() | Options::ENABLE_STRIKETHROUGH;
148 let parser = Parser::new_ext(md, options);
149 let mut events: Vec<Event> = Vec::new();
150 let mut skipping_image: Option<(String, String)> = None; let mut suppressed_link: Option<String> = None;
154 for ev in parser {
155 if let Some((_url, alt)) = skipping_image.as_mut() {
156 match ev {
157 Event::End(TagEnd::Image) => {
158 let (url, alt) = skipping_image.take().unwrap();
159 let label = if alt.trim().is_empty() {
160 format!("image: {url}")
161 } else {
162 format!("image: {alt} ({url})")
163 };
164 if link_dest_allowed(&url, exported_ids) {
165 events.push(Event::Start(Tag::Link {
166 link_type: pulldown_cmark::LinkType::Inline,
167 dest_url: url.clone().into(),
168 title: "".into(),
169 id: "".into(),
170 }));
171 events.push(Event::Text(label.into()));
172 events.push(Event::End(TagEnd::Link));
173 } else {
174 events.push(Event::Text(format!("[{label}]").into()));
176 }
177 }
178 Event::Text(t) => alt.push_str(&t),
179 _ => {}
180 }
181 continue;
182 }
183 match ev {
184 Event::Html(s) | Event::InlineHtml(s) => {
186 events.push(Event::Text(s));
187 }
188 Event::Start(Tag::Image { dest_url, .. }) => {
190 skipping_image = Some((dest_url.to_string(), String::new()));
191 }
192 Event::Start(Tag::Link { dest_url, .. }) if suppressed_link.is_none() => {
193 let dest = dest_url.to_string();
194 if link_dest_allowed(&dest, exported_ids) {
195 events.push(Event::Start(Tag::Link {
196 link_type: pulldown_cmark::LinkType::Inline,
197 dest_url: dest.into(),
198 title: "".into(),
199 id: "".into(),
200 }));
201 } else {
202 suppressed_link = Some(dest);
203 }
204 }
205 Event::End(TagEnd::Link) if suppressed_link.is_some() => {
206 let dest = suppressed_link.take().unwrap();
207 events.push(Event::Text(format!(" ({dest} — link removed)").into()));
208 }
209 other => events.push(other),
210 }
211 }
212 let mut html = String::new();
213 pulldown_cmark::html::push_html(&mut html, events.into_iter());
214 html
215}
216
217impl Engine {
218 pub fn render_html_export(
222 &self,
223 mem: &str,
224 export_date: &str,
225 ) -> Result<String, crate::engine::EngineError> {
226 let mounted = self
227 .mounts
228 .iter()
229 .find(|m| m.mount.mem == mem)
230 .ok_or_else(|| crate::engine::EngineError::UnknownMem(mem.to_string()))?;
231 let third_party = mounted.mount.capability == MountCapability::ReadOnly;
232 let config = self.mem_config_for(mem);
233 let schema = self.schemas.get(mem);
237 let schema_ref = self
238 .schemas
239 .get(mem)
240 .map(|s| {
241 let (n, v) = s.id();
242 format!("{n}@{v}")
243 })
244 .unwrap_or_else(|| "(unresolved)".to_string());
245
246 let mut entities: Vec<&Entity> = self
249 .store
250 .all_entities()
251 .filter(|e| e.mem == mem && !e.stub)
252 .collect();
253 entities.sort_by(|a, b| {
254 a.entity_type
255 .cmp(&b.entity_type)
256 .then_with(|| a.id.as_ref().cmp(b.id.as_ref()))
257 });
258 let mut stubs: Vec<&Entity> = self
259 .store
260 .all_entities()
261 .filter(|e| e.mem == mem && e.stub)
262 .collect();
263 stubs.sort_by(|a, b| a.id.as_ref().cmp(b.id.as_ref()));
264 let exported_ids: Vec<String> = entities.iter().map(|e| e.id.to_string()).collect();
265
266 let mut out = String::new();
267 out.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n");
268 out.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
269 let doc_title = config
270 .and_then(|c| c.title.clone())
271 .unwrap_or_else(|| mem.to_string());
272 let _ = writeln!(out, "<title>{}</title>", esc(&doc_title));
273 out.push_str(
274 "<style>\n\
275 body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;\
276 max-width:52rem;margin:0 auto;padding:2rem 1rem;line-height:1.55;color:#1a1a1a;}\n\
277 h1{border-bottom:2px solid #ddd;padding-bottom:.3rem;}\n\
278 section.entity{border-top:1px solid #ddd;margin-top:2rem;padding-top:1rem;}\n\
279 table.meta{border-collapse:collapse;font-size:.9rem;margin:.5rem 0;}\n\
280 table.meta td{border:1px solid #ddd;padding:.15rem .5rem;}\n\
281 table.meta td:first-child{color:#555;}\n\
282 nav ul{columns:2;list-style:none;padding-left:0;}\n\
283 nav li{margin:.15rem 0;}\n\
284 .identity{background:#f6f6f6;border:1px solid #ddd;padding:.75rem 1rem;\
285 border-radius:4px;font-size:.95rem;}\n\
286 .badge{display:inline-block;background:#eee;border-radius:3px;\
287 padding:0 .4rem;font-size:.8rem;color:#555;}\n\
288 .stub{color:#888;font-style:italic;}\n\
289 .reltable{font-size:.9rem;}\n\
290 @media print{nav ul{columns:1;}}\n\
291 </style>\n</head>\n<body>\n",
292 );
293
294 let _ = write!(
296 out,
297 "<h1>{}</h1>\n<div class=\"identity\">\n",
298 esc(&doc_title)
299 );
300 let _ = writeln!(out, "<div><strong>Mem:</strong> {}</div>", esc(mem));
301 if let Some(desc) = config.and_then(|c| c.description.as_deref())
302 && !desc.is_empty()
303 {
304 let _ = writeln!(
305 out,
306 "<div><strong>Description:</strong> {}</div>",
307 esc(desc)
308 );
309 }
310 if let Some(subject) = config.and_then(|c| c.subject.as_ref()) {
311 let _ = writeln!(
312 out,
313 "<div><strong>Subject:</strong> {}</div>",
314 esc(&subject.scope)
315 );
316 }
317 let _ = writeln!(
318 out,
319 "<div><strong>Schema:</strong> {}</div>",
320 esc(&schema_ref)
321 );
322 let trust = if third_party {
323 "third-party (read-only mount — someone else's published content, quoted here)"
324 } else {
325 "first-party (writable mem of this workspace)"
326 };
327 let _ = writeln!(out, "<div><strong>Origin:</strong> {trust}</div>");
328 let _ = write!(
329 out,
330 "<div><strong>Exported:</strong> {} · {} entities</div>\n</div>\n",
331 esc(export_date),
332 entities.len()
333 );
334
335 let mut by_type: BTreeMap<&str, Vec<&Entity>> = BTreeMap::new();
337 for e in &entities {
338 by_type.entry(e.entity_type.as_str()).or_default().push(e);
339 }
340 out.push_str("<nav>\n<h2>Index</h2>\n");
341 for (ty, list) in &by_type {
342 let _ = write!(out, "<h3>{} ({})</h3>\n<ul>\n", esc(ty), list.len());
343 for e in list {
344 let _ = writeln!(
345 out,
346 "<li><a href=\"#{}\">{}</a></li>",
347 esc(e.id.as_ref()),
348 esc(&e.title)
349 );
350 }
351 out.push_str("</ul>\n");
352 }
353 out.push_str("</nav>\n");
354
355 for e in &entities {
357 let _ = write!(
358 out,
359 "<section class=\"entity\" id=\"{}\">\n<h2>{}</h2>\n<span class=\"badge\">{}</span> <span class=\"badge\">{}</span>\n",
360 esc(e.id.as_ref()),
361 esc(&e.title),
362 esc(&e.entity_type),
363 esc(e.id.as_ref()),
364 );
365 if !e.metadata.is_empty() {
366 out.push_str("<table class=\"meta\">\n");
367 for (k, v) in &e.metadata {
368 let _ = writeln!(
369 out,
370 "<tr><td>{}</td><td>{}</td></tr>",
371 esc(k),
372 esc(&v.to_frontmatter_string())
373 );
374 }
375 out.push_str("</table>\n");
376 }
377 for (key, body) in &e.sections {
378 if body.trim().is_empty() {
379 continue;
380 }
381 let heading = schema
394 .and_then(|s| s.get_type(&e.entity_type))
395 .and_then(|t| {
396 t.sections
397 .iter()
398 .find(|s| &s.key == key)
399 .map(|s| s.heading.clone())
400 })
401 .unwrap_or_else(|| key.clone());
402 let _ = writeln!(out, "<h3>{}</h3>", esc(&heading));
403 let resolved = resolve_wiki_links(body, mem, &exported_ids);
404 out.push_str(&markdown_to_safe_html(&resolved, &exported_ids));
405 }
406 if !e.relationships.is_empty() {
407 out.push_str("<h3>Relationships</h3>\n<ul class=\"reltable\">\n");
413 let mut rels = e.relationships.clone();
414 rels.sort_by(|a, b| {
415 a.rel_type
416 .cmp(&b.rel_type)
417 .then_with(|| a.target.as_ref().cmp(b.target.as_ref()))
418 });
419 for r in &rels {
420 let target_id = r.target.to_string();
421 let in_doc = exported_ids.iter().any(|id| id == &target_id);
422 let is_stub_target = self.store.get(&r.target).map(|t| t.stub).unwrap_or(false);
423 if in_doc {
424 let _ = writeln!(
425 out,
426 "<li>{} → <a href=\"#{}\">{}</a></li>",
427 esc(&r.rel_type),
428 esc(&target_id),
429 esc(&target_id)
430 );
431 } else if is_stub_target {
432 let _ = writeln!(
433 out,
434 "<li>{} → <span class=\"stub\">{} (stub — unresolved reference)</span></li>",
435 esc(&r.rel_type),
436 esc(&target_id)
437 );
438 } else {
439 let _ = writeln!(
440 out,
441 "<li>{} → {} <span class=\"badge\">other mem</span></li>",
442 esc(&r.rel_type),
443 esc(&target_id)
444 );
445 }
446 }
447 out.push_str("</ul>\n");
448 }
449 out.push_str("</section>\n");
450 }
451
452 if !stubs.is_empty() {
453 out.push_str(
454 "<section class=\"entity\">\n<h2>Unresolved references (stubs)</h2>\n<ul>\n",
455 );
456 for s in &stubs {
457 let _ = writeln!(
458 out,
459 "<li class=\"stub\">{} — referenced but never written</li>",
460 esc(s.id.as_ref())
461 );
462 }
463 out.push_str("</ul>\n</section>\n");
464 }
465
466 out.push_str("</body>\n</html>\n");
467 Ok(out)
468 }
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474 use tempfile::TempDir;
475
476 use crate::backend::MemBackend;
477 use crate::engine::test_helpers::{cli_actor, folder_mount};
478 use crate::storage::FilesystemMemWriter;
479 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
480
481 #[test]
487 fn wiki_link_resolution_leaves_code_verbatim() {
488 let exported = vec!["m--real".to_string()];
489 for body in [
490 "```\n[[m--real]]\n```",
491 "~~~\n[[m--real]]\n~~~",
492 " [[m--real]]",
493 "> ```\n> [[m--real]]\n> ```",
494 "An inline `[[m--real]]` sample.",
495 "A double ``[[m--real]]`` sample.",
496 ] {
497 assert_eq!(
498 resolve_wiki_links(body, "m", &exported),
499 body,
500 "code content must survive byte-identical: {body:?}"
501 );
502 }
503 }
504
505 #[test]
508 fn wiki_link_resolution_does_not_mark_code_as_unresolved() {
509 let body = "```\n[[m--ghost]]\n```\n\n [[m--other-ghost]]\n";
510 assert_eq!(resolve_wiki_links(body, "m", &[]), body);
511 }
512
513 #[test]
517 fn wiki_link_resolution_still_rewrites_prose() {
518 let exported = vec!["m--real".to_string()];
519 let body =
520 "See [[m--real]].\n\n```\n[[m--real]]\n```\n\nAnd [[m--ghost]] and [[other--thing]].\n";
521 let out = resolve_wiki_links(body, "m", &exported);
522 assert!(out.contains("[m--real](#m--real)"), "{out}");
523 assert!(out.contains("m--ghost *(unresolved)*"), "{out}");
524 assert!(out.contains("other--thing *(other mem)*"), "{out}");
525 assert!(
526 out.contains("```\n[[m--real]]\n```"),
527 "code untouched: {out}"
528 );
529 }
530
531 #[test]
534 fn wiki_link_resolution_passes_through_an_unterminated_open() {
535 let body = "text [[not-closed and more text after\n";
536 assert_eq!(resolve_wiki_links(body, "m", &[]), body);
537 }
538
539 fn fixture_engine(tmp: &TempDir) -> Engine {
544 let mem_dir = tmp.path().to_path_buf();
545 std::fs::write(
546 mem_dir.join("bösenberg-söhne-rev-21.md"),
547 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Bösenberg & Söhne — Rev. 2.1\n\n## Identity\n\nCited in [[target-entity]] and [[über-ziele]] and cross-mem [[other--far-away]].\n\n<script>alert('x')</script>\n\n\n\nSee [docs](https://example.org/page), [broken](#bogus-frag), [evil](javascript:alert(2)).\n\n## Purpose\n\nZweck mit Umlauten: äöüß.\n",
548 )
549 .unwrap();
550 std::fs::write(
551 mem_dir.join("über-ziele.md"),
552 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Über-Ziele\n\n## Identity\n\nUmlaut-slug anchor target.\n\n## Purpose\n\nP.\n",
553 )
554 .unwrap();
555 std::fs::write(
556 mem_dir.join("target-entity.md"),
557 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Target Entity\n\n## Identity\n\nThe link target.\n\n## Purpose\n\nAnchors resolve here.\n",
558 )
559 .unwrap();
560 let writer = FilesystemMemWriter::new(mem_dir.clone());
561 Engine::from_mounts(vec![(
562 folder_mount("specs", mem_dir),
563 Box::new(writer) as Box<dyn MemBackend>,
564 )])
565 .unwrap()
566 }
567
568 fn assert_no_dangling_anchors(html: &str) {
571 let mut ids: Vec<&str> = Vec::new();
572 for part in html.split("id=\"").skip(1) {
573 if let Some(end) = part.find('"') {
574 ids.push(&part[..end]);
575 }
576 }
577 for part in html.split("href=\"#").skip(1) {
578 if let Some(end) = part.find('"') {
579 let anchor = percent_decode(&part[..end]);
580 assert!(
581 ids.iter().any(|id| *id == anchor),
582 "dangling in-document anchor #{anchor}"
583 );
584 }
585 }
586 }
587
588 #[test]
603 fn html_export_renders_the_declared_heading_not_the_section_key() {
604 let tmp = TempDir::new().unwrap();
605 let mem_dir = tmp.path().to_path_buf();
606 std::fs::write(
607 mem_dir.join("open-question.md"),
608 "---\ntype: inquiry\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n\
609 status: open\nurgency: medium\n---\n# Open Question\n\n## Question\n\nQ?\n\n\
610 ## Significance\n\nS.\n\n## Current State\n\nWhere things stand.\n",
611 )
612 .unwrap();
613 let writer = FilesystemMemWriter::new(mem_dir.clone());
614 let engine = Engine::from_mounts(vec![(
615 folder_mount("specs", mem_dir),
616 Box::new(writer) as Box<dyn MemBackend>,
617 )])
618 .unwrap();
619
620 let html = engine.render_html_export("specs", "2026-08-15").unwrap();
621
622 assert!(
623 html.contains("<h3>Current State</h3>"),
624 "must render the declared heading; got:\n{html}"
625 );
626 assert!(
627 !html.contains("<h3>current_state</h3>"),
628 "must not render the storage key as a heading; got:\n{html}"
629 );
630 assert!(html.contains("<h3>Question</h3>"), "got:\n{html}");
632 assert!(html.contains("<h3>Significance</h3>"), "got:\n{html}");
633 assert_no_dangling_anchors(&html);
634
635 let tmp2 = TempDir::new().unwrap();
640 let goal_dir = tmp2.path().to_path_buf();
641 std::fs::write(
642 goal_dir.join("second-goal.md"),
643 "---\ntype: goal\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n\
644 priority: high\nstatus: active\n---\n# Second Goal\n\n## Statement\n\nS.\n\n\
645 ## Rationale\n\nR.\n\n## Success Criteria\n\nC.\n\n## Out of Scope\n\n\
646 Everything else.\n",
647 )
648 .unwrap();
649 let goal_writer = FilesystemMemWriter::new(goal_dir.clone());
650 let goal_mount = Mount {
651 mem: "plans".to_string(),
652 schema: Some(memstead_schema::SchemaRef::new(
653 "planning",
654 semver::Version::new(0, 4, 0),
655 )),
656 storage: MountStorage::Folder { path: goal_dir },
657 capability: MountCapability::Write,
658 lifecycle: MountLifecycle::Eager,
659 cross_linkable: true,
660 migration_target: None,
661 };
662 let goal_engine = Engine::from_mounts(vec![(
663 goal_mount,
664 Box::new(goal_writer) as Box<dyn MemBackend>,
665 )])
666 .unwrap();
667 let goal_html = goal_engine
668 .render_html_export("plans", "2026-08-15")
669 .unwrap();
670 assert!(
671 goal_html.contains("<h3>Out of Scope</h3>"),
672 "the interior word must stay lowercase, as declared; got:\n{goal_html}"
673 );
674 assert!(
675 !goal_html.contains("<h3>Out Of Scope</h3>")
676 && !goal_html.contains("<h3>out_of_scope</h3>"),
677 "neither a title-cased guess nor the storage key; got:\n{goal_html}"
678 );
679
680 let again = engine.render_html_export("specs", "2026-08-15").unwrap();
682 assert_eq!(html, again, "export must be byte-deterministic");
683 }
684
685 #[test]
688 fn html_export_renders_sanitises_and_stays_self_contained() {
689 let tmp = TempDir::new().unwrap();
690 let mut engine = fixture_engine(&tmp);
691
692 let html = engine.render_html_export("specs", "2026-08-10").unwrap();
693
694 assert!(
696 html.contains("<strong>Mem:</strong> specs"),
697 "identity block"
698 );
699 assert!(html.contains("<strong>Exported:</strong> 2026-08-10"));
700 assert!(html.contains("<nav>"), "type-grouped index");
701 assert!(
702 html.contains("Bösenberg & Söhne — Rev. 2.1"),
703 "widened title escaped: {html}"
704 );
705 assert!(html.contains("äöüß"), "umlauts verbatim");
706
707 assert!(!html.contains("<script>"), "raw HTML must not pass through");
709 assert!(html.contains("<script>"), "escaped as visible text");
710
711 assert!(!html.contains("<img"), "no image element: {html}");
713 assert!(
714 html.contains("<a href=\"https://evil.example/x.png\">image: diagram (https://evil.example/x.png)</a>"),
715 "image degraded to labelled link: {html}"
716 );
717 assert!(html.contains("<a href=\"https://example.org/page\">docs</a>"));
718
719 assert!(
721 html.contains("href=\"#specs--target-entity\""),
722 "in-doc anchor"
723 );
724 assert!(html.contains("other--far-away"), "cross-mem labelled");
725 assert!(
726 !html.contains("href=\"#other--far-away\""),
727 "cross-mem never an anchor"
728 );
729
730 assert!(html.contains("ber-ziele"), "umlaut target linked: {html}");
734
735 assert!(
738 !html.contains("href=\"#bogus-frag\""),
739 "dangling fragment neutralised"
740 );
741 assert!(html.contains("(#bogus-frag — link removed)"), "{html}");
742
743 assert!(
746 !html.contains("href=\"javascript:"),
747 "javascript scheme stripped: {html}"
748 );
749 assert!(
750 html.contains("link removed"),
751 "neutralised destination surfaced"
752 );
753 assert_no_dangling_anchors(&html);
754
755 for fetching in [
762 "<img",
763 "<video",
764 "<audio",
765 "<iframe",
766 "<link ",
767 "<script src",
768 "@import",
769 "url(",
770 ] {
771 assert!(
772 !html.contains(fetching),
773 "self-containment violated by {fetching}"
774 );
775 }
776
777 let again = engine.render_html_export("specs", "2026-08-10").unwrap();
779 assert_eq!(html, again, "byte-deterministic");
780
781 let untouched_block = {
784 let start = html.find("id=\"specs--bösenberg-söhne-rev-21\"").unwrap();
785 let end = html[start..].find("</section>").unwrap() + start;
786 html[start..end].to_string()
787 };
788 let (actor, client) = cli_actor();
789 let mut edit = crate::engine::UpdateEntityArgs {
790 anchors: Vec::new(),
791 id: crate::entity::EntityId::new("specs", "target-entity"),
792 expected_hash: None,
793 sections: indexmap::IndexMap::from_iter([(
794 "purpose".to_string(),
795 "Geändert.".to_string(),
796 )]),
797 append_sections: indexmap::IndexMap::new(),
798 patch_sections: indexmap::IndexMap::new(),
799 metadata: indexmap::IndexMap::new(),
800 metadata_unset: Vec::new(),
801 declare_relations: Vec::new(),
802 dry_run: false,
803 relations_unset: Vec::new(),
804 anchors_unset: Vec::new(),
805 };
806 let _ = &mut edit;
807 engine
808 .update_entity(edit, actor, Some(&client), None)
809 .expect("edit lands");
810 let after = engine.render_html_export("specs", "2026-08-10").unwrap();
811 assert_ne!(html, after, "edit changes the export");
812 assert!(
813 after.contains(&untouched_block),
814 "untouched entity's region byte-identical after the edit"
815 );
816 assert!(after.contains("Geändert."), "edited content present");
817 }
818
819 #[test]
823 fn read_only_origin_stated_and_unknown_mem_refuses() {
824 let tmp = TempDir::new().unwrap();
825 let mem_dir = tmp.path().to_path_buf();
826 std::fs::write(
827 mem_dir.join("note.md"),
828 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Foreign Note\n\n## Identity\n\nI.\n\n## Purpose\n\nP.\n",
829 )
830 .unwrap();
831 let writer = FilesystemMemWriter::new(mem_dir.clone());
832 let mount = Mount {
833 mem: "foreign".to_string(),
834 schema: Some("default@1.0.0".parse().unwrap()),
835 storage: MountStorage::Folder { path: mem_dir },
836 capability: MountCapability::ReadOnly,
837 lifecycle: MountLifecycle::Eager,
838 cross_linkable: true,
839 migration_target: None,
840 };
841 let engine =
842 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
843 let html = engine.render_html_export("foreign", "2026-08-10").unwrap();
844 assert!(
845 html.contains("third-party (read-only mount"),
846 "trust class stated: {html}"
847 );
848
849 let err = engine.render_html_export("nope", "2026-08-10").unwrap_err();
850 assert_eq!(err.code(), "UNKNOWN_MEM");
851 }
852}