Skip to main content

ferrule_core/
formatter.rs

1use ferrule_sql::value::Value;
2use ferrule_sql::{QueryResult, SqlError};
3use std::borrow::Cow;
4use tabled::builder::Builder;
5use tabled::settings::Style;
6
7/// Supported output formats.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum OutputFormat {
10    Table,
11    Json,
12    Csv,
13    Yaml,
14    Raw,
15    Markdown,
16    Jsonl,
17    Html,
18}
19
20impl OutputFormat {
21    /// Parse from a string argument.
22    pub fn parse(s: &str) -> Option<Self> {
23        match s.to_ascii_lowercase().as_str() {
24            "table" => Some(Self::Table),
25            "json" => Some(Self::Json),
26            "csv" => Some(Self::Csv),
27            "yaml" => Some(Self::Yaml),
28            "raw" => Some(Self::Raw),
29            "markdown" | "md" => Some(Self::Markdown),
30            "jsonl" | "ndjson" => Some(Self::Jsonl),
31            "html" => Some(Self::Html),
32            _ => None,
33        }
34    }
35}
36
37/// Optional formatter configuration. Reserved for future extensions
38/// such as a custom NULL marker (issue #52); v1 callers can pass
39/// [`FormatOptions::default()`] via [`format_result`] without losing
40/// functionality.
41#[derive(Debug, Clone, Default)]
42pub struct FormatOptions {
43    /// Override for NULL rendering. v1 honours this in future-extensible
44    /// formats only — markdown/HTML render empty cells; JSONL always
45    /// emits JSON null (changing it would produce invalid JSON).
46    pub null_marker: Option<String>,
47}
48
49/// Render a `QueryResult` into the requested output format using default options.
50#[must_use = "the formatted output is the function's only product"]
51pub fn format_result(result: &QueryResult, format: OutputFormat) -> Result<String, SqlError> {
52    format_result_with(result, format, &FormatOptions::default())
53}
54
55/// Render a `QueryResult` into the requested output format with explicit
56/// [`FormatOptions`]. Entry point for callers that need to override
57/// formatter behaviour (e.g. custom NULL marker — issue #52).
58#[must_use = "the formatted output is the function's only product"]
59pub fn format_result_with(
60    result: &QueryResult,
61    format: OutputFormat,
62    _opts: &FormatOptions,
63) -> Result<String, SqlError> {
64    match format {
65        OutputFormat::Table => format_table(result),
66        OutputFormat::Json => format_json(result),
67        OutputFormat::Csv => format_csv(result),
68        OutputFormat::Yaml => format_yaml(result),
69        OutputFormat::Raw => format_raw(result),
70        OutputFormat::Markdown => format_markdown(result),
71        OutputFormat::Jsonl => format_jsonl(result),
72        OutputFormat::Html => format_html(result),
73    }
74}
75
76fn format_table(result: &QueryResult) -> Result<String, SqlError> {
77    let mut builder = Builder::default();
78    let headers: Vec<String> = result.columns.iter().map(|c| c.name.clone()).collect();
79    builder.push_record(headers);
80    for row in &result.rows {
81        let cells: Vec<String> = row.iter().map(cell_string).collect();
82        builder.push_record(cells);
83    }
84    let mut table = builder.build();
85    table.with(Style::modern());
86    Ok(table.to_string())
87}
88
89fn format_json(result: &QueryResult) -> Result<String, SqlError> {
90    let mut out = Vec::with_capacity(result.rows.len());
91    for row in &result.rows {
92        let mut obj = serde_json::Map::new();
93        for (col, val) in result.columns.iter().zip(row.iter()) {
94            obj.insert(col.name.clone(), json_value(val));
95        }
96        out.push(serde_json::Value::Object(obj));
97    }
98    serde_json::to_string_pretty(&out).map_err(|e| SqlError::QueryFailed(e.to_string()))
99}
100
101fn format_csv(result: &QueryResult) -> Result<String, SqlError> {
102    let mut wtr = csv::Writer::from_writer(Vec::new());
103    let headers: Vec<&str> = result.columns.iter().map(|c| c.name.as_str()).collect();
104    wtr.write_record(&headers)
105        .map_err(|e| SqlError::QueryFailed(e.to_string()))?;
106    for row in &result.rows {
107        let cells: Vec<String> = row.iter().map(cell_string).collect();
108        wtr.write_record(&cells)
109            .map_err(|e| SqlError::QueryFailed(e.to_string()))?;
110    }
111    wtr.into_inner()
112        .map(|v| String::from_utf8_lossy(&v).into_owned())
113        .map_err(|e| SqlError::QueryFailed(e.to_string()))
114}
115
116fn format_yaml(result: &QueryResult) -> Result<String, SqlError> {
117    let mut out = Vec::with_capacity(result.rows.len());
118    for row in &result.rows {
119        let mut obj = serde_json::Map::new();
120        for (col, val) in result.columns.iter().zip(row.iter()) {
121            obj.insert(col.name.clone(), json_value(val));
122        }
123        out.push(serde_json::Value::Object(obj));
124    }
125    serde_saphyr::to_string(&out).map_err(|e| SqlError::QueryFailed(e.to_string()))
126}
127
128fn format_raw(result: &QueryResult) -> Result<String, SqlError> {
129    let mut lines = Vec::new();
130    for row in &result.rows {
131        let cells: Vec<String> = row.iter().map(cell_string).collect();
132        lines.push(cells.join("\t"));
133    }
134    Ok(lines.join("\n"))
135}
136
137fn cell_string(v: &Value) -> String {
138    match v {
139        Value::Null => "NULL".to_string(),
140        Value::String(s) => s.clone(),
141        other => other.to_string(),
142    }
143}
144
145fn json_value(v: &Value) -> serde_json::Value {
146    match v {
147        Value::Null => serde_json::Value::Null,
148        Value::Bool(b) => serde_json::Value::Bool(*b),
149        Value::Int64(i) => serde_json::Value::Number((*i).into()),
150        Value::Float64(f) => serde_json::Value::Number(
151            serde_json::Number::from_f64(*f).unwrap_or_else(|| serde_json::Number::from(0)),
152        ),
153        Value::Decimal(d) => serde_json::Value::String(d.clone()),
154        Value::String(s) => serde_json::Value::String(s.clone()),
155        Value::Bytes(b) => serde_json::Value::String(format!("<{} bytes>", b.len())),
156        Value::Date(d) => serde_json::Value::String(d.to_string()),
157        Value::Time(t) => serde_json::Value::String(t.to_string()),
158        Value::DateTime(dt) => serde_json::Value::String(dt.to_string()),
159        Value::DateTimeTz(dt) => serde_json::Value::String(dt.to_rfc3339()),
160        Value::Json(j) => j.clone(),
161        Value::Uuid(u) => serde_json::Value::String(u.clone()),
162        Value::Array(a) => serde_json::Value::Array(a.iter().map(json_value).collect()),
163    }
164}
165
166fn format_markdown(result: &QueryResult) -> Result<String, SqlError> {
167    if result.columns.is_empty() {
168        return Ok("(no columns)\n".into());
169    }
170    let mut out = String::new();
171    // Header row.
172    out.push_str("| ");
173    let headers: Vec<String> = result
174        .columns
175        .iter()
176        .map(|c| escape_md_cell(&c.name))
177        .collect();
178    out.push_str(&headers.join(" | "));
179    out.push_str(" |\n");
180    // Separator row.
181    out.push_str("| ");
182    let seps: Vec<&str> = result.columns.iter().map(|_| "---").collect();
183    out.push_str(&seps.join(" | "));
184    out.push_str(" |\n");
185    // Data rows.
186    for row in &result.rows {
187        out.push_str("| ");
188        let cells: Vec<String> = row
189            .iter()
190            .map(|v| {
191                if matches!(v, Value::Null) {
192                    String::new()
193                } else {
194                    escape_md_cell(&cell_string(v))
195                }
196            })
197            .collect();
198        out.push_str(&cells.join(" | "));
199        out.push_str(" |\n");
200    }
201    Ok(out)
202}
203
204fn escape_md_cell(s: &str) -> String {
205    // 1. Escape pipes (must come before newline → <br> so we don't accidentally
206    //    escape pipes introduced by <br> — there aren't any, but the order keeps
207    //    the transformation easy to reason about).
208    let mut out = s.replace('|', "\\|");
209    // 2. Newlines → <br>. Replace \r\n BEFORE \n so we don't leave a stray \r.
210    out = out.replace("\r\n", "<br>").replace('\n', "<br>");
211    // 3. Leading / trailing space runs → &nbsp; per space (preserve whitespace
212    //    that GFM would otherwise collapse).
213    let trimmed = out.trim_matches(' ');
214    let leading = out.len() - out.trim_start_matches(' ').len();
215    let trailing = out.len() - out.trim_end_matches(' ').len();
216    let mut padded = String::with_capacity(out.len() + (leading + trailing) * 5);
217    for _ in 0..leading {
218        padded.push_str("&nbsp;");
219    }
220    padded.push_str(trimmed);
221    for _ in 0..trailing {
222        padded.push_str("&nbsp;");
223    }
224    padded
225}
226
227fn format_jsonl(result: &QueryResult) -> Result<String, SqlError> {
228    if result.rows.is_empty() {
229        return Ok(String::new());
230    }
231    let mut out = String::new();
232    for row in &result.rows {
233        let mut obj = serde_json::Map::new();
234        for (col, val) in result.columns.iter().zip(row.iter()) {
235            obj.insert(col.name.clone(), json_value(val));
236        }
237        let line = serde_json::to_string(&serde_json::Value::Object(obj))
238            .map_err(|e| SqlError::QueryFailed(e.to_string()))?;
239        out.push_str(&line);
240        out.push('\n');
241    }
242    Ok(out)
243}
244
245fn format_html(result: &QueryResult) -> Result<String, SqlError> {
246    let mut out = String::new();
247    out.push_str("<table>\n<thead>\n<tr>\n");
248    for col in &result.columns {
249        out.push_str("<th>");
250        out.push_str(&html_escape(&col.name));
251        out.push_str("</th>\n");
252    }
253    out.push_str("</tr>\n</thead>\n<tbody>\n");
254    for row in &result.rows {
255        out.push_str("<tr>\n");
256        for v in row {
257            if matches!(v, Value::Null) {
258                out.push_str("<td></td>\n");
259            } else {
260                out.push_str("<td>");
261                out.push_str(&html_escape(&cell_string(v)));
262                out.push_str("</td>\n");
263            }
264        }
265        out.push_str("</tr>\n");
266    }
267    out.push_str("</tbody>\n</table>\n");
268    Ok(out)
269}
270
271fn html_escape(s: &str) -> Cow<'_, str> {
272    if !s
273        .bytes()
274        .any(|b| matches!(b, b'&' | b'<' | b'>' | b'"' | b'\''))
275    {
276        return Cow::Borrowed(s);
277    }
278    let mut out = String::with_capacity(s.len() + 8);
279    for c in s.chars() {
280        match c {
281            '&' => out.push_str("&amp;"),
282            '<' => out.push_str("&lt;"),
283            '>' => out.push_str("&gt;"),
284            '"' => out.push_str("&quot;"),
285            '\'' => out.push_str("&#39;"),
286            other => out.push(other),
287        }
288    }
289    Cow::Owned(out)
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use ferrule_sql::value::{ColumnInfo, TypeHint, Value};
296
297    fn col(name: &str) -> ColumnInfo {
298        ColumnInfo {
299            name: name.into(),
300            type_hint: TypeHint::String,
301            nullable: true,
302        }
303    }
304
305    fn qr(cols: Vec<&str>, rows: Vec<Vec<Value>>) -> QueryResult {
306        QueryResult {
307            columns: cols.into_iter().map(col).collect(),
308            rows,
309        }
310    }
311
312    #[test]
313    fn parse_markdown_and_aliases() {
314        assert_eq!(
315            OutputFormat::parse("markdown"),
316            Some(OutputFormat::Markdown)
317        );
318        assert_eq!(OutputFormat::parse("md"), Some(OutputFormat::Markdown));
319        assert_eq!(OutputFormat::parse("MD"), Some(OutputFormat::Markdown));
320        assert_eq!(OutputFormat::parse("jsonl"), Some(OutputFormat::Jsonl));
321        assert_eq!(OutputFormat::parse("ndjson"), Some(OutputFormat::Jsonl));
322        assert_eq!(OutputFormat::parse("JSONL"), Some(OutputFormat::Jsonl));
323        assert_eq!(OutputFormat::parse("html"), Some(OutputFormat::Html));
324        assert_eq!(OutputFormat::parse("HTML"), Some(OutputFormat::Html));
325    }
326
327    #[test]
328    fn parse_unknown_still_none() {
329        assert_eq!(OutputFormat::parse("xml"), None);
330    }
331
332    #[test]
333    fn markdown_happy_path() {
334        let result = qr(
335            vec!["id", "name"],
336            vec![
337                vec![Value::Int64(1), Value::String("alice".into())],
338                vec![Value::Int64(2), Value::String("bob".into())],
339                vec![Value::Int64(3), Value::String("carol".into())],
340            ],
341        );
342        let out = format_result(&result, OutputFormat::Markdown).unwrap();
343        assert_eq!(
344            out,
345            "| id | name |\n| --- | --- |\n| 1 | alice |\n| 2 | bob |\n| 3 | carol |\n",
346        );
347    }
348
349    #[test]
350    fn markdown_escapes_pipe_and_newline() {
351        let result = qr(vec!["c"], vec![vec![Value::String("a|b\nc".into())]]);
352        let out = format_result(&result, OutputFormat::Markdown).unwrap();
353        assert!(out.contains("a\\|b<br>c"));
354        // Also verify \r\n becomes a single <br> (not <br>\r or \r<br>).
355        let result_crlf = qr(vec!["c"], vec![vec![Value::String("a\r\nb".into())]]);
356        let out_crlf = format_result(&result_crlf, OutputFormat::Markdown).unwrap();
357        assert!(out_crlf.contains("a<br>b"));
358        assert!(!out_crlf.contains('\r'));
359    }
360
361    #[test]
362    fn markdown_empty_columns_emits_note() {
363        let result = qr(vec![], vec![]);
364        let out = format_result(&result, OutputFormat::Markdown).unwrap();
365        assert_eq!(out, "(no columns)\n");
366    }
367
368    #[test]
369    fn markdown_zero_rows_emits_header_only() {
370        let result = qr(vec!["a", "b"], vec![]);
371        let out = format_result(&result, OutputFormat::Markdown).unwrap();
372        assert_eq!(out, "| a | b |\n| --- | --- |\n");
373    }
374
375    #[test]
376    fn jsonl_each_line_parses_independently() {
377        let result = qr(
378            vec!["id", "name"],
379            vec![
380                vec![Value::Int64(1), Value::String("alice".into())],
381                vec![Value::Int64(2), Value::Null],
382                vec![Value::Int64(3), Value::String("carol".into())],
383            ],
384        );
385        let out = format_result(&result, OutputFormat::Jsonl).unwrap();
386        // Exactly N newlines for N rows.
387        assert_eq!(out.matches('\n').count(), 3);
388        // Each line is valid JSON.
389        for line in out.lines() {
390            let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
391            assert!(parsed.is_object());
392        }
393        // NULL → JSON null.
394        assert!(out.contains("\"name\":null"));
395    }
396
397    #[test]
398    fn jsonl_zero_rows_returns_empty_string() {
399        let result = qr(vec!["id"], vec![]);
400        let out = format_result(&result, OutputFormat::Jsonl).unwrap();
401        assert_eq!(out, "");
402    }
403
404    #[test]
405    fn html_escapes_owasp_set() {
406        let result = qr(
407            vec!["c"],
408            vec![vec![Value::String(
409                "<script>alert('Tom & Jerry')</script>".into(),
410            )]],
411        );
412        let out = format_result(&result, OutputFormat::Html).unwrap();
413        assert!(!out.contains("<script>"));
414        assert!(out.contains("&lt;script&gt;"));
415        assert!(out.contains("&amp;"));
416        assert!(out.contains("&#39;"));
417    }
418
419    #[test]
420    fn html_table_shape() {
421        let result = qr(
422            vec!["id", "name"],
423            vec![
424                vec![Value::Int64(1), Value::String("alice".into())],
425                vec![Value::Int64(2), Value::Null],
426            ],
427        );
428        let out = format_result(&result, OutputFormat::Html).unwrap();
429        assert_eq!(out.matches("<table>").count(), 1);
430        assert_eq!(out.matches("<thead>").count(), 1);
431        assert_eq!(out.matches("<tbody>").count(), 1);
432        // NULL → empty <td></td>.
433        assert!(out.contains("<td></td>"));
434        // Final \n after </table>.
435        assert!(out.ends_with("</table>\n"));
436    }
437}