1pub struct CsvTableOptions {
9 pub separator: char,
11 pub has_header: bool,
13 pub caption: Option<String>,
15 pub class: String,
17 pub data_type: Option<String>,
20}
21
22pub fn render(content: &str, options: &CsvTableOptions) -> String {
25 let rows = parse_rows(content, options.separator);
26 build_table(&rows, options)
27}
28
29fn parse_rows(content: &str, sep: char) -> Vec<Vec<String>> {
30 let mut rows: Vec<Vec<String>> = Vec::new();
34 let mut row: Vec<String> = Vec::new();
35 let mut field = String::new();
36 let mut in_quotes = false;
37 let mut chars = content.chars().peekable();
38
39 while let Some(c) = chars.next() {
40 match (c, in_quotes) {
41 ('"', true) if chars.peek() == Some(&'"') => {
42 chars.next();
43 field.push('"');
44 }
45 ('"', true) => in_quotes = false,
46 ('"', false) => in_quotes = true,
47 (c, true) => field.push(c),
48 (c, false) if c == sep => {
49 row.push(std::mem::take(&mut field));
50 }
51 ('\n', false) => {
52 row.push(std::mem::take(&mut field));
53 rows.push(std::mem::take(&mut row));
54 }
55 ('\r', false) => { }
56 (c, false) => field.push(c),
57 }
58 }
59 if !field.is_empty() || !row.is_empty() {
60 row.push(field);
61 rows.push(row);
62 }
63 rows
64}
65
66fn build_table(rows: &[Vec<String>], opts: &CsvTableOptions) -> String {
67 let data_type_attr = opts.data_type.as_deref()
68 .map(|t| format!(" data-type=\"{}\"", t))
69 .unwrap_or_default();
70 if rows.is_empty() {
71 return format!("<table class=\"{}\"{}></table>", opts.class, data_type_attr);
72 }
73 let mut out = String::new();
74 out.push_str(&format!("<table class=\"{}\"{}>", opts.class, data_type_attr));
75 if let Some(cap) = &opts.caption {
76 out.push_str(&format!("<caption>{}</caption>", escape(cap)));
77 }
78 let (header, body) = if opts.has_header {
79 (Some(&rows[0]), &rows[1..])
80 } else {
81 (None, rows)
82 };
83 if let Some(h) = header {
84 out.push_str("<thead><tr>");
85 for cell in h {
86 out.push_str(&format!("<th>{}</th>", escape(cell)));
87 }
88 out.push_str("</tr></thead>");
89 }
90 out.push_str("<tbody>");
91 for row in body {
92 out.push_str("<tr>");
93 for cell in row {
94 out.push_str(&format!("<td>{}</td>", escape(cell)));
95 }
96 out.push_str("</tr>");
97 }
98 out.push_str("</tbody></table>");
99 out
100}
101
102fn escape(s: &str) -> String {
103 s.replace('&', "&")
104 .replace('<', "<")
105 .replace('>', ">")
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 fn opts_default() -> CsvTableOptions {
113 CsvTableOptions {
114 separator: ',',
115 has_header: true,
116 caption: None,
117 class: "moss-embed".to_string(),
118 data_type: Some("table".to_string()),
119 }
120 }
121
122 #[test]
123 fn test_csv_basic() {
124 let csv = "name,age\nAlice,30\nBob,25\n";
125 let out = render(csv, &opts_default());
126 assert!(out.contains("<thead><tr><th>name</th><th>age</th></tr></thead>"));
127 assert!(out.contains("<td>Alice</td><td>30</td>"));
128 assert!(out.contains("<td>Bob</td><td>25</td>"));
129 assert!(out.contains("class=\"moss-embed\" data-type=\"table\""));
130 }
131
132 #[test]
133 fn test_csv_no_header() {
134 let csv = "a,1\nb,2\n";
135 let opts = CsvTableOptions {
136 has_header: false,
137 ..opts_default()
138 };
139 let out = render(csv, &opts);
140 assert!(!out.contains("<thead>"), "got: {}", out);
141 assert!(out.contains("<td>a</td>"));
142 }
143
144 #[test]
145 fn test_csv_quoted_field_with_comma() {
146 let csv = "name,note\n\"Smith, J\",\"hi, world\"\n";
147 let out = render(csv, &opts_default());
148 assert!(out.contains("<td>Smith, J</td>"), "got: {}", out);
149 assert!(out.contains("<td>hi, world</td>"), "got: {}", out);
150 }
151
152 #[test]
153 fn test_csv_escaped_quote() {
154 let csv = "name\n\"he said \"\"hi\"\"\"\n";
155 let out = render(csv, &opts_default());
156 assert!(out.contains("<td>he said \"hi\"</td>"), "got: {}", out);
157 }
158
159 #[test]
160 fn test_csv_html_escape() {
161 let csv = "html\n<script>alert(1)</script>\n";
162 let out = render(csv, &opts_default());
163 assert!(!out.contains("<script>"), "raw script leaked: {}", out);
164 assert!(out.contains("<script>"), "got: {}", out);
165 }
166
167 #[test]
168 fn test_tsv_tab_separator() {
169 let tsv = "a\tb\n1\t2\n";
170 let opts = CsvTableOptions {
171 separator: '\t',
172 ..opts_default()
173 };
174 let out = render(tsv, &opts);
175 assert!(out.contains("<th>a</th>"));
176 assert!(out.contains("<th>b</th>"));
177 assert!(out.contains("<td>1</td><td>2</td>"));
178 }
179
180 #[test]
181 fn test_csv_with_caption() {
182 let csv = "a\n1\n";
183 let opts = CsvTableOptions {
184 caption: Some("My Data".to_string()),
185 ..opts_default()
186 };
187 let out = render(csv, &opts);
188 assert!(out.contains("<caption>My Data</caption>"), "got: {}", out);
189 }
190
191 #[test]
192 fn test_csv_empty() {
193 let out = render("", &opts_default());
194 assert!(out.starts_with("<table"), "got: {}", out);
195 assert!(out.ends_with("</table>"), "got: {}", out);
196 }
197
198 #[test]
199 fn test_csv_crlf_line_endings() {
200 let csv = "a,b\r\n1,2\r\n";
201 let out = render(csv, &opts_default());
202 assert!(out.contains("<th>a</th>"), "got: {}", out);
203 assert!(out.contains("<th>b</th>"), "got: {}", out);
204 assert!(out.contains("<td>1</td><td>2</td>"), "got: {}", out);
205 }
206}