use serde_json::{Value, json};
use crate::error::MifRhError;
use crate::harness_markdown::{dedupe_sections, secblock, source_link_line};
pub struct RenderInputs<'a> {
pub artifact: &'a Value,
pub slug: &'a str,
pub slugpath: &'a str,
pub created: &'a str,
pub version: u64,
pub verification: Option<&'a Value>,
}
pub fn render_artifact(inputs: &RenderInputs<'_>, channel: &str) -> Result<String, MifRhError> {
match channel {
"report" => render_report(inputs),
"blog" => Ok(render_blog(inputs)),
"book" => Ok(render_book(inputs)),
other => Err(MifRhError::InvalidToggleValue {
field: "channel".to_string(),
value: other.to_string(),
allowed: "report|blog|book".to_string(),
}),
}
}
fn namespace_of(artifact: &Value) -> &str {
artifact
.get("namespace")
.and_then(Value::as_str)
.unwrap_or("harness/report")
}
fn sections_of(artifact: &Value) -> Vec<Value> {
let raw = artifact
.get("sections")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
dedupe_sections(&raw)
}
fn sources_of(artifact: &Value) -> Vec<Value> {
artifact
.get("sources")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
}
fn sources_list_block(artifact: &Value) -> Vec<String> {
let mut lines = vec![String::new(), "## Sources".to_string(), String::new()];
for source in &sources_of(artifact) {
lines.push(source_link_line(source));
}
lines
}
fn render_report(inputs: &RenderInputs<'_>) -> Result<String, MifRhError> {
let artifact = inputs.artifact;
let namespace = namespace_of(artifact);
let genre = artifact
.get("genre")
.and_then(Value::as_str)
.unwrap_or("general");
let finding_count = artifact
.get("finding_refs")
.and_then(Value::as_array)
.map_or(0, Vec::len);
let mut body_lines = vec![format!(
"This {genre} synthesis covers {finding_count} surviving finding(s) across the research."
)];
for section in §ions_of(artifact) {
body_lines.extend(secblock(section, true, true));
}
body_lines.extend(sources_list_block(artifact));
let body = body_lines.join("\n");
let citations: Vec<Value> = sources_of(artifact)
.iter()
.map(|source| {
let mut citation = json!({
"@type": "Citation",
"citationType": source.get("citationType"),
"citationRole": source.get("citationRole"),
"title": source.get("title"),
"url": source.get("url"),
});
if let Some(note) = source.get("note") {
citation["note"] = note.clone();
}
citation
})
.collect();
let mut concept = json!({
"@context": "https://mif-spec.dev/schema/context.jsonld",
"@type": "Concept",
"@id": format!("urn:mif:report:{namespace}:{}", inputs.slug),
"slug": inputs.slugpath,
"version": inputs.version,
"conceptType": "semantic",
"namespace": namespace,
"title": artifact.get("title"),
"genre": genre,
"created": inputs.created,
"provenance": {
"@type": "Provenance",
"sourceType": "system_generated",
"confidence": 0.9,
"trustLevel": "moderate_confidence",
},
"citations": citations,
"extensions": { "harness": { "dimension": "synthesis" } },
});
if let Some(verification) = inputs.verification {
concept["extensions"]["harness"]["verification"] = verification.clone();
}
let frontmatter_yaml = serde_norway::to_string(&concept)
.map_err(|source| MifRhError::FrontmatterYamlSerialize { source })?;
Ok(format!("---\n{frontmatter_yaml}---\n\n{body}\n"))
}
fn render_blog(inputs: &RenderInputs<'_>) -> String {
let artifact = inputs.artifact;
let namespace = namespace_of(artifact);
let title = artifact
.get("title")
.and_then(Value::as_str)
.unwrap_or_default();
let mut lines = vec![
"---".to_string(),
"\"@context\": https://mif-spec.dev/schema/context.jsonld".to_string(),
"\"@type\": Concept".to_string(),
format!("\"@id\": urn:mif:blog:{namespace}:{}", inputs.slug),
format!("slug: {}", inputs.slugpath),
format!("version: {}", inputs.version),
"conceptType: semantic".to_string(),
format!("created: \"{}\"", inputs.created),
format!("namespace: {namespace}"),
"---".to_string(),
String::new(),
format!("# {title}"),
];
for section in §ions_of(artifact) {
lines.extend(secblock(section, false, true));
}
lines.extend(sources_list_block(artifact));
format!("{}\n", lines.join("\n"))
}
fn render_book(inputs: &RenderInputs<'_>) -> String {
let artifact = inputs.artifact;
let namespace = namespace_of(artifact);
let title = artifact
.get("title")
.and_then(Value::as_str)
.unwrap_or_default();
let genre = artifact
.get("genre")
.and_then(Value::as_str)
.unwrap_or("general");
let audience = artifact
.get("audience")
.and_then(Value::as_str)
.unwrap_or("general");
let mut lines = vec![
"---".to_string(),
"\"@context\": https://mif-spec.dev/schema/context.jsonld".to_string(),
"\"@type\": Concept".to_string(),
format!("\"@id\": urn:mif:book:{namespace}:{}", inputs.slug),
format!("slug: {}", inputs.slugpath),
format!("version: {}", inputs.version),
"conceptType: semantic".to_string(),
format!("created: \"{}\"", inputs.created),
format!("namespace: {namespace}"),
"---".to_string(),
String::new(),
format!("# Chapter: {title}"),
String::new(),
format!("> Genre: {genre} ยท audience: {audience}"),
];
for section in §ions_of(artifact) {
lines.extend(secblock(section, false, false));
}
lines.push(String::new());
lines.push("## Endnotes".to_string());
lines.push(String::new());
for (i, source) in sources_of(artifact).iter().enumerate() {
let title = source
.get("title")
.and_then(Value::as_str)
.unwrap_or_default();
let title = title.trim_matches([' ', '\t']);
let url = source
.get("url")
.and_then(Value::as_str)
.unwrap_or_default();
lines.push(format!("[{}] {title} โ <{url}>", i + 1));
}
format!("{}\n", lines.join("\n"))
}
#[cfg(test)]
mod tests {
use super::{RenderInputs, render_artifact};
use serde_json::json;
fn artifact() -> serde_json::Value {
json!({
"title": "Widget Synthesis",
"genre": "landscape",
"audience": "engineers",
"namespace": "harness/widgets",
"finding_refs": ["urn:mif:f1", "urn:mif:f2"],
"sections": [
{
"heading": "First finding",
"body": "Some prose about widgets.",
"dimension": "landscape",
"verdict": "survived",
"sources": [{"title": "Source A", "url": "https://a.example"}],
},
],
"sources": [{"title": "Source A", "url": "https://a.example"}],
})
}
fn inputs(artifact: &serde_json::Value) -> RenderInputs<'_> {
RenderInputs {
artifact,
slug: "widget-report",
slugpath: "reports/widgets/widget-report",
created: "2026-01-01T00:00:00Z",
version: 1,
verification: None,
}
}
#[test]
fn report_channel_produces_yaml_frontmatter_and_l3_concept_fields() {
let art = artifact();
let rendered = render_artifact(&inputs(&art), "report").unwrap();
assert!(rendered.starts_with("---\n"));
assert!(rendered.contains("'@id': urn:mif:report:harness/widgets:widget-report"));
assert!(rendered.contains("conceptType: semantic"));
assert!(rendered.contains("## First finding"));
assert!(rendered.contains("## Sources"));
assert!(!rendered.contains("\n# Widget Synthesis"));
}
#[test]
fn blog_channel_carries_a_body_h1_and_no_dimension_meta_line() {
let art = artifact();
let rendered = render_artifact(&inputs(&art), "blog").unwrap();
assert!(rendered.contains("# Widget Synthesis"));
assert!(!rendered.contains("_Dimension:"));
assert!(rendered.contains("Evidence:"));
}
#[test]
fn book_channel_has_chapter_heading_and_numbered_endnotes_not_inline_evidence() {
let art = artifact();
let rendered = render_artifact(&inputs(&art), "book").unwrap();
assert!(rendered.contains("# Chapter: Widget Synthesis"));
assert!(rendered.contains("> Genre: landscape ยท audience: engineers"));
assert!(rendered.contains("## Endnotes"));
assert!(rendered.contains("[1] Source A"));
assert!(!rendered.contains("Evidence:"));
}
#[test]
fn rejects_an_unknown_channel() {
let art = artifact();
let error = render_artifact(&inputs(&art), "podcast").unwrap_err();
assert!(matches!(
error,
super::MifRhError::InvalidToggleValue { .. }
));
}
#[test]
fn report_channel_folds_in_a_supplied_verification_verdict() {
let art = artifact();
let verification = json!({"verdict": "survived", "attempted_at": "2026-01-01"});
let mut opts = inputs(&art);
opts.verification = Some(&verification);
let rendered = render_artifact(&opts, "report").unwrap();
assert!(rendered.contains("verdict: survived"));
}
}