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 render_report(inputs: &RenderInputs<'_>) -> Result<String, MifRhError> {
96 let artifact = inputs.artifact;
97 let namespace = namespace_of(artifact);
98 let genre = artifact
99 .get("genre")
100 .and_then(Value::as_str)
101 .unwrap_or("general");
102 let finding_count = artifact
103 .get("finding_refs")
104 .and_then(Value::as_array)
105 .map_or(0, Vec::len);
106
107 let mut body_lines = vec![format!(
108 "This {genre} synthesis covers {finding_count} surviving finding(s) across the research."
109 )];
110 for section in §ions_of(artifact) {
111 body_lines.extend(secblock(section, true, true));
112 }
113 body_lines.extend(sources_list_block(artifact));
114 let body = body_lines.join("\n");
115
116 let citations: Vec<Value> = sources_of(artifact)
117 .iter()
118 .map(|source| {
119 let mut citation = json!({
120 "@type": "Citation",
121 "citationType": source.get("citationType"),
122 "citationRole": source.get("citationRole"),
123 "title": source.get("title"),
124 "url": source.get("url"),
125 });
126 if let Some(note) = source.get("note") {
127 citation["note"] = note.clone();
128 }
129 citation
130 })
131 .collect();
132
133 let mut concept = json!({
134 "@context": "https://mif-spec.dev/schema/context.jsonld",
135 "@type": "Concept",
136 "@id": format!("urn:mif:report:{namespace}:{}", inputs.slug),
137 "slug": inputs.slugpath,
138 "version": inputs.version,
139 "conceptType": "semantic",
140 "namespace": namespace,
141 "title": artifact.get("title"),
142 "genre": genre,
143 "created": inputs.created,
144 "provenance": {
145 "@type": "Provenance",
146 "sourceType": "system_generated",
147 "confidence": 0.9,
148 "trustLevel": "moderate_confidence",
149 },
150 "citations": citations,
151 "extensions": { "harness": { "dimension": "synthesis" } },
152 });
153 if let Some(verification) = inputs.verification {
154 concept["extensions"]["harness"]["verification"] = verification.clone();
155 }
156
157 let frontmatter_yaml = serde_norway::to_string(&concept)
161 .map_err(|source| MifRhError::FrontmatterYamlSerialize { source })?;
162 Ok(format!("---\n{frontmatter_yaml}---\n\n{body}\n"))
163}
164
165fn render_blog(inputs: &RenderInputs<'_>) -> String {
166 let artifact = inputs.artifact;
167 let namespace = namespace_of(artifact);
168 let title = artifact
169 .get("title")
170 .and_then(Value::as_str)
171 .unwrap_or_default();
172
173 let mut lines = vec![
174 "---".to_string(),
175 "\"@context\": https://mif-spec.dev/schema/context.jsonld".to_string(),
176 "\"@type\": Concept".to_string(),
177 format!("\"@id\": urn:mif:blog:{namespace}:{}", inputs.slug),
178 format!("slug: {}", inputs.slugpath),
179 format!("version: {}", inputs.version),
180 "conceptType: semantic".to_string(),
181 format!("created: \"{}\"", inputs.created),
182 format!("namespace: {namespace}"),
183 "---".to_string(),
184 String::new(),
185 format!("# {title}"),
186 ];
187 for section in §ions_of(artifact) {
188 lines.extend(secblock(section, false, true));
189 }
190 lines.extend(sources_list_block(artifact));
191 format!("{}\n", lines.join("\n"))
192}
193
194fn render_book(inputs: &RenderInputs<'_>) -> String {
195 let artifact = inputs.artifact;
196 let namespace = namespace_of(artifact);
197 let title = artifact
198 .get("title")
199 .and_then(Value::as_str)
200 .unwrap_or_default();
201 let genre = artifact
202 .get("genre")
203 .and_then(Value::as_str)
204 .unwrap_or("general");
205 let audience = artifact
206 .get("audience")
207 .and_then(Value::as_str)
208 .unwrap_or("general");
209
210 let mut lines = vec![
211 "---".to_string(),
212 "\"@context\": https://mif-spec.dev/schema/context.jsonld".to_string(),
213 "\"@type\": Concept".to_string(),
214 format!("\"@id\": urn:mif:book:{namespace}:{}", inputs.slug),
215 format!("slug: {}", inputs.slugpath),
216 format!("version: {}", inputs.version),
217 "conceptType: semantic".to_string(),
218 format!("created: \"{}\"", inputs.created),
219 format!("namespace: {namespace}"),
220 "---".to_string(),
221 String::new(),
222 format!("# Chapter: {title}"),
223 String::new(),
224 format!("> Genre: {genre} · audience: {audience}"),
225 ];
226 for section in §ions_of(artifact) {
227 lines.extend(secblock(section, false, false));
228 }
229 lines.push(String::new());
230 lines.push("## Endnotes".to_string());
231 lines.push(String::new());
232 for (i, source) in sources_of(artifact).iter().enumerate() {
233 let title = source
234 .get("title")
235 .and_then(Value::as_str)
236 .unwrap_or_default();
237 let title = title.trim_matches([' ', '\t']);
238 let url = source
239 .get("url")
240 .and_then(Value::as_str)
241 .unwrap_or_default();
242 lines.push(format!("[{}] {title} — <{url}>", i + 1));
243 }
244 format!("{}\n", lines.join("\n"))
245}
246
247#[cfg(test)]
248mod tests {
249 use super::{RenderInputs, render_artifact};
250 use serde_json::json;
251
252 fn artifact() -> serde_json::Value {
253 json!({
254 "title": "Widget Synthesis",
255 "genre": "landscape",
256 "audience": "engineers",
257 "namespace": "harness/widgets",
258 "finding_refs": ["urn:mif:f1", "urn:mif:f2"],
259 "sections": [
260 {
261 "heading": "First finding",
262 "body": "Some prose about widgets.",
263 "dimension": "landscape",
264 "verdict": "survived",
265 "sources": [{"title": "Source A", "url": "https://a.example"}],
266 },
267 ],
268 "sources": [{"title": "Source A", "url": "https://a.example"}],
269 })
270 }
271
272 fn inputs(artifact: &serde_json::Value) -> RenderInputs<'_> {
273 RenderInputs {
274 artifact,
275 slug: "widget-report",
276 slugpath: "reports/widgets/widget-report",
277 created: "2026-01-01T00:00:00Z",
278 version: 1,
279 verification: None,
280 }
281 }
282
283 #[test]
284 fn report_channel_produces_yaml_frontmatter_and_l3_concept_fields() {
285 let art = artifact();
286 let rendered = render_artifact(&inputs(&art), "report").unwrap();
287 assert!(rendered.starts_with("---\n"));
288 assert!(rendered.contains("'@id': urn:mif:report:harness/widgets:widget-report"));
289 assert!(rendered.contains("conceptType: semantic"));
290 assert!(rendered.contains("## First finding"));
291 assert!(rendered.contains("## Sources"));
292 assert!(!rendered.contains("\n# Widget Synthesis"));
294 }
295
296 #[test]
297 fn blog_channel_carries_a_body_h1_and_no_dimension_meta_line() {
298 let art = artifact();
299 let rendered = render_artifact(&inputs(&art), "blog").unwrap();
300 assert!(rendered.contains("# Widget Synthesis"));
301 assert!(!rendered.contains("_Dimension:"));
302 assert!(rendered.contains("Evidence:"));
303 }
304
305 #[test]
306 fn book_channel_has_chapter_heading_and_numbered_endnotes_not_inline_evidence() {
307 let art = artifact();
308 let rendered = render_artifact(&inputs(&art), "book").unwrap();
309 assert!(rendered.contains("# Chapter: Widget Synthesis"));
310 assert!(rendered.contains("> Genre: landscape · audience: engineers"));
311 assert!(rendered.contains("## Endnotes"));
312 assert!(rendered.contains("[1] Source A"));
313 assert!(!rendered.contains("Evidence:"));
314 }
315
316 #[test]
317 fn rejects_an_unknown_channel() {
318 let art = artifact();
319 let error = render_artifact(&inputs(&art), "podcast").unwrap_err();
320 assert!(matches!(
321 error,
322 super::MifRhError::InvalidToggleValue { .. }
323 ));
324 }
325
326 #[test]
327 fn report_channel_folds_in_a_supplied_verification_verdict() {
328 let art = artifact();
329 let verification = json!({"verdict": "survived", "attempted_at": "2026-01-01"});
330 let mut opts = inputs(&art);
331 opts.verification = Some(&verification);
332 let rendered = render_artifact(&opts, "report").unwrap();
333 assert!(rendered.contains("verdict: survived"));
334 }
335}