Skip to main content

mif_rh/
harness_render.rs

1//! Artifact-to-channel rendering (rht Category B, Story #293).
2//!
3//! Ports rht's `scripts/render-artifact.sh`: renders one typed Artifact
4//! (`schemas/artifact.schema.json`) to an output channel. Three channels:
5//! `report` (the canonical MIF Level-3 markdown report, write-then-validated
6//! by `mif-project.sh`), `blog`, and `book` (published, MIF Level-1
7//! channels — exempt from L3 conformance, no internal finding identity
8//! leaks into their prose).
9
10use serde_json::{Value, json};
11
12use crate::error::MifRhError;
13use crate::harness_markdown::{dedupe_sections, secblock, source_link_line};
14
15/// The pre-resolved, caller-supplied inputs [`render_artifact`] needs
16/// beyond the artifact JSON itself.
17///
18/// Path/version arithmetic the original script does against the live
19/// filesystem (resolving `$OUT`'s repo-relative slug path, reading a
20/// prior version to increment) stays a CLI-layer concern, not a
21/// pure-rendering one.
22pub struct RenderInputs<'a> {
23    /// The parsed `artifact.json` document. Its `.namespace` field (or the
24    /// `"harness/report"` default) is the report/blog/book namespace —
25    /// derived internally, not a separate input.
26    pub artifact: &'a Value,
27    /// The output file's slug (its basename, minus `.md`).
28    pub slug: &'a str,
29    /// The output file's repo-root-relative path (for the `slug:`
30    /// frontmatter field astro-rehype-relative-markdown-links needs).
31    pub slugpath: &'a str,
32    /// The RFC 3339 `created` timestamp.
33    pub created: &'a str,
34    /// This revision's version number (prior version + 1, or 1).
35    pub version: u64,
36    /// A falsification verdict (`extensions.harness.verification`) to fold
37    /// into the `report` channel's frontmatter. Required for `report`;
38    /// ignored for `blog`/`book`.
39    pub verification: Option<&'a Value>,
40}
41
42/// Renders `inputs.artifact` to `channel` (`"report"`, `"blog"`, or
43/// `"book"`), returning the complete markdown file contents (frontmatter +
44/// body).
45///
46/// # Errors
47///
48/// Returns [`MifRhError::InvalidToggleValue`] if `channel` is not one of
49/// the three recognized values.
50pub 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
95/// Prefixes every line of `text` with `> ` so a multiline lede stays inside
96/// one Markdown blockquote instead of only its first line.
97fn 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 &sections_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    // serde_json::Value's Serialize impl is format-agnostic, so serializing
170    // it through serde_norway's Serializer produces the same YAML a
171    // dedicated YAML type would, matching `yq -p=json -o=yaml`.
172    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 &sections_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 &sections_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        // The report body carries no H1 — the title lives in frontmatter.
315        assert!(!rendered.contains("\n# Widget Synthesis"));
316        // No `subtitle` in the fixture — `description` is omitted, not null.
317        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        // No `subtitle` in the fixture — no lede blockquote under the H1.
328        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        // No `subtitle` in the fixture — the genre/audience line is the
341        // first blockquote right after the H1, no separate lede above it.
342        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}