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
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 &sections_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    // serde_json::Value's Serialize impl is format-agnostic, so serializing
158    // it through serde_norway's Serializer produces the same YAML a
159    // dedicated YAML type would, matching `yq -p=json -o=yaml`.
160    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 &sections_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 &sections_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        // The report body carries no H1 — the title lives in frontmatter.
293        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}