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 custom doc entry with arbitrary type and data.
143    #[must_use]
144    pub fn custom(type_name: &str, data: serde_json::Value) -> Self {
145        let mut map = Self::base();
146        map.insert("kind".to_string(), serde_json::Value::String("custom".to_string()));
147        map.insert("type".to_string(), serde_json::Value::String(type_name.to_string()));
148        map.insert("data".to_string(), data);
149        DocEntry(map)
150    }
151
152    /// Add children to this doc entry.
153    #[must_use]
154    pub fn with_children(mut self, children: Vec<DocEntry>) -> Self {
155        if !children.is_empty() {
156            let child_values: Vec<serde_json::Value> = children
157                .into_iter()
158                .map(|c| serde_json::to_value(c).unwrap())
159                .collect();
160            self.0
161                .insert("children".to_string(), serde_json::Value::Array(child_values));
162        }
163        self
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn note_serializes_correctly() {
173        let entry = DocEntry::note("hello");
174        let json = serde_json::to_value(&entry).unwrap();
175        assert_eq!(json["kind"], "note");
176        assert_eq!(json["text"], "hello");
177        assert_eq!(json["phase"], "runtime");
178    }
179
180    #[test]
181    fn tag_serializes_correctly() {
182        let entry = DocEntry::tag(&["smoke", "fast"]);
183        let json = serde_json::to_value(&entry).unwrap();
184        assert_eq!(json["kind"], "tag");
185        assert_eq!(json["names"], serde_json::json!(["smoke", "fast"]));
186        assert_eq!(json["phase"], "runtime");
187    }
188
189    #[test]
190    fn kv_serializes_correctly() {
191        let entry = DocEntry::kv("count", serde_json::json!(42));
192        let json = serde_json::to_value(&entry).unwrap();
193        assert_eq!(json["kind"], "kv");
194        assert_eq!(json["label"], "count");
195        assert_eq!(json["value"], 42);
196    }
197
198    #[test]
199    fn code_serializes_correctly() {
200        let entry = DocEntry::code("snippet", "fn main() {}", Some("rust"));
201        let json = serde_json::to_value(&entry).unwrap();
202        assert_eq!(json["kind"], "code");
203        assert_eq!(json["label"], "snippet");
204        assert_eq!(json["content"], "fn main() {}");
205        assert_eq!(json["lang"], "rust");
206    }
207
208    #[test]
209    fn json_doc_serializes_as_code_with_json_lang() {
210        let data = serde_json::json!({"key": "value"});
211        let entry = DocEntry::json_doc("payload", &data);
212        let json = serde_json::to_value(&entry).unwrap();
213        assert_eq!(json["kind"], "code");
214        assert_eq!(json["lang"], "json");
215    }
216
217    #[test]
218    fn table_serializes_correctly() {
219        let entry = DocEntry::table("results", &["name", "score"], &[&["Alice", "100"]]);
220        let json = serde_json::to_value(&entry).unwrap();
221        assert_eq!(json["kind"], "table");
222        assert_eq!(json["columns"], serde_json::json!(["name", "score"]));
223        assert_eq!(json["rows"], serde_json::json!([["Alice", "100"]]));
224    }
225
226    #[test]
227    fn link_serializes_correctly() {
228        let entry = DocEntry::link("docs", "https://example.com");
229        let json = serde_json::to_value(&entry).unwrap();
230        assert_eq!(json["kind"], "link");
231        assert_eq!(json["url"], "https://example.com");
232    }
233
234    #[test]
235    fn section_serializes_correctly() {
236        let entry = DocEntry::section("Details", "## Heading\nBody text");
237        let json = serde_json::to_value(&entry).unwrap();
238        assert_eq!(json["kind"], "section");
239        assert_eq!(json["title"], "Details");
240        assert_eq!(json["markdown"], "## Heading\nBody text");
241    }
242
243    #[test]
244    fn mermaid_serializes_correctly() {
245        let entry = DocEntry::mermaid("graph TD; A-->B;", Some("Flow"));
246        let json = serde_json::to_value(&entry).unwrap();
247        assert_eq!(json["kind"], "mermaid");
248        assert_eq!(json["code"], "graph TD; A-->B;");
249        assert_eq!(json["title"], "Flow");
250    }
251
252    #[test]
253    fn screenshot_serializes_correctly() {
254        let entry = DocEntry::screenshot("/tmp/shot.png", Some("Login page"));
255        let json = serde_json::to_value(&entry).unwrap();
256        assert_eq!(json["kind"], "screenshot");
257        assert_eq!(json["path"], "/tmp/shot.png");
258        assert_eq!(json["alt"], "Login page");
259    }
260
261    #[test]
262    fn custom_serializes_correctly() {
263        let entry = DocEntry::custom("metrics", serde_json::json!({"latency_ms": 42}));
264        let json = serde_json::to_value(&entry).unwrap();
265        assert_eq!(json["kind"], "custom");
266        assert_eq!(json["type"], "metrics");
267        assert_eq!(json["data"]["latency_ms"], 42);
268    }
269}