1use serde::Serialize;
2use std::collections::HashMap;
3
4#[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
217 pub fn with_children(mut self, children: Vec<DocEntry>) -> Self {
218 if !children.is_empty() {
219 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#[derive(Default)]
236pub struct HtmlOptions<'a> {
237 pub path: Option<&'a str>,
239 pub url: Option<&'a str>,
241 pub content: Option<&'a str>,
243 pub title: Option<&'a str>,
245 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 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}