Skip to main content

executable_stories/
doc_entry.rs

1use serde::Serialize;
2use std::collections::HashMap;
3
4/// A flexible document entry that serializes as a flat JSON object.
5///
6/// Each entry has a `"phase": "runtime"` field set automatically.
7#[derive(Clone)]
8pub struct DocEntry(HashMap<String, serde_json::Value>);
9
10impl Serialize for DocEntry {
11    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
12    where
13        S: serde::Serializer,
14    {
15        self.0.serialize(serializer)
16    }
17}
18
19impl DocEntry {
20    fn base() -> HashMap<String, serde_json::Value> {
21        let mut map = HashMap::new();
22        map.insert("phase".to_string(), serde_json::Value::String("runtime".to_string()));
23        map
24    }
25
26    /// A simple text note.
27    #[must_use]
28    pub fn note(text: &str) -> Self {
29        let mut map = Self::base();
30        map.insert("kind".to_string(), serde_json::Value::String("note".to_string()));
31        map.insert("text".to_string(), serde_json::Value::String(text.to_string()));
32        DocEntry(map)
33    }
34
35    /// Tag annotation with one or more tag names.
36    #[must_use]
37    pub fn tag(names: &[&str]) -> Self {
38        let mut map = Self::base();
39        map.insert("kind".to_string(), serde_json::Value::String("tag".to_string()));
40        let values: Vec<serde_json::Value> =
41            names.iter().map(|n| serde_json::Value::String((*n).to_string())).collect();
42        map.insert("names".to_string(), serde_json::Value::Array(values));
43        DocEntry(map)
44    }
45
46    /// A key-value pair.
47    #[must_use]
48    pub fn kv(label: &str, value: serde_json::Value) -> Self {
49        let mut map = Self::base();
50        map.insert("kind".to_string(), serde_json::Value::String("kv".to_string()));
51        map.insert("label".to_string(), serde_json::Value::String(label.to_string()));
52        map.insert("value".to_string(), value);
53        DocEntry(map)
54    }
55
56    /// A code block with optional language.
57    #[must_use]
58    pub fn code(label: &str, content: &str, lang: Option<&str>) -> Self {
59        let mut map = Self::base();
60        map.insert("kind".to_string(), serde_json::Value::String("code".to_string()));
61        map.insert("label".to_string(), serde_json::Value::String(label.to_string()));
62        map.insert("content".to_string(), serde_json::Value::String(content.to_string()));
63        if let Some(l) = lang {
64            map.insert("lang".to_string(), serde_json::Value::String(l.to_string()));
65        }
66        DocEntry(map)
67    }
68
69    /// A JSON document rendered as a code block with lang=json.
70    pub fn json_doc(label: &str, value: &impl Serialize) -> Self {
71        let json_str = serde_json::to_string_pretty(value).unwrap_or_default();
72        Self::code(label, &json_str, Some("json"))
73    }
74
75    /// A table with columns and rows.
76    #[must_use]
77    pub fn table(label: &str, columns: &[&str], rows: &[&[&str]]) -> Self {
78        let mut map = Self::base();
79        map.insert("kind".to_string(), serde_json::Value::String("table".to_string()));
80        map.insert("label".to_string(), serde_json::Value::String(label.to_string()));
81
82        let col_values: Vec<serde_json::Value> =
83            columns.iter().map(|c| serde_json::Value::String((*c).to_string())).collect();
84        map.insert("columns".to_string(), serde_json::Value::Array(col_values));
85
86        let row_values: Vec<serde_json::Value> = rows
87            .iter()
88            .map(|row| {
89                let cells: Vec<serde_json::Value> =
90                    row.iter().map(|c| serde_json::Value::String((*c).to_string())).collect();
91                serde_json::Value::Array(cells)
92            })
93            .collect();
94        map.insert("rows".to_string(), serde_json::Value::Array(row_values));
95        DocEntry(map)
96    }
97
98    /// A hyperlink.
99    #[must_use]
100    pub fn link(label: &str, url: &str) -> Self {
101        let mut map = Self::base();
102        map.insert("kind".to_string(), serde_json::Value::String("link".to_string()));
103        map.insert("label".to_string(), serde_json::Value::String(label.to_string()));
104        map.insert("url".to_string(), serde_json::Value::String(url.to_string()));
105        DocEntry(map)
106    }
107
108    /// A markdown section.
109    #[must_use]
110    pub fn section(title: &str, markdown: &str) -> Self {
111        let mut map = Self::base();
112        map.insert("kind".to_string(), serde_json::Value::String("section".to_string()));
113        map.insert("title".to_string(), serde_json::Value::String(title.to_string()));
114        map.insert("markdown".to_string(), serde_json::Value::String(markdown.to_string()));
115        DocEntry(map)
116    }
117
118    /// A mermaid diagram.
119    #[must_use]
120    pub fn mermaid(code: &str, title: Option<&str>) -> Self {
121        let mut map = Self::base();
122        map.insert("kind".to_string(), serde_json::Value::String("mermaid".to_string()));
123        map.insert("code".to_string(), serde_json::Value::String(code.to_string()));
124        if let Some(t) = title {
125            map.insert("title".to_string(), serde_json::Value::String(t.to_string()));
126        }
127        DocEntry(map)
128    }
129
130    /// A screenshot reference.
131    #[must_use]
132    pub fn screenshot(path: &str, alt: Option<&str>) -> Self {
133        let mut map = Self::base();
134        map.insert("kind".to_string(), serde_json::Value::String("screenshot".to_string()));
135        map.insert("path".to_string(), serde_json::Value::String(path.to_string()));
136        if let Some(a) = alt {
137            map.insert("alt".to_string(), serde_json::Value::String(a.to_string()));
138        }
139        DocEntry(map)
140    }
141
142    /// A video, played inline in HTML reports. `poster` is the still shown
143    /// before playback.
144    #[must_use]
145    pub fn video(path: &str, caption: Option<&str>, poster: Option<&str>) -> Self {
146        let mut map = Self::base();
147        map.insert("kind".to_string(), serde_json::Value::String("video".to_string()));
148        map.insert("path".to_string(), serde_json::Value::String(path.to_string()));
149        if let Some(c) = caption {
150            map.insert("caption".to_string(), serde_json::Value::String(c.to_string()));
151        }
152        if let Some(p) = poster {
153            map.insert("poster".to_string(), serde_json::Value::String(p.to_string()));
154        }
155        DocEntry(map)
156    }
157
158    /// Embedded HTML rendered inside an always-sandboxed iframe.
159    ///
160    /// # Panics
161    /// Panics unless exactly one of `path`, `url`, or `content` is set.
162    #[must_use]
163    pub fn html(opts: HtmlOptions) -> Self {
164        let count = u8::from(opts.path.is_some())
165            + u8::from(opts.url.is_some())
166            + u8::from(opts.content.is_some());
167        assert!(count == 1, "DocEntry::html requires exactly one of path, url, or content");
168        let mut map = Self::base();
169        map.insert("kind".to_string(), serde_json::Value::String("html".to_string()));
170        if let Some(p) = opts.path {
171            map.insert("path".to_string(), serde_json::Value::String(p.to_string()));
172        }
173        if let Some(u) = opts.url {
174            map.insert("url".to_string(), serde_json::Value::String(u.to_string()));
175        }
176        if let Some(c) = opts.content {
177            map.insert("content".to_string(), serde_json::Value::String(c.to_string()));
178        }
179        if let Some(t) = opts.title {
180            map.insert("title".to_string(), serde_json::Value::String(t.to_string()));
181        }
182        if let Some(h) = opts.height {
183            map.insert("height".to_string(), h);
184        }
185        DocEntry(map)
186    }
187
188    /// A state snapshot captured at a step (data storyboard frame).
189    ///
190    /// `value` is a JSON-serializable snapshot of "what the world looks like"
191    /// at this step; consecutive states with the same label are diffed by the
192    /// report renderer. `None` (or an empty label) omits the label field
193    /// (anonymous state lane).
194    #[must_use]
195    pub fn state(label: Option<&str>, value: serde_json::Value) -> Self {
196        let mut map = Self::base();
197        map.insert("kind".to_string(), serde_json::Value::String("state".to_string()));
198        if let Some(l) = label.filter(|l| !l.is_empty()) {
199            map.insert("label".to_string(), serde_json::Value::String(l.to_string()));
200        }
201        map.insert("value".to_string(), value);
202        DocEntry(map)
203    }
204
205    /// A custom doc entry with arbitrary type and data.
206    #[must_use]
207    pub fn custom(type_name: &str, data: serde_json::Value) -> Self {
208        let mut map = Self::base();
209        map.insert("kind".to_string(), serde_json::Value::String("custom".to_string()));
210        map.insert("type".to_string(), serde_json::Value::String(type_name.to_string()));
211        map.insert("data".to_string(), data);
212        DocEntry(map)
213    }
214
215    /// Add children to this doc entry.
216    #[must_use]
217    pub fn with_children(mut self, children: Vec<DocEntry>) -> Self {
218        if !children.is_empty() {
219            // An entry is already a JSON object, so hand it over as one rather
220            // than serializing it back through a fallible conversion.
221            let child_values: Vec<serde_json::Value> = children
222                .into_iter()
223                .map(|c| serde_json::Value::Object(c.0.into_iter().collect()))
224                .collect();
225            self.0.insert("children".to_string(), serde_json::Value::Array(child_values));
226        }
227        self
228    }
229}
230
231/// Options for an embedded-HTML doc entry ([`DocEntry::html`]).
232///
233/// Exactly one of `path`, `url`, or `content` must be `Some`. Build with
234/// [`HtmlOptions::default`] and set the fields you need.
235#[derive(Default)]
236pub struct HtmlOptions<'a> {
237    /// Local HTML file path (inlined into the report by default).
238    pub path: Option<&'a str>,
239    /// Remote URL rendered via iframe src.
240    pub url: Option<&'a str>,
241    /// Inline HTML content rendered via iframe srcdoc.
242    pub content: Option<&'a str>,
243    /// Title shown in the embed's chrome bar.
244    pub title: Option<&'a str>,
245    /// Iframe height: a JSON number → px, a JSON string passed through (e.g. "60vh"). Default 400px.
246    pub height: Option<serde_json::Value>,
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn note_serializes_correctly() {
255        let entry = DocEntry::note("hello");
256        let json = serde_json::to_value(&entry).unwrap();
257        assert_eq!(json["kind"], "note");
258        assert_eq!(json["text"], "hello");
259        assert_eq!(json["phase"], "runtime");
260    }
261
262    #[test]
263    fn tag_serializes_correctly() {
264        let entry = DocEntry::tag(&["smoke", "fast"]);
265        let json = serde_json::to_value(&entry).unwrap();
266        assert_eq!(json["kind"], "tag");
267        assert_eq!(json["names"], serde_json::json!(["smoke", "fast"]));
268        assert_eq!(json["phase"], "runtime");
269    }
270
271    #[test]
272    fn kv_serializes_correctly() {
273        let entry = DocEntry::kv("count", serde_json::json!(42));
274        let json = serde_json::to_value(&entry).unwrap();
275        assert_eq!(json["kind"], "kv");
276        assert_eq!(json["label"], "count");
277        assert_eq!(json["value"], 42);
278    }
279
280    #[test]
281    fn code_serializes_correctly() {
282        let entry = DocEntry::code("snippet", "fn main() {}", Some("rust"));
283        let json = serde_json::to_value(&entry).unwrap();
284        assert_eq!(json["kind"], "code");
285        assert_eq!(json["label"], "snippet");
286        assert_eq!(json["content"], "fn main() {}");
287        assert_eq!(json["lang"], "rust");
288    }
289
290    #[test]
291    fn json_doc_serializes_as_code_with_json_lang() {
292        let data = serde_json::json!({"key": "value"});
293        let entry = DocEntry::json_doc("payload", &data);
294        let json = serde_json::to_value(&entry).unwrap();
295        assert_eq!(json["kind"], "code");
296        assert_eq!(json["lang"], "json");
297    }
298
299    #[test]
300    fn table_serializes_correctly() {
301        let entry = DocEntry::table("results", &["name", "score"], &[&["Alice", "100"]]);
302        let json = serde_json::to_value(&entry).unwrap();
303        assert_eq!(json["kind"], "table");
304        assert_eq!(json["columns"], serde_json::json!(["name", "score"]));
305        assert_eq!(json["rows"], serde_json::json!([["Alice", "100"]]));
306    }
307
308    #[test]
309    fn link_serializes_correctly() {
310        let entry = DocEntry::link("docs", "https://example.com");
311        let json = serde_json::to_value(&entry).unwrap();
312        assert_eq!(json["kind"], "link");
313        assert_eq!(json["url"], "https://example.com");
314    }
315
316    #[test]
317    fn section_serializes_correctly() {
318        let entry = DocEntry::section("Details", "## Heading\nBody text");
319        let json = serde_json::to_value(&entry).unwrap();
320        assert_eq!(json["kind"], "section");
321        assert_eq!(json["title"], "Details");
322        assert_eq!(json["markdown"], "## Heading\nBody text");
323    }
324
325    #[test]
326    fn mermaid_serializes_correctly() {
327        let entry = DocEntry::mermaid("graph TD; A-->B;", Some("Flow"));
328        let json = serde_json::to_value(&entry).unwrap();
329        assert_eq!(json["kind"], "mermaid");
330        assert_eq!(json["code"], "graph TD; A-->B;");
331        assert_eq!(json["title"], "Flow");
332    }
333
334    #[test]
335    fn screenshot_serializes_correctly() {
336        let entry = DocEntry::screenshot("/tmp/shot.png", Some("Login page"));
337        let json = serde_json::to_value(&entry).unwrap();
338        assert_eq!(json["kind"], "screenshot");
339        assert_eq!(json["path"], "/tmp/shot.png");
340        assert_eq!(json["alt"], "Login page");
341    }
342
343    #[test]
344    fn video_has_path_caption_and_poster() {
345        let json = serde_json::to_value(DocEntry::video(
346            "artifacts/run.webm",
347            Some("Full checkout run"),
348            Some("artifacts/run.jpg"),
349        ))
350        .unwrap();
351        assert_eq!(json["kind"], "video");
352        assert_eq!(json["path"], "artifacts/run.webm");
353        assert_eq!(json["caption"], "Full checkout run");
354        assert_eq!(json["poster"], "artifacts/run.jpg");
355        assert_eq!(json["phase"], "runtime");
356    }
357
358    #[test]
359    fn video_omits_what_was_not_given() {
360        let json = serde_json::to_value(DocEntry::video("run.webm", None, None)).unwrap();
361        assert_eq!(json["path"], "run.webm");
362        assert!(json.get("caption").is_none());
363        assert!(json.get("poster").is_none());
364    }
365
366    #[test]
367    fn html_serializes_correctly() {
368        let entry = DocEntry::html(HtmlOptions {
369            path: Some("./coverage/index.html"),
370            title: Some("Coverage"),
371            height: Some(serde_json::json!(600)),
372            ..Default::default()
373        });
374        let json = serde_json::to_value(&entry).unwrap();
375        assert_eq!(json["kind"], "html");
376        assert_eq!(json["path"], "./coverage/index.html");
377        assert_eq!(json["title"], "Coverage");
378        assert_eq!(json["height"], 600);
379        assert!(json.get("url").is_none());
380        assert!(json.get("content").is_none());
381    }
382
383    #[test]
384    fn html_accepts_string_height_and_content() {
385        let entry = DocEntry::html(HtmlOptions {
386            content: Some("<h1>Hi</h1>"),
387            height: Some(serde_json::json!("60vh")),
388            ..Default::default()
389        });
390        let json = serde_json::to_value(&entry).unwrap();
391        assert_eq!(json["content"], "<h1>Hi</h1>");
392        assert_eq!(json["height"], "60vh");
393    }
394
395    #[test]
396    #[should_panic(expected = "exactly one")]
397    fn html_panics_without_a_source() {
398        let _ = DocEntry::html(HtmlOptions { title: Some("x"), ..Default::default() });
399    }
400
401    #[test]
402    #[should_panic(expected = "exactly one")]
403    fn html_panics_with_two_sources() {
404        let _ = DocEntry::html(HtmlOptions {
405            path: Some("a.html"),
406            url: Some("https://x.test"),
407            ..Default::default()
408        });
409    }
410
411    #[test]
412    fn state_serializes_correctly() {
413        let value = serde_json::json!({
414            "items": [{"sku": "A1", "qty": 2}],
415            "total": 9.99
416        });
417        let entry = DocEntry::state(Some("Basket"), value.clone());
418        let json = serde_json::to_value(&entry).unwrap();
419        assert_eq!(json["kind"], "state");
420        assert_eq!(json["label"], "Basket");
421        assert_eq!(json["phase"], "runtime");
422        // Arbitrary nested JSON round-trips untouched
423        assert_eq!(json["value"], value);
424    }
425
426    #[test]
427    fn state_without_label_omits_field() {
428        let entry = DocEntry::state(None, serde_json::json!([1, 2, 3]));
429        let json = serde_json::to_value(&entry).unwrap();
430        assert_eq!(json["kind"], "state");
431        assert!(json.get("label").is_none());
432        assert_eq!(json["value"], serde_json::json!([1, 2, 3]));
433    }
434
435    #[test]
436    fn state_with_empty_label_omits_field() {
437        let entry = DocEntry::state(Some(""), serde_json::json!({"ok": true}));
438        let json = serde_json::to_value(&entry).unwrap();
439        assert!(json.get("label").is_none());
440    }
441
442    #[test]
443    fn custom_serializes_correctly() {
444        let entry = DocEntry::custom("metrics", serde_json::json!({"latency_ms": 42}));
445        let json = serde_json::to_value(&entry).unwrap();
446        assert_eq!(json["kind"], "custom");
447        assert_eq!(json["type"], "metrics");
448        assert_eq!(json["data"]["latency_ms"], 42);
449    }
450}