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 self.render_html_export_scoped(mem, export_date, None)
227 }
228
229 pub fn render_html_export_scoped(
235 &self,
236 mem: &str,
237 export_date: &str,
238 chain: Option<&crate::graph::chain::ChainSet>,
239 ) -> Result<String, crate::engine::EngineError> {
240 let in_scope = |e: &Entity| chain.is_none_or(|c| c.contains(&e.id));
241 let mounted = self
242 .mounts
243 .iter()
244 .find(|m| m.mount.mem == mem)
245 .ok_or_else(|| crate::engine::EngineError::UnknownMem(mem.to_string()))?;
246 let third_party = mounted.mount.capability == MountCapability::ReadOnly;
247 let config = self.mem_config_for(mem);
248 let schema = self.schemas.get(mem);
252 let schema_ref = self
253 .schemas
254 .get(mem)
255 .map(|s| {
256 let (n, v) = s.id();
257 format!("{n}@{v}")
258 })
259 .unwrap_or_else(|| "(unresolved)".to_string());
260
261 let mut entities: Vec<&Entity> = self
264 .store
265 .all_entities()
266 .filter(|e| e.mem == mem && !e.stub && in_scope(e))
267 .collect();
268 entities.sort_by(|a, b| {
269 a.entity_type
270 .cmp(&b.entity_type)
271 .then_with(|| a.id.as_ref().cmp(b.id.as_ref()))
272 });
273 let mut stubs: Vec<&Entity> = self
274 .store
275 .all_entities()
276 .filter(|e| e.mem == mem && e.stub && in_scope(e))
277 .collect();
278 stubs.sort_by(|a, b| a.id.as_ref().cmp(b.id.as_ref()));
279 let exported_ids: Vec<String> = entities.iter().map(|e| e.id.to_string()).collect();
280
281 let mut out = String::new();
282 out.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n");
283 out.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
284 let doc_title = config
285 .and_then(|c| c.title.clone())
286 .unwrap_or_else(|| mem.to_string());
287 let _ = writeln!(out, "<title>{}</title>", esc(&doc_title));
288 out.push_str(
289 "<style>\n\
290 body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;\
291 max-width:52rem;margin:0 auto;padding:2rem 1rem;line-height:1.55;color:#1a1a1a;}\n\
292 h1{border-bottom:2px solid #ddd;padding-bottom:.3rem;}\n\
293 section.entity{border-top:1px solid #ddd;margin-top:2rem;padding-top:1rem;}\n\
294 table.meta{border-collapse:collapse;font-size:.9rem;margin:.5rem 0;}\n\
295 table.meta td{border:1px solid #ddd;padding:.15rem .5rem;}\n\
296 table.meta td:first-child{color:#555;}\n\
297 nav ul{columns:2;list-style:none;padding-left:0;}\n\
298 nav li{margin:.15rem 0;}\n\
299 .identity{background:#f6f6f6;border:1px solid #ddd;padding:.75rem 1rem;\
300 border-radius:4px;font-size:.95rem;}\n\
301 .badge{display:inline-block;background:#eee;border-radius:3px;\
302 padding:0 .4rem;font-size:.8rem;color:#555;}\n\
303 .stub{color:#888;font-style:italic;}\n\
304 .reltable{font-size:.9rem;}\n\
305 @media print{nav ul{columns:1;}}\n\
306 </style>\n</head>\n<body>\n",
307 );
308
309 let _ = write!(
311 out,
312 "<h1>{}</h1>\n<div class=\"identity\">\n",
313 esc(&doc_title)
314 );
315 let _ = writeln!(out, "<div><strong>Mem:</strong> {}</div>", esc(mem));
316 if let Some(desc) = config.and_then(|c| c.description.as_deref())
317 && !desc.is_empty()
318 {
319 let _ = writeln!(
320 out,
321 "<div><strong>Description:</strong> {}</div>",
322 esc(desc)
323 );
324 }
325 if let Some(subject) = config.and_then(|c| c.subject.as_ref()) {
326 let _ = writeln!(
327 out,
328 "<div><strong>Subject:</strong> {}</div>",
329 esc(&subject.scope)
330 );
331 }
332 let _ = writeln!(
333 out,
334 "<div><strong>Schema:</strong> {}</div>",
335 esc(&schema_ref)
336 );
337 let trust = if third_party {
338 "third-party (read-only mount — someone else's published content, quoted here)"
339 } else {
340 "first-party (writable mem of this workspace)"
341 };
342 let _ = writeln!(out, "<div><strong>Origin:</strong> {trust}</div>");
343 if let Some(chain) = chain {
344 let _ = writeln!(
345 out,
346 "<div><strong>Chain:</strong> {} — only the entities reachable from the root are \
347 rendered; links to entities outside the chain are marked unresolved</div>",
348 esc(&chain.describe())
349 );
350 }
351 let _ = write!(
352 out,
353 "<div><strong>Exported:</strong> {} · {} entities</div>\n</div>\n",
354 esc(export_date),
355 entities.len()
356 );
357
358 let mut by_type: BTreeMap<&str, Vec<&Entity>> = BTreeMap::new();
360 for e in &entities {
361 by_type.entry(e.entity_type.as_str()).or_default().push(e);
362 }
363 out.push_str("<nav>\n<h2>Index</h2>\n");
364 for (ty, list) in &by_type {
365 let _ = write!(out, "<h3>{} ({})</h3>\n<ul>\n", esc(ty), list.len());
366 for e in list {
367 let _ = writeln!(
368 out,
369 "<li><a href=\"#{}\">{}</a></li>",
370 esc(e.id.as_ref()),
371 esc(&e.title)
372 );
373 }
374 out.push_str("</ul>\n");
375 }
376 out.push_str("</nav>\n");
377
378 for e in &entities {
380 let _ = write!(
381 out,
382 "<section class=\"entity\" id=\"{}\">\n<h2>{}</h2>\n<span class=\"badge\">{}</span> <span class=\"badge\">{}</span>\n",
383 esc(e.id.as_ref()),
384 esc(&e.title),
385 esc(&e.entity_type),
386 esc(e.id.as_ref()),
387 );
388 if !e.metadata.is_empty() {
389 out.push_str("<table class=\"meta\">\n");
390 for (k, v) in &e.metadata {
391 let _ = writeln!(
392 out,
393 "<tr><td>{}</td><td>{}</td></tr>",
394 esc(k),
395 esc(&v.to_frontmatter_string())
396 );
397 }
398 out.push_str("</table>\n");
399 }
400 for (key, body) in &e.sections {
401 if body.trim().is_empty() {
402 continue;
403 }
404 let heading = schema
417 .and_then(|s| s.get_type(&e.entity_type))
418 .and_then(|t| {
419 t.sections
420 .iter()
421 .find(|s| &s.key == key)
422 .map(|s| s.heading.clone())
423 })
424 .unwrap_or_else(|| key.clone());
425 let _ = writeln!(out, "<h3>{}</h3>", esc(&heading));
426 let resolved = resolve_wiki_links(body, mem, &exported_ids);
427 out.push_str(&markdown_to_safe_html(&resolved, &exported_ids));
428 }
429 if !e.relationships.is_empty() {
430 out.push_str("<h3>Relationships</h3>\n<ul class=\"reltable\">\n");
436 let mut rels = e.relationships.clone();
437 rels.sort_by(|a, b| {
438 a.rel_type
439 .cmp(&b.rel_type)
440 .then_with(|| a.target.as_ref().cmp(b.target.as_ref()))
441 });
442 for r in &rels {
443 let target_id = r.target.to_string();
444 let in_doc = exported_ids.iter().any(|id| id == &target_id);
445 let is_stub_target = self.store.get(&r.target).map(|t| t.stub).unwrap_or(false);
446 if in_doc {
447 let _ = writeln!(
448 out,
449 "<li>{} → <a href=\"#{}\">{}</a></li>",
450 esc(&r.rel_type),
451 esc(&target_id),
452 esc(&target_id)
453 );
454 } else if is_stub_target {
455 let _ = writeln!(
456 out,
457 "<li>{} → <span class=\"stub\">{} (stub — unresolved reference)</span></li>",
458 esc(&r.rel_type),
459 esc(&target_id)
460 );
461 } else {
462 let _ = writeln!(
463 out,
464 "<li>{} → {} <span class=\"badge\">other mem</span></li>",
465 esc(&r.rel_type),
466 esc(&target_id)
467 );
468 }
469 }
470 out.push_str("</ul>\n");
471 }
472 out.push_str("</section>\n");
473 }
474
475 if !stubs.is_empty() {
476 out.push_str(
477 "<section class=\"entity\">\n<h2>Unresolved references (stubs)</h2>\n<ul>\n",
478 );
479 for s in &stubs {
480 let _ = writeln!(
481 out,
482 "<li class=\"stub\">{} — referenced but never written</li>",
483 esc(s.id.as_ref())
484 );
485 }
486 out.push_str("</ul>\n</section>\n");
487 }
488
489 out.push_str("</body>\n</html>\n");
490 Ok(out)
491 }
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497 use tempfile::TempDir;
498
499 use crate::backend::MemBackend;
500 use crate::engine::test_helpers::{cli_actor, folder_mount};
501 use crate::storage::FilesystemMemWriter;
502 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
503
504 #[test]
510 fn wiki_link_resolution_leaves_code_verbatim() {
511 let exported = vec!["m--real".to_string()];
512 for body in [
513 "```\n[[m--real]]\n```",
514 "~~~\n[[m--real]]\n~~~",
515 " [[m--real]]",
516 "> ```\n> [[m--real]]\n> ```",
517 "An inline `[[m--real]]` sample.",
518 "A double ``[[m--real]]`` sample.",
519 ] {
520 assert_eq!(
521 resolve_wiki_links(body, "m", &exported),
522 body,
523 "code content must survive byte-identical: {body:?}"
524 );
525 }
526 }
527
528 #[test]
531 fn wiki_link_resolution_does_not_mark_code_as_unresolved() {
532 let body = "```\n[[m--ghost]]\n```\n\n [[m--other-ghost]]\n";
533 assert_eq!(resolve_wiki_links(body, "m", &[]), body);
534 }
535
536 #[test]
540 fn wiki_link_resolution_still_rewrites_prose() {
541 let exported = vec!["m--real".to_string()];
542 let body =
543 "See [[m--real]].\n\n```\n[[m--real]]\n```\n\nAnd [[m--ghost]] and [[other--thing]].\n";
544 let out = resolve_wiki_links(body, "m", &exported);
545 assert!(out.contains("[m--real](#m--real)"), "{out}");
546 assert!(out.contains("m--ghost *(unresolved)*"), "{out}");
547 assert!(out.contains("other--thing *(other mem)*"), "{out}");
548 assert!(
549 out.contains("```\n[[m--real]]\n```"),
550 "code untouched: {out}"
551 );
552 }
553
554 #[test]
557 fn wiki_link_resolution_passes_through_an_unterminated_open() {
558 let body = "text [[not-closed and more text after\n";
559 assert_eq!(resolve_wiki_links(body, "m", &[]), body);
560 }
561
562 fn fixture_engine(tmp: &TempDir) -> Engine {
567 let mem_dir = tmp.path().to_path_buf();
568 std::fs::write(
569 mem_dir.join("bösenberg-söhne-rev-21.md"),
570 "---\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",
571 )
572 .unwrap();
573 std::fs::write(
574 mem_dir.join("über-ziele.md"),
575 "---\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",
576 )
577 .unwrap();
578 std::fs::write(
579 mem_dir.join("target-entity.md"),
580 "---\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",
581 )
582 .unwrap();
583 let writer = FilesystemMemWriter::new(mem_dir.clone());
584 Engine::from_mounts(vec![(
585 folder_mount("specs", mem_dir),
586 Box::new(writer) as Box<dyn MemBackend>,
587 )])
588 .unwrap()
589 }
590
591 fn assert_no_dangling_anchors(html: &str) {
594 let mut ids: Vec<&str> = Vec::new();
595 for part in html.split("id=\"").skip(1) {
596 if let Some(end) = part.find('"') {
597 ids.push(&part[..end]);
598 }
599 }
600 for part in html.split("href=\"#").skip(1) {
601 if let Some(end) = part.find('"') {
602 let anchor = percent_decode(&part[..end]);
603 assert!(
604 ids.iter().any(|id| *id == anchor),
605 "dangling in-document anchor #{anchor}"
606 );
607 }
608 }
609 }
610
611 #[test]
626 fn html_export_renders_the_declared_heading_not_the_section_key() {
627 let tmp = TempDir::new().unwrap();
628 let mem_dir = tmp.path().to_path_buf();
629 std::fs::write(
630 mem_dir.join("open-question.md"),
631 "---\ntype: inquiry\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n\
632 status: open\nurgency: medium\n---\n# Open Question\n\n## Question\n\nQ?\n\n\
633 ## Significance\n\nS.\n\n## Current State\n\nWhere things stand.\n",
634 )
635 .unwrap();
636 let writer = FilesystemMemWriter::new(mem_dir.clone());
637 let engine = Engine::from_mounts(vec![(
638 folder_mount("specs", mem_dir),
639 Box::new(writer) as Box<dyn MemBackend>,
640 )])
641 .unwrap();
642
643 let html = engine.render_html_export("specs", "2026-08-15").unwrap();
644
645 assert!(
646 html.contains("<h3>Current State</h3>"),
647 "must render the declared heading; got:\n{html}"
648 );
649 assert!(
650 !html.contains("<h3>current_state</h3>"),
651 "must not render the storage key as a heading; got:\n{html}"
652 );
653 assert!(html.contains("<h3>Question</h3>"), "got:\n{html}");
655 assert!(html.contains("<h3>Significance</h3>"), "got:\n{html}");
656 assert_no_dangling_anchors(&html);
657
658 let tmp2 = TempDir::new().unwrap();
663 let goal_dir = tmp2.path().to_path_buf();
664 std::fs::write(
665 goal_dir.join("second-goal.md"),
666 "---\ntype: goal\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n\
667 priority: high\nstatus: active\n---\n# Second Goal\n\n## Statement\n\nS.\n\n\
668 ## Rationale\n\nR.\n\n## Success Criteria\n\nC.\n\n## Out of Scope\n\n\
669 Everything else.\n",
670 )
671 .unwrap();
672 let goal_writer = FilesystemMemWriter::new(goal_dir.clone());
673 let goal_mount = Mount {
674 mem: "plans".to_string(),
675 schema: Some(memstead_schema::SchemaRef::new(
676 "planning",
677 semver::Version::new(0, 4, 0),
678 )),
679 storage: MountStorage::Folder { path: goal_dir },
680 capability: MountCapability::Write,
681 lifecycle: MountLifecycle::Eager,
682 cross_linkable: true,
683 migration_target: None,
684 };
685 let goal_engine = Engine::from_mounts(vec![(
686 goal_mount,
687 Box::new(goal_writer) as Box<dyn MemBackend>,
688 )])
689 .unwrap();
690 let goal_html = goal_engine
691 .render_html_export("plans", "2026-08-15")
692 .unwrap();
693 assert!(
694 goal_html.contains("<h3>Out of Scope</h3>"),
695 "the interior word must stay lowercase, as declared; got:\n{goal_html}"
696 );
697 assert!(
698 !goal_html.contains("<h3>Out Of Scope</h3>")
699 && !goal_html.contains("<h3>out_of_scope</h3>"),
700 "neither a title-cased guess nor the storage key; got:\n{goal_html}"
701 );
702
703 let again = engine.render_html_export("specs", "2026-08-15").unwrap();
705 assert_eq!(html, again, "export must be byte-deterministic");
706 }
707
708 #[test]
711 fn html_export_renders_sanitises_and_stays_self_contained() {
712 let tmp = TempDir::new().unwrap();
713 let mut engine = fixture_engine(&tmp);
714
715 let html = engine.render_html_export("specs", "2026-08-10").unwrap();
716
717 assert!(
719 html.contains("<strong>Mem:</strong> specs"),
720 "identity block"
721 );
722 assert!(html.contains("<strong>Exported:</strong> 2026-08-10"));
723 assert!(html.contains("<nav>"), "type-grouped index");
724 assert!(
725 html.contains("Bösenberg & Söhne — Rev. 2.1"),
726 "widened title escaped: {html}"
727 );
728 assert!(html.contains("äöüß"), "umlauts verbatim");
729
730 assert!(!html.contains("<script>"), "raw HTML must not pass through");
732 assert!(html.contains("<script>"), "escaped as visible text");
733
734 assert!(!html.contains("<img"), "no image element: {html}");
736 assert!(
737 html.contains("<a href=\"https://evil.example/x.png\">image: diagram (https://evil.example/x.png)</a>"),
738 "image degraded to labelled link: {html}"
739 );
740 assert!(html.contains("<a href=\"https://example.org/page\">docs</a>"));
741
742 assert!(
744 html.contains("href=\"#specs--target-entity\""),
745 "in-doc anchor"
746 );
747 assert!(html.contains("other--far-away"), "cross-mem labelled");
748 assert!(
749 !html.contains("href=\"#other--far-away\""),
750 "cross-mem never an anchor"
751 );
752
753 assert!(html.contains("ber-ziele"), "umlaut target linked: {html}");
757
758 assert!(
761 !html.contains("href=\"#bogus-frag\""),
762 "dangling fragment neutralised"
763 );
764 assert!(html.contains("(#bogus-frag — link removed)"), "{html}");
765
766 assert!(
769 !html.contains("href=\"javascript:"),
770 "javascript scheme stripped: {html}"
771 );
772 assert!(
773 html.contains("link removed"),
774 "neutralised destination surfaced"
775 );
776 assert_no_dangling_anchors(&html);
777
778 for fetching in [
785 "<img",
786 "<video",
787 "<audio",
788 "<iframe",
789 "<link ",
790 "<script src",
791 "@import",
792 "url(",
793 ] {
794 assert!(
795 !html.contains(fetching),
796 "self-containment violated by {fetching}"
797 );
798 }
799
800 let again = engine.render_html_export("specs", "2026-08-10").unwrap();
802 assert_eq!(html, again, "byte-deterministic");
803
804 let untouched_block = {
807 let start = html.find("id=\"specs--bösenberg-söhne-rev-21\"").unwrap();
808 let end = html[start..].find("</section>").unwrap() + start;
809 html[start..end].to_string()
810 };
811 let (actor, client) = cli_actor();
812 let mut edit = crate::engine::UpdateEntityArgs {
813 anchors: Vec::new(),
814 id: crate::entity::EntityId::new("specs", "target-entity"),
815 expected_hash: None,
816 sections: indexmap::IndexMap::from_iter([(
817 "purpose".to_string(),
818 "Geändert.".to_string(),
819 )]),
820 append_sections: indexmap::IndexMap::new(),
821 patch_sections: indexmap::IndexMap::new(),
822 sections_unset: Vec::new(),
823 metadata: indexmap::IndexMap::new(),
824 metadata_unset: Vec::new(),
825 declare_relations: Vec::new(),
826 dry_run: false,
827 relations_unset: Vec::new(),
828 anchors_unset: Vec::new(),
829 };
830 let _ = &mut edit;
831 engine
832 .update_entity(edit, actor, Some(&client), None)
833 .expect("edit lands");
834 let after = engine.render_html_export("specs", "2026-08-10").unwrap();
835 assert_ne!(html, after, "edit changes the export");
836 assert!(
837 after.contains(&untouched_block),
838 "untouched entity's region byte-identical after the edit"
839 );
840 assert!(after.contains("Geändert."), "edited content present");
841 }
842
843 #[test]
847 fn read_only_origin_stated_and_unknown_mem_refuses() {
848 let tmp = TempDir::new().unwrap();
849 let mem_dir = tmp.path().to_path_buf();
850 std::fs::write(
851 mem_dir.join("note.md"),
852 "---\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",
853 )
854 .unwrap();
855 let writer = FilesystemMemWriter::new(mem_dir.clone());
856 let mount = Mount {
857 mem: "foreign".to_string(),
858 schema: Some("default@1.0.0".parse().unwrap()),
859 storage: MountStorage::Folder { path: mem_dir },
860 capability: MountCapability::ReadOnly,
861 lifecycle: MountLifecycle::Eager,
862 cross_linkable: true,
863 migration_target: None,
864 };
865 let engine =
866 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
867 let html = engine.render_html_export("foreign", "2026-08-10").unwrap();
868 assert!(
869 html.contains("third-party (read-only mount"),
870 "trust class stated: {html}"
871 );
872
873 let err = engine.render_html_export("nope", "2026-08-10").unwrap_err();
874 assert_eq!(err.code(), "UNKNOWN_MEM");
875 }
876}