1use std::io::Write;
2
3use crate::format::FormatWriter;
4use crate::value::Value;
5
6pub struct HtmlWriter {
13 styled: bool,
15 full_html: bool,
17}
18
19impl HtmlWriter {
20 pub fn new(styled: bool, full_html: bool) -> Self {
21 Self { styled, full_html }
22 }
23}
24
25impl FormatWriter for HtmlWriter {
26 fn write(&self, value: &Value) -> anyhow::Result<String> {
27 Ok(render_html(value, self.styled, self.full_html))
28 }
29
30 fn write_to_writer(&self, value: &Value, mut writer: impl Write) -> anyhow::Result<()> {
31 let output = render_html(value, self.styled, self.full_html);
32 writer.write_all(output.as_bytes())?;
33 Ok(())
34 }
35}
36
37fn render_html(value: &Value, styled: bool, full_html: bool) -> String {
38 let table = match value {
39 Value::Array(arr) if !arr.is_empty() && arr[0].as_object().is_some() => {
40 render_array_of_objects(arr, styled)
41 }
42 Value::Array(arr) => render_array_of_primitives(arr, styled),
43 Value::Object(_) => render_single_object(value, styled),
44 _ => return escape_html(&format_cell_value(value)),
45 };
46
47 if full_html {
48 wrap_full_html(&table, styled)
49 } else {
50 table
51 }
52}
53
54fn wrap_full_html(table: &str, styled: bool) -> String {
55 let style_block = if styled {
56 "\n <style>\n table { border-collapse: collapse; width: 100%; font-family: sans-serif; }\n th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }\n th { background-color: #4a4a4a; color: white; }\n tr:nth-child(even) { background-color: #f9f9f9; }\n tr:hover { background-color: #f1f1f1; }\n </style>"
57 } else {
58 ""
59 };
60
61 format!(
62 "<!DOCTYPE html>\n<html>\n <head>\n <meta charset=\"UTF-8\">{style_block}\n </head>\n <body>\n{table}\n </body>\n</html>\n"
63 )
64}
65
66const TABLE_STYLE: &str =
67 " style=\"border-collapse: collapse; width: 100%; font-family: sans-serif;\"";
68const TH_STYLE: &str = " style=\"border: 1px solid #ddd; padding: 8px; text-align: left; background-color: #4a4a4a; color: white;\"";
69const TD_STYLE: &str = " style=\"border: 1px solid #ddd; padding: 8px; text-align: left;\"";
70
71fn render_array_of_objects(arr: &[Value], styled: bool) -> String {
73 let headers = collect_keys(arr);
74 if headers.is_empty() {
75 return String::new();
76 }
77
78 let table_attr = if styled { TABLE_STYLE } else { "" };
79 let th_attr = if styled { TH_STYLE } else { "" };
80 let td_attr = if styled { TD_STYLE } else { "" };
81
82 let mut lines = Vec::new();
83 lines.push(format!(" <table{table_attr}>"));
84 lines.push(" <thead>".to_string());
85 lines.push(" <tr>".to_string());
86 for h in &headers {
87 lines.push(format!(" <th{th_attr}>{}</th>", escape_html(h)));
88 }
89 lines.push(" </tr>".to_string());
90 lines.push(" </thead>".to_string());
91 lines.push(" <tbody>".to_string());
92
93 for item in arr {
94 if let Value::Object(obj) = item {
95 lines.push(" <tr>".to_string());
96 for key in &headers {
97 let cell = match obj.get(key) {
98 Some(v) => escape_html(&format_cell_value(v)),
99 None => String::new(),
100 };
101 lines.push(format!(" <td{td_attr}>{cell}</td>"));
102 }
103 lines.push(" </tr>".to_string());
104 }
105 }
106
107 lines.push(" </tbody>".to_string());
108 lines.push(" </table>".to_string());
109 lines.join("\n") + "\n"
110}
111
112fn render_array_of_primitives(arr: &[Value], styled: bool) -> String {
114 let table_attr = if styled { TABLE_STYLE } else { "" };
115 let th_attr = if styled { TH_STYLE } else { "" };
116 let td_attr = if styled { TD_STYLE } else { "" };
117
118 let mut lines = Vec::new();
119 lines.push(format!(" <table{table_attr}>"));
120 lines.push(" <thead>".to_string());
121 lines.push(format!(" <tr><th{th_attr}>value</th></tr>"));
122 lines.push(" </thead>".to_string());
123 lines.push(" <tbody>".to_string());
124
125 for item in arr {
126 let cell = escape_html(&format_cell_value(item));
127 lines.push(format!(" <tr><td{td_attr}>{cell}</td></tr>"));
128 }
129
130 lines.push(" </tbody>".to_string());
131 lines.push(" </table>".to_string());
132 lines.join("\n") + "\n"
133}
134
135fn render_single_object(value: &Value, styled: bool) -> String {
137 let table_attr = if styled { TABLE_STYLE } else { "" };
138 let th_attr = if styled { TH_STYLE } else { "" };
139 let td_attr = if styled { TD_STYLE } else { "" };
140
141 let mut lines = Vec::new();
142 lines.push(format!(" <table{table_attr}>"));
143 lines.push(" <thead>".to_string());
144 lines.push(format!(
145 " <tr><th{th_attr}>key</th><th{th_attr}>value</th></tr>"
146 ));
147 lines.push(" </thead>".to_string());
148 lines.push(" <tbody>".to_string());
149
150 if let Value::Object(obj) = value {
151 for (k, v) in obj {
152 lines.push(format!(
153 " <tr><td{td_attr}>{}</td><td{td_attr}>{}</td></tr>",
154 escape_html(k),
155 escape_html(&format_cell_value(v))
156 ));
157 }
158 }
159
160 lines.push(" </tbody>".to_string());
161 lines.push(" </table>".to_string());
162 lines.join("\n") + "\n"
163}
164
165fn collect_keys(arr: &[Value]) -> Vec<String> {
167 let mut keys: Vec<String> = Vec::new();
168 for item in arr {
169 if let Value::Object(obj) = item {
170 for k in obj.keys() {
171 if !keys.contains(k) {
172 keys.push(k.clone());
173 }
174 }
175 }
176 }
177 keys
178}
179
180fn format_cell_value(v: &Value) -> String {
182 match v {
183 Value::Null => "null".to_string(),
184 Value::Bool(b) => b.to_string(),
185 Value::Integer(n) => n.to_string(),
186 Value::Float(f) => f.to_string(),
187 Value::String(s) => s.clone(),
188 Value::Array(_) | Value::Object(_) => {
189 serde_json::to_string(&value_to_json(v)).unwrap_or_else(|_| "{...}".to_string())
190 }
191 }
192}
193
194fn value_to_json(v: &Value) -> serde_json::Value {
196 match v {
197 Value::Null => serde_json::Value::Null,
198 Value::Bool(b) => serde_json::Value::Bool(*b),
199 Value::Integer(n) => serde_json::json!(n),
200 Value::Float(f) => serde_json::json!(f),
201 Value::String(s) => serde_json::Value::String(s.clone()),
202 Value::Array(arr) => serde_json::Value::Array(arr.iter().map(value_to_json).collect()),
203 Value::Object(obj) => {
204 let map: serde_json::Map<String, serde_json::Value> = obj
205 .iter()
206 .map(|(k, v)| (k.clone(), value_to_json(v)))
207 .collect();
208 serde_json::Value::Object(map)
209 }
210 }
211}
212
213fn escape_html(s: &str) -> String {
215 s.replace('&', "&")
216 .replace('<', "<")
217 .replace('>', ">")
218 .replace('"', """)
219 .replace('\'', "'")
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225 use crate::format::FormatWriter;
226 use indexmap::IndexMap;
227
228 fn make_user(name: &str, age: i64) -> Value {
229 let mut m = IndexMap::new();
230 m.insert("name".to_string(), Value::String(name.to_string()));
231 m.insert("age".to_string(), Value::Integer(age));
232 Value::Object(m)
233 }
234
235 #[test]
236 fn test_array_of_objects_basic() {
237 let data = Value::Array(vec![make_user("Alice", 30), make_user("Bob", 25)]);
238 let output = HtmlWriter::new(false, false).write(&data).unwrap();
239 assert!(output.contains("<table>"));
240 assert!(output.contains("<thead>"));
241 assert!(output.contains("<tbody>"));
242 assert!(output.contains("<th>name</th>"));
243 assert!(output.contains("<th>age</th>"));
244 assert!(output.contains("<td>Alice</td>"));
245 assert!(output.contains("<td>30</td>"));
246 assert!(output.contains("<td>Bob</td>"));
247 assert!(output.contains("<td>25</td>"));
248 }
249
250 #[test]
251 fn test_styled_output() {
252 let data = Value::Array(vec![make_user("Alice", 30)]);
253 let output = HtmlWriter::new(true, false).write(&data).unwrap();
254 assert!(output.contains("style="));
255 assert!(output.contains("border-collapse"));
256 }
257
258 #[test]
259 fn test_full_html_document() {
260 let data = Value::Array(vec![make_user("Alice", 30)]);
261 let output = HtmlWriter::new(false, true).write(&data).unwrap();
262 assert!(output.contains("<!DOCTYPE html>"));
263 assert!(output.contains("<html>"));
264 assert!(output.contains("<head>"));
265 assert!(output.contains("<meta charset=\"UTF-8\">"));
266 assert!(output.contains("<body>"));
267 assert!(output.contains("</html>"));
268 assert!(!output.contains("<style>"));
270 }
271
272 #[test]
273 fn test_full_html_styled() {
274 let data = Value::Array(vec![make_user("Alice", 30)]);
275 let output = HtmlWriter::new(true, true).write(&data).unwrap();
276 assert!(output.contains("<!DOCTYPE html>"));
277 assert!(output.contains("<style>"));
278 assert!(output.contains("border-collapse"));
279 }
280
281 #[test]
282 fn test_html_entity_escape() {
283 let mut m = IndexMap::new();
284 m.insert(
285 "formula".to_string(),
286 Value::String("<script>alert('xss')</script>".to_string()),
287 );
288 let data = Value::Array(vec![Value::Object(m)]);
289 let output = HtmlWriter::new(false, false).write(&data).unwrap();
290 assert!(output.contains("<script>alert('xss')</script>"));
291 assert!(!output.contains("<script>"));
292 }
293
294 #[test]
295 fn test_ampersand_escape() {
296 let mut m = IndexMap::new();
297 m.insert("text".to_string(), Value::String("A & B".to_string()));
298 let data = Value::Array(vec![Value::Object(m)]);
299 let output = HtmlWriter::new(false, false).write(&data).unwrap();
300 assert!(output.contains("A & B"));
301 }
302
303 #[test]
304 fn test_quote_escape() {
305 let mut m = IndexMap::new();
306 m.insert(
307 "attr".to_string(),
308 Value::String("say \"hello\"".to_string()),
309 );
310 let data = Value::Array(vec![Value::Object(m)]);
311 let output = HtmlWriter::new(false, false).write(&data).unwrap();
312 assert!(output.contains("say "hello""));
313 }
314
315 #[test]
316 fn test_single_object() {
317 let mut m = IndexMap::new();
318 m.insert("host".to_string(), Value::String("localhost".to_string()));
319 m.insert("port".to_string(), Value::Integer(8080));
320 let data = Value::Object(m);
321 let output = HtmlWriter::new(false, false).write(&data).unwrap();
322 assert!(output.contains("<th>key</th>"));
323 assert!(output.contains("<th>value</th>"));
324 assert!(output.contains("<td>host</td>"));
325 assert!(output.contains("<td>localhost</td>"));
326 assert!(output.contains("<td>port</td>"));
327 assert!(output.contains("<td>8080</td>"));
328 }
329
330 #[test]
331 fn test_array_of_primitives() {
332 let data = Value::Array(vec![
333 Value::Integer(1),
334 Value::Integer(2),
335 Value::Integer(3),
336 ]);
337 let output = HtmlWriter::new(false, false).write(&data).unwrap();
338 assert!(output.contains("<th>value</th>"));
339 assert!(output.contains("<td>1</td>"));
340 assert!(output.contains("<td>2</td>"));
341 assert!(output.contains("<td>3</td>"));
342 }
343
344 #[test]
345 fn test_primitive_value() {
346 let data = Value::String("hello".to_string());
347 let output = HtmlWriter::new(false, false).write(&data).unwrap();
348 assert_eq!(output, "hello");
349 }
350
351 #[test]
352 fn test_null_value_in_cell() {
353 let mut m = IndexMap::new();
354 m.insert("name".to_string(), Value::String("Alice".to_string()));
355 m.insert("email".to_string(), Value::Null);
356 let data = Value::Array(vec![Value::Object(m)]);
357 let output = HtmlWriter::new(false, false).write(&data).unwrap();
358 assert!(output.contains("<td>null</td>"));
359 }
360
361 #[test]
362 fn test_nested_value_json_inline() {
363 let mut m = IndexMap::new();
364 m.insert(
365 "tags".to_string(),
366 Value::Array(vec![
367 Value::String("a".to_string()),
368 Value::String("b".to_string()),
369 ]),
370 );
371 let data = Value::Array(vec![Value::Object(m)]);
372 let output = HtmlWriter::new(false, false).write(&data).unwrap();
373 assert!(output.contains("["a","b"]"));
374 }
375
376 #[test]
377 fn test_missing_fields() {
378 let mut m1 = IndexMap::new();
379 m1.insert("a".to_string(), Value::Integer(1));
380 m1.insert("b".to_string(), Value::Integer(2));
381 let mut m2 = IndexMap::new();
382 m2.insert("a".to_string(), Value::Integer(3));
383 let data = Value::Array(vec![Value::Object(m1), Value::Object(m2)]);
384 let output = HtmlWriter::new(false, false).write(&data).unwrap();
385 assert!(output.contains("<th>a</th>"));
386 assert!(output.contains("<th>b</th>"));
387 assert!(output.contains("<td></td>"));
389 }
390
391 #[test]
392 fn test_empty_array() {
393 let data = Value::Array(vec![]);
394 let output = HtmlWriter::new(false, false).write(&data).unwrap();
395 assert!(output.contains("<th>value</th>"));
396 }
397
398 #[test]
399 fn test_write_to_writer() {
400 let data = Value::Array(vec![make_user("Alice", 30)]);
401 let mut buf = Vec::new();
402 HtmlWriter::new(false, false)
403 .write_to_writer(&data, &mut buf)
404 .unwrap();
405 let output = String::from_utf8(buf).unwrap();
406 assert!(output.contains("<table>"));
407 assert!(output.contains("Alice"));
408 }
409}