1use ferrule_sql::value::Value;
2use ferrule_sql::{QueryResult, SqlError};
3use std::borrow::Cow;
4use tabled::builder::Builder;
5use tabled::settings::Style;
6
7#[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 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#[derive(Debug, Clone, Default)]
42pub struct FormatOptions {
43 pub null_marker: Option<String>,
47}
48
49#[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#[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 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 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 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 let mut out = s.replace('|', "\\|");
209 out = out.replace("\r\n", "<br>").replace('\n', "<br>");
211 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(" ");
219 }
220 padded.push_str(trimmed);
221 for _ in 0..trailing {
222 padded.push_str(" ");
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("&"),
282 '<' => out.push_str("<"),
283 '>' => out.push_str(">"),
284 '"' => out.push_str("""),
285 '\'' => out.push_str("'"),
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 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 assert_eq!(out.matches('\n').count(), 3);
388 for line in out.lines() {
390 let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
391 assert!(parsed.is_object());
392 }
393 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("<script>"));
415 assert!(out.contains("&"));
416 assert!(out.contains("'"));
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 assert!(out.contains("<td></td>"));
434 assert!(out.ends_with("</table>\n"));
436 }
437}