1use serde_json::{Value, json};
11
12use crate::error::MifRhError;
13use crate::harness_markdown::{dedupe_sections, secblock, source_link_line};
14
15pub struct RenderInputs<'a> {
23 pub artifact: &'a Value,
27 pub slug: &'a str,
29 pub slugpath: &'a str,
32 pub created: &'a str,
34 pub version: u64,
36 pub verification: Option<&'a Value>,
40}
41
42pub fn render_artifact(inputs: &RenderInputs<'_>, channel: &str) -> Result<String, MifRhError> {
51 match channel {
52 "report" => render_report(inputs),
53 "blog" => Ok(render_blog(inputs)),
54 "book" => Ok(render_book(inputs)),
55 other => Err(MifRhError::InvalidToggleValue {
56 field: "channel".to_string(),
57 value: other.to_string(),
58 allowed: "report|blog|book".to_string(),
59 }),
60 }
61}
62
63fn namespace_of(artifact: &Value) -> &str {
64 artifact
65 .get("namespace")
66 .and_then(Value::as_str)
67 .unwrap_or("harness/report")
68}
69
70fn sections_of(artifact: &Value) -> Vec<Value> {
71 let raw = artifact
72 .get("sections")
73 .and_then(Value::as_array)
74 .cloned()
75 .unwrap_or_default();
76 dedupe_sections(&raw)
77}
78
79fn sources_of(artifact: &Value) -> Vec<Value> {
80 artifact
81 .get("sources")
82 .and_then(Value::as_array)
83 .cloned()
84 .unwrap_or_default()
85}
86
87fn sources_list_block(artifact: &Value) -> Vec<String> {
88 let mut lines = vec![String::new(), "## Sources".to_string(), String::new()];
89 for source in &sources_of(artifact) {
90 lines.push(source_link_line(source));
91 }
92 lines
93}
94
95fn blockquote_lines(text: &str) -> Vec<String> {
98 text.trim()
99 .lines()
100 .map(|line| format!("> {line}"))
101 .collect()
102}
103
104fn render_report(inputs: &RenderInputs<'_>) -> Result<String, MifRhError> {
105 let artifact = inputs.artifact;
106 let namespace = namespace_of(artifact);
107 let genre = artifact
108 .get("genre")
109 .and_then(Value::as_str)
110 .unwrap_or("general");
111 let finding_count = artifact
112 .get("finding_refs")
113 .and_then(Value::as_array)
114 .map_or(0, Vec::len);
115
116 let mut body_lines = vec![format!(
117 "This {genre} synthesis covers {finding_count} surviving finding(s) across the research."
118 )];
119 for section in §ions_of(artifact) {
120 body_lines.extend(secblock(section, true, true));
121 }
122 body_lines.extend(sources_list_block(artifact));
123 let body = body_lines.join("\n");
124
125 let citations: Vec<Value> = sources_of(artifact)
126 .iter()
127 .map(|source| {
128 let mut citation = json!({
129 "@type": "Citation",
130 "citationType": source.get("citationType"),
131 "citationRole": source.get("citationRole"),
132 "title": source.get("title"),
133 "url": source.get("url"),
134 });
135 if let Some(note) = source.get("note") {
136 citation["note"] = note.clone();
137 }
138 citation
139 })
140 .collect();
141
142 let mut concept = json!({
143 "@context": "https://mif-spec.dev/schema/context.jsonld",
144 "@type": "Concept",
145 "@id": format!("urn:mif:report:{namespace}:{}", inputs.slug),
146 "slug": inputs.slugpath,
147 "version": inputs.version,
148 "conceptType": "semantic",
149 "namespace": namespace,
150 "title": artifact.get("title"),
151 "genre": genre,
152 "created": inputs.created,
153 "provenance": {
154 "@type": "Provenance",
155 "sourceType": "system_generated",
156 "confidence": 0.9,
157 "trustLevel": "moderate_confidence",
158 },
159 "citations": citations,
160 "extensions": { "harness": { "dimension": "synthesis" } },
161 });
162 if let Some(verification) = inputs.verification {
163 concept["extensions"]["harness"]["verification"] = verification.clone();
164 }
165 if let Some(subtitle) = artifact.get("subtitle").and_then(Value::as_str) {
166 concept["description"] = json!(subtitle);
167 }
168
169 let frontmatter_yaml = serde_norway::to_string(&concept)
173 .map_err(|source| MifRhError::FrontmatterYamlSerialize { source })?;
174 Ok(format!("---\n{frontmatter_yaml}---\n\n{body}\n"))
175}
176
177fn render_blog(inputs: &RenderInputs<'_>) -> String {
178 let artifact = inputs.artifact;
179 let namespace = namespace_of(artifact);
180 let title = artifact
181 .get("title")
182 .and_then(Value::as_str)
183 .unwrap_or_default();
184 let subtitle = artifact.get("subtitle").and_then(Value::as_str);
185
186 let mut lines = vec![
187 "---".to_string(),
188 "\"@context\": https://mif-spec.dev/schema/context.jsonld".to_string(),
189 "\"@type\": Concept".to_string(),
190 format!("\"@id\": urn:mif:blog:{namespace}:{}", inputs.slug),
191 format!("slug: {}", inputs.slugpath),
192 format!("version: {}", inputs.version),
193 "conceptType: semantic".to_string(),
194 format!("created: \"{}\"", inputs.created),
195 format!("namespace: {namespace}"),
196 "---".to_string(),
197 String::new(),
198 format!("# {title}"),
199 ];
200 if let Some(subtitle) = subtitle {
201 lines.push(String::new());
202 lines.extend(blockquote_lines(subtitle));
203 }
204 for section in §ions_of(artifact) {
205 lines.extend(secblock(section, false, true));
206 }
207 lines.extend(sources_list_block(artifact));
208 format!("{}\n", lines.join("\n"))
209}
210
211fn render_book(inputs: &RenderInputs<'_>) -> String {
212 let artifact = inputs.artifact;
213 let namespace = namespace_of(artifact);
214 let title = artifact
215 .get("title")
216 .and_then(Value::as_str)
217 .unwrap_or_default();
218 let genre = artifact
219 .get("genre")
220 .and_then(Value::as_str)
221 .unwrap_or("general");
222 let audience = artifact
223 .get("audience")
224 .and_then(Value::as_str)
225 .unwrap_or("general");
226 let subtitle = artifact.get("subtitle").and_then(Value::as_str);
227
228 let mut lines = vec![
229 "---".to_string(),
230 "\"@context\": https://mif-spec.dev/schema/context.jsonld".to_string(),
231 "\"@type\": Concept".to_string(),
232 format!("\"@id\": urn:mif:book:{namespace}:{}", inputs.slug),
233 format!("slug: {}", inputs.slugpath),
234 format!("version: {}", inputs.version),
235 "conceptType: semantic".to_string(),
236 format!("created: \"{}\"", inputs.created),
237 format!("namespace: {namespace}"),
238 "---".to_string(),
239 String::new(),
240 format!("# Chapter: {title}"),
241 ];
242 if let Some(subtitle) = subtitle {
243 lines.push(String::new());
244 lines.extend(blockquote_lines(subtitle));
245 }
246 lines.push(String::new());
247 lines.push(format!("> Genre: {genre} · audience: {audience}"));
248 for section in §ions_of(artifact) {
249 lines.extend(secblock(section, false, false));
250 }
251 lines.push(String::new());
252 lines.push("## Endnotes".to_string());
253 lines.push(String::new());
254 for (i, source) in sources_of(artifact).iter().enumerate() {
255 let title = source
256 .get("title")
257 .and_then(Value::as_str)
258 .unwrap_or_default();
259 let title = title.trim_matches([' ', '\t']);
260 let url = source
261 .get("url")
262 .and_then(Value::as_str)
263 .unwrap_or_default();
264 lines.push(format!("[{}] {title} — <{url}>", i + 1));
265 }
266 format!("{}\n", lines.join("\n"))
267}
268
269#[cfg(test)]
270mod tests {
271 use super::{RenderInputs, render_artifact};
272 use serde_json::json;
273
274 fn artifact() -> serde_json::Value {
275 json!({
276 "title": "Widget Synthesis",
277 "genre": "landscape",
278 "audience": "engineers",
279 "namespace": "harness/widgets",
280 "finding_refs": ["urn:mif:f1", "urn:mif:f2"],
281 "sections": [
282 {
283 "heading": "First finding",
284 "body": "Some prose about widgets.",
285 "dimension": "landscape",
286 "verdict": "survived",
287 "sources": [{"title": "Source A", "url": "https://a.example"}],
288 },
289 ],
290 "sources": [{"title": "Source A", "url": "https://a.example"}],
291 })
292 }
293
294 fn inputs(artifact: &serde_json::Value) -> RenderInputs<'_> {
295 RenderInputs {
296 artifact,
297 slug: "widget-report",
298 slugpath: "reports/widgets/widget-report",
299 created: "2026-01-01T00:00:00Z",
300 version: 1,
301 verification: None,
302 }
303 }
304
305 #[test]
306 fn report_channel_produces_yaml_frontmatter_and_l3_concept_fields() {
307 let art = artifact();
308 let rendered = render_artifact(&inputs(&art), "report").unwrap();
309 assert!(rendered.starts_with("---\n"));
310 assert!(rendered.contains("'@id': urn:mif:report:harness/widgets:widget-report"));
311 assert!(rendered.contains("conceptType: semantic"));
312 assert!(rendered.contains("## First finding"));
313 assert!(rendered.contains("## Sources"));
314 assert!(!rendered.contains("\n# Widget Synthesis"));
316 assert!(!rendered.contains("description:"));
318 }
319
320 #[test]
321 fn blog_channel_carries_a_body_h1_and_no_dimension_meta_line() {
322 let art = artifact();
323 let rendered = render_artifact(&inputs(&art), "blog").unwrap();
324 assert!(rendered.contains("# Widget Synthesis"));
325 assert!(!rendered.contains("_Dimension:"));
326 assert!(rendered.contains("Evidence:"));
327 assert!(!rendered.contains("# Widget Synthesis\n\n>"));
329 }
330
331 #[test]
332 fn book_channel_has_chapter_heading_and_numbered_endnotes_not_inline_evidence() {
333 let art = artifact();
334 let rendered = render_artifact(&inputs(&art), "book").unwrap();
335 assert!(rendered.contains("# Chapter: Widget Synthesis"));
336 assert!(rendered.contains("> Genre: landscape · audience: engineers"));
337 assert!(rendered.contains("## Endnotes"));
338 assert!(rendered.contains("[1] Source A"));
339 assert!(!rendered.contains("Evidence:"));
340 assert!(rendered.contains("# Chapter: Widget Synthesis\n\n> Genre:"));
343 }
344
345 #[test]
346 fn report_channel_projects_subtitle_into_description_frontmatter_when_present() {
347 let mut art = artifact();
348 art["subtitle"] = json!("The BLUF sentence a reader needs most.");
349 let rendered = render_artifact(&inputs(&art), "report").unwrap();
350 assert!(rendered.contains("description: The BLUF sentence a reader needs most."));
351 }
352
353 #[test]
354 fn blog_channel_carries_a_lede_blockquote_when_subtitle_present() {
355 let mut art = artifact();
356 art["subtitle"] = json!("The BLUF sentence a reader needs most.");
357 let rendered = render_artifact(&inputs(&art), "blog").unwrap();
358 assert!(
359 rendered.contains("# Widget Synthesis\n\n> The BLUF sentence a reader needs most.")
360 );
361 }
362
363 #[test]
364 fn book_channel_carries_a_lede_blockquote_when_subtitle_present() {
365 let mut art = artifact();
366 art["subtitle"] = json!("The BLUF sentence a reader needs most.");
367 let rendered = render_artifact(&inputs(&art), "book").unwrap();
368 assert!(rendered.contains(
369 "# Chapter: Widget Synthesis\n\n> The BLUF sentence a reader needs most.\n\n> Genre:"
370 ));
371 }
372
373 #[test]
374 fn blog_channel_prefixes_every_line_of_a_multiline_subtitle() {
375 let mut art = artifact();
376 art["subtitle"] = json!("First line.\nSecond line.");
377 let rendered = render_artifact(&inputs(&art), "blog").unwrap();
378 assert!(rendered.contains("# Widget Synthesis\n\n> First line.\n> Second line."));
379 }
380
381 #[test]
382 fn book_channel_prefixes_every_line_of_a_multiline_subtitle() {
383 let mut art = artifact();
384 art["subtitle"] = json!("First line.\nSecond line.");
385 let rendered = render_artifact(&inputs(&art), "book").unwrap();
386 assert!(
387 rendered.contains(
388 "# Chapter: Widget Synthesis\n\n> First line.\n> Second line.\n\n> Genre:"
389 )
390 );
391 }
392
393 #[test]
394 fn rejects_an_unknown_channel() {
395 let art = artifact();
396 let error = render_artifact(&inputs(&art), "podcast").unwrap_err();
397 assert!(matches!(
398 error,
399 super::MifRhError::InvalidToggleValue { .. }
400 ));
401 }
402
403 #[test]
404 fn report_channel_folds_in_a_supplied_verification_verdict() {
405 let art = artifact();
406 let verification = json!({"verdict": "survived", "attempted_at": "2026-01-01"});
407 let mut opts = inputs(&art);
408 opts.verification = Some(&verification);
409 let rendered = render_artifact(&opts, "report").unwrap();
410 assert!(rendered.contains("verdict: survived"));
411 }
412}