Skip to main content

moss_core/
csv_table.rs

1//! Pure CSV/TSV → HTML table renderer.
2//!
3//! No I/O: caller supplies the file content as a string. Called from
4//! src-tauri's Deferred-marker resolver for `![[data.csv]]` embeds via
5//! [`TableRenderer`](crate::resolve::embed_renderer::TableRenderer).
6
7/// Options controlling how a CSV/TSV payload is rendered as an HTML table.
8pub struct CsvTableOptions {
9    /// Column separator: `,` for CSV, `\t` for TSV.
10    pub separator: char,
11    /// Treat the first row as `<th>` headers.
12    pub has_header: bool,
13    /// Optional caption rendered inside a `<caption>` element.
14    pub caption: Option<String>,
15    /// CSS class applied to the wrapping `<table>` element.
16    pub class: String,
17    /// Optional `data-type` attribute applied to the wrapping `<table>` element
18    /// (v1 vocabulary: `.moss-embed[data-type="table"]`).
19    pub data_type: Option<String>,
20}
21
22/// Render a CSV/TSV string as HTML. Caller supplies options; content is
23/// HTML-escaped before emission.
24pub 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    // Minimal CSV parser: handles quoted fields with embedded commas, newlines,
31    // and escaped quotes (`""` → `"`). Not a full RFC 4180 parser but correct
32    // for well-formed authored data. Upgrade to the `csv` crate if needed.
33    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) => { /* swallow; CRLF handled by \n branch */ }
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('&', "&amp;")
104        .replace('<', "&lt;")
105        .replace('>', "&gt;")
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("&lt;script&gt;"), "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}