use ferrule_sql::value::Value;
use ferrule_sql::{QueryResult, SqlError};
use std::borrow::Cow;
use tabled::builder::Builder;
use tabled::settings::Style;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Table,
Json,
Csv,
Yaml,
Raw,
Markdown,
Jsonl,
Html,
}
impl OutputFormat {
pub fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"table" => Some(Self::Table),
"json" => Some(Self::Json),
"csv" => Some(Self::Csv),
"yaml" => Some(Self::Yaml),
"raw" => Some(Self::Raw),
"markdown" | "md" => Some(Self::Markdown),
"jsonl" | "ndjson" => Some(Self::Jsonl),
"html" => Some(Self::Html),
_ => None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct FormatOptions {
pub null_marker: Option<String>,
}
#[must_use = "the formatted output is the function's only product"]
pub fn format_result(result: &QueryResult, format: OutputFormat) -> Result<String, SqlError> {
format_result_with(result, format, &FormatOptions::default())
}
#[must_use = "the formatted output is the function's only product"]
pub fn format_result_with(
result: &QueryResult,
format: OutputFormat,
_opts: &FormatOptions,
) -> Result<String, SqlError> {
match format {
OutputFormat::Table => format_table(result),
OutputFormat::Json => format_json(result),
OutputFormat::Csv => format_csv(result),
OutputFormat::Yaml => format_yaml(result),
OutputFormat::Raw => format_raw(result),
OutputFormat::Markdown => format_markdown(result),
OutputFormat::Jsonl => format_jsonl(result),
OutputFormat::Html => format_html(result),
}
}
fn format_table(result: &QueryResult) -> Result<String, SqlError> {
let mut builder = Builder::default();
let headers: Vec<String> = result.columns.iter().map(|c| c.name.clone()).collect();
builder.push_record(headers);
for row in &result.rows {
let cells: Vec<String> = row.iter().map(cell_string).collect();
builder.push_record(cells);
}
let mut table = builder.build();
table.with(Style::modern());
Ok(table.to_string())
}
fn format_json(result: &QueryResult) -> Result<String, SqlError> {
let mut out = Vec::with_capacity(result.rows.len());
for row in &result.rows {
let mut obj = serde_json::Map::new();
for (col, val) in result.columns.iter().zip(row.iter()) {
obj.insert(col.name.clone(), json_value(val));
}
out.push(serde_json::Value::Object(obj));
}
serde_json::to_string_pretty(&out).map_err(|e| SqlError::QueryFailed(e.to_string()))
}
fn format_csv(result: &QueryResult) -> Result<String, SqlError> {
let mut wtr = csv::Writer::from_writer(Vec::new());
let headers: Vec<&str> = result.columns.iter().map(|c| c.name.as_str()).collect();
wtr.write_record(&headers)
.map_err(|e| SqlError::QueryFailed(e.to_string()))?;
for row in &result.rows {
let cells: Vec<String> = row.iter().map(cell_string).collect();
wtr.write_record(&cells)
.map_err(|e| SqlError::QueryFailed(e.to_string()))?;
}
wtr.into_inner()
.map(|v| String::from_utf8_lossy(&v).into_owned())
.map_err(|e| SqlError::QueryFailed(e.to_string()))
}
fn format_yaml(result: &QueryResult) -> Result<String, SqlError> {
let mut out = Vec::with_capacity(result.rows.len());
for row in &result.rows {
let mut obj = serde_json::Map::new();
for (col, val) in result.columns.iter().zip(row.iter()) {
obj.insert(col.name.clone(), json_value(val));
}
out.push(serde_json::Value::Object(obj));
}
serde_saphyr::to_string(&out).map_err(|e| SqlError::QueryFailed(e.to_string()))
}
fn format_raw(result: &QueryResult) -> Result<String, SqlError> {
let mut lines = Vec::new();
for row in &result.rows {
let cells: Vec<String> = row.iter().map(cell_string).collect();
lines.push(cells.join("\t"));
}
Ok(lines.join("\n"))
}
fn cell_string(v: &Value) -> String {
match v {
Value::Null => "NULL".to_string(),
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
fn json_value(v: &Value) -> serde_json::Value {
match v {
Value::Null => serde_json::Value::Null,
Value::Bool(b) => serde_json::Value::Bool(*b),
Value::Int64(i) => serde_json::Value::Number((*i).into()),
Value::Float64(f) => serde_json::Value::Number(
serde_json::Number::from_f64(*f).unwrap_or_else(|| serde_json::Number::from(0)),
),
Value::Decimal(d) => serde_json::Value::String(d.clone()),
Value::String(s) => serde_json::Value::String(s.clone()),
Value::Bytes(b) => serde_json::Value::String(format!("<{} bytes>", b.len())),
Value::Date(d) => serde_json::Value::String(d.to_string()),
Value::Time(t) => serde_json::Value::String(t.to_string()),
Value::DateTime(dt) => serde_json::Value::String(dt.to_string()),
Value::DateTimeTz(dt) => serde_json::Value::String(dt.to_rfc3339()),
Value::Json(j) => j.clone(),
Value::Uuid(u) => serde_json::Value::String(u.clone()),
Value::Array(a) => serde_json::Value::Array(a.iter().map(json_value).collect()),
}
}
fn format_markdown(result: &QueryResult) -> Result<String, SqlError> {
if result.columns.is_empty() {
return Ok("(no columns)\n".into());
}
let mut out = String::new();
out.push_str("| ");
let headers: Vec<String> = result
.columns
.iter()
.map(|c| escape_md_cell(&c.name))
.collect();
out.push_str(&headers.join(" | "));
out.push_str(" |\n");
out.push_str("| ");
let seps: Vec<&str> = result.columns.iter().map(|_| "---").collect();
out.push_str(&seps.join(" | "));
out.push_str(" |\n");
for row in &result.rows {
out.push_str("| ");
let cells: Vec<String> = row
.iter()
.map(|v| {
if matches!(v, Value::Null) {
String::new()
} else {
escape_md_cell(&cell_string(v))
}
})
.collect();
out.push_str(&cells.join(" | "));
out.push_str(" |\n");
}
Ok(out)
}
fn escape_md_cell(s: &str) -> String {
let mut out = s.replace('|', "\\|");
out = out.replace("\r\n", "<br>").replace('\n', "<br>");
let trimmed = out.trim_matches(' ');
let leading = out.len() - out.trim_start_matches(' ').len();
let trailing = out.len() - out.trim_end_matches(' ').len();
let mut padded = String::with_capacity(out.len() + (leading + trailing) * 5);
for _ in 0..leading {
padded.push_str(" ");
}
padded.push_str(trimmed);
for _ in 0..trailing {
padded.push_str(" ");
}
padded
}
fn format_jsonl(result: &QueryResult) -> Result<String, SqlError> {
if result.rows.is_empty() {
return Ok(String::new());
}
let mut out = String::new();
for row in &result.rows {
let mut obj = serde_json::Map::new();
for (col, val) in result.columns.iter().zip(row.iter()) {
obj.insert(col.name.clone(), json_value(val));
}
let line = serde_json::to_string(&serde_json::Value::Object(obj))
.map_err(|e| SqlError::QueryFailed(e.to_string()))?;
out.push_str(&line);
out.push('\n');
}
Ok(out)
}
fn format_html(result: &QueryResult) -> Result<String, SqlError> {
let mut out = String::new();
out.push_str("<table>\n<thead>\n<tr>\n");
for col in &result.columns {
out.push_str("<th>");
out.push_str(&html_escape(&col.name));
out.push_str("</th>\n");
}
out.push_str("</tr>\n</thead>\n<tbody>\n");
for row in &result.rows {
out.push_str("<tr>\n");
for v in row {
if matches!(v, Value::Null) {
out.push_str("<td></td>\n");
} else {
out.push_str("<td>");
out.push_str(&html_escape(&cell_string(v)));
out.push_str("</td>\n");
}
}
out.push_str("</tr>\n");
}
out.push_str("</tbody>\n</table>\n");
Ok(out)
}
fn html_escape(s: &str) -> Cow<'_, str> {
if !s
.bytes()
.any(|b| matches!(b, b'&' | b'<' | b'>' | b'"' | b'\''))
{
return Cow::Borrowed(s);
}
let mut out = String::with_capacity(s.len() + 8);
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
other => out.push(other),
}
}
Cow::Owned(out)
}
#[cfg(test)]
mod tests {
use super::*;
use ferrule_sql::value::{ColumnInfo, TypeHint, Value};
fn col(name: &str) -> ColumnInfo {
ColumnInfo {
name: name.into(),
type_hint: TypeHint::String,
nullable: true,
}
}
fn qr(cols: Vec<&str>, rows: Vec<Vec<Value>>) -> QueryResult {
QueryResult {
columns: cols.into_iter().map(col).collect(),
rows,
}
}
#[test]
fn parse_markdown_and_aliases() {
assert_eq!(
OutputFormat::parse("markdown"),
Some(OutputFormat::Markdown)
);
assert_eq!(OutputFormat::parse("md"), Some(OutputFormat::Markdown));
assert_eq!(OutputFormat::parse("MD"), Some(OutputFormat::Markdown));
assert_eq!(OutputFormat::parse("jsonl"), Some(OutputFormat::Jsonl));
assert_eq!(OutputFormat::parse("ndjson"), Some(OutputFormat::Jsonl));
assert_eq!(OutputFormat::parse("JSONL"), Some(OutputFormat::Jsonl));
assert_eq!(OutputFormat::parse("html"), Some(OutputFormat::Html));
assert_eq!(OutputFormat::parse("HTML"), Some(OutputFormat::Html));
}
#[test]
fn parse_unknown_still_none() {
assert_eq!(OutputFormat::parse("xml"), None);
}
#[test]
fn markdown_happy_path() {
let result = qr(
vec!["id", "name"],
vec![
vec![Value::Int64(1), Value::String("alice".into())],
vec![Value::Int64(2), Value::String("bob".into())],
vec![Value::Int64(3), Value::String("carol".into())],
],
);
let out = format_result(&result, OutputFormat::Markdown).unwrap();
assert_eq!(
out,
"| id | name |\n| --- | --- |\n| 1 | alice |\n| 2 | bob |\n| 3 | carol |\n",
);
}
#[test]
fn markdown_escapes_pipe_and_newline() {
let result = qr(vec!["c"], vec![vec![Value::String("a|b\nc".into())]]);
let out = format_result(&result, OutputFormat::Markdown).unwrap();
assert!(out.contains("a\\|b<br>c"));
let result_crlf = qr(vec!["c"], vec![vec![Value::String("a\r\nb".into())]]);
let out_crlf = format_result(&result_crlf, OutputFormat::Markdown).unwrap();
assert!(out_crlf.contains("a<br>b"));
assert!(!out_crlf.contains('\r'));
}
#[test]
fn markdown_empty_columns_emits_note() {
let result = qr(vec![], vec![]);
let out = format_result(&result, OutputFormat::Markdown).unwrap();
assert_eq!(out, "(no columns)\n");
}
#[test]
fn markdown_zero_rows_emits_header_only() {
let result = qr(vec!["a", "b"], vec![]);
let out = format_result(&result, OutputFormat::Markdown).unwrap();
assert_eq!(out, "| a | b |\n| --- | --- |\n");
}
#[test]
fn jsonl_each_line_parses_independently() {
let result = qr(
vec!["id", "name"],
vec![
vec![Value::Int64(1), Value::String("alice".into())],
vec![Value::Int64(2), Value::Null],
vec![Value::Int64(3), Value::String("carol".into())],
],
);
let out = format_result(&result, OutputFormat::Jsonl).unwrap();
assert_eq!(out.matches('\n').count(), 3);
for line in out.lines() {
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert!(parsed.is_object());
}
assert!(out.contains("\"name\":null"));
}
#[test]
fn jsonl_zero_rows_returns_empty_string() {
let result = qr(vec!["id"], vec![]);
let out = format_result(&result, OutputFormat::Jsonl).unwrap();
assert_eq!(out, "");
}
#[test]
fn html_escapes_owasp_set() {
let result = qr(
vec!["c"],
vec![vec![Value::String(
"<script>alert('Tom & Jerry')</script>".into(),
)]],
);
let out = format_result(&result, OutputFormat::Html).unwrap();
assert!(!out.contains("<script>"));
assert!(out.contains("<script>"));
assert!(out.contains("&"));
assert!(out.contains("'"));
}
#[test]
fn html_table_shape() {
let result = qr(
vec!["id", "name"],
vec![
vec![Value::Int64(1), Value::String("alice".into())],
vec![Value::Int64(2), Value::Null],
],
);
let out = format_result(&result, OutputFormat::Html).unwrap();
assert_eq!(out.matches("<table>").count(), 1);
assert_eq!(out.matches("<thead>").count(), 1);
assert_eq!(out.matches("<tbody>").count(), 1);
assert!(out.contains("<td></td>"));
assert!(out.ends_with("</table>\n"));
}
}