use crate::model::{Row, Value};
pub fn format_results(
rows: &[Row],
columns: Option<&[String]>,
output_format: &str,
truncate: usize,
) -> String {
if rows.is_empty() {
return "No results.".to_string();
}
let cols: Vec<String> = match columns {
Some(c) => c.to_vec(),
None => {
let mut seen: Vec<String> = Vec::new();
let mut set = std::collections::HashSet::new();
for r in rows {
for k in r.keys() {
if set.insert(k.clone()) {
seen.push(k.clone());
}
}
}
seen
}
};
match output_format {
"json" => format_json(rows, &cols),
"csv" => format_csv(rows, &cols),
_ => format_table(rows, &cols, truncate),
}
}
fn format_json(rows: &[Row], columns: &[String]) -> String {
let projected: Vec<serde_json::Value> = rows
.iter()
.map(|r| {
let mut obj = serde_json::Map::new();
for c in columns {
let val = r.get(c).unwrap_or(&Value::Null);
obj.insert(c.clone(), value_to_json(val));
}
serde_json::Value::Object(obj)
})
.collect();
serde_json::to_string_pretty(&projected).unwrap_or_default()
}
fn value_to_json(val: &Value) -> serde_json::Value {
match val {
Value::Null => serde_json::Value::Null,
Value::String(s) => serde_json::Value::String(s.clone()),
Value::Int(n) => serde_json::json!(*n),
Value::Float(f) => serde_json::json!(*f),
Value::Bool(b) => serde_json::Value::Bool(*b),
Value::Date(d) => serde_json::Value::String(d.format("%Y-%m-%d").to_string()),
Value::DateTime(dt) => serde_json::Value::String(dt.format("%Y-%m-%dT%H:%M:%S").to_string()),
Value::List(items) => {
serde_json::Value::Array(items.iter().map(|s| serde_json::Value::String(s.clone())).collect())
}
Value::Dict(map) => {
let obj: serde_json::Map<String, serde_json::Value> = map.iter()
.map(|(k, v)| (k.clone(), value_to_json(v)))
.collect();
serde_json::Value::Object(obj)
}
}
}
fn format_csv(rows: &[Row], columns: &[String]) -> String {
let mut out = String::new();
out.push_str(&columns.join(","));
out.push('\n');
for r in rows {
let vals: Vec<String> = columns
.iter()
.map(|c| {
let val = r.get(c).unwrap_or(&Value::Null);
csv_value(val)
})
.collect();
out.push_str(&vals.join(","));
out.push('\n');
}
out
}
fn csv_value(val: &Value) -> String {
match val {
Value::Null => String::new(),
Value::List(items) => items.join(";"),
Value::Date(d) => d.format("%Y-%m-%d").to_string(),
Value::DateTime(dt) => dt.format("%Y-%m-%dT%H:%M:%S").to_string(),
Value::Dict(map) => {
let obj: serde_json::Map<String, serde_json::Value> = map.iter()
.map(|(k, v)| (k.clone(), value_to_json(v)))
.collect();
serde_json::to_string(&serde_json::Value::Object(obj)).unwrap_or_default()
}
other => other.to_display_string(),
}
}
fn format_table(rows: &[Row], columns: &[String], truncate: usize) -> String {
let ncols = columns.len();
if ncols == 0 {
return "No results.".to_string();
}
let mut natural_widths: Vec<usize> = columns.iter().map(|c| c.chars().count()).collect();
let raw_cells: Vec<Vec<String>> = rows
.iter()
.map(|r| {
columns
.iter()
.enumerate()
.map(|(i, c)| {
let val = r.get(c).unwrap_or(&Value::Null);
let s = val.to_display_string().replace('\n', " ");
let s = s.trim().to_string();
let char_len = s.chars().count();
if char_len > natural_widths[i] {
natural_widths[i] = char_len;
}
s
})
.collect()
})
.collect();
let gap = 2; let total_gap = gap * (ncols.saturating_sub(1));
let effective_widths = if truncate > 0 {
natural_widths.iter().map(|&w| w.min(truncate)).collect::<Vec<_>>()
} else {
let term_width = terminal_width().unwrap_or(120);
fit_columns_to_width(&natural_widths, term_width.saturating_sub(total_gap))
};
let cell_data: Vec<Vec<String>> = raw_cells
.iter()
.map(|row_cells| {
row_cells
.iter()
.enumerate()
.map(|(i, s)| truncate_str(s, effective_widths[i]))
.collect()
})
.collect();
let truncated_headers: Vec<String> = columns
.iter()
.enumerate()
.map(|(i, c)| truncate_str(c, effective_widths[i]))
.collect();
let mut display_widths: Vec<usize> = truncated_headers.iter().map(|h| h.chars().count()).collect();
for row_cells in &cell_data {
for (i, s) in row_cells.iter().enumerate() {
let w = s.chars().count();
if w > display_widths[i] {
display_widths[i] = w;
}
}
}
let mut out = String::new();
let header: Vec<String> = truncated_headers
.iter()
.enumerate()
.map(|(i, h)| format!("{:<width$}", h, width = display_widths[i]))
.collect();
out.push_str(&header.join(" "));
out.push('\n');
let sep: Vec<String> = display_widths.iter().map(|w| "-".repeat(*w)).collect();
out.push_str(&sep.join(" "));
out.push('\n');
for cells in &cell_data {
let line: Vec<String> = cells
.iter()
.enumerate()
.map(|(i, c)| {
if rows
.first()
.and_then(|r| r.get(&columns[i]))
.map_or(false, |v| matches!(v, Value::Int(_) | Value::Float(_)))
{
format!("{:>width$}", c, width = display_widths[i])
} else {
format!("{:<width$}", c, width = display_widths[i])
}
})
.collect();
out.push_str(&line.join(" "));
out.push('\n');
}
out.trim_end_matches('\n').to_string()
}
fn terminal_width() -> Option<usize> {
if let Ok(cols) = std::env::var("COLUMNS") {
if let Ok(w) = cols.parse::<usize>() {
if w > 0 {
return Some(w);
}
}
}
#[cfg(unix)]
{
use std::mem::zeroed;
for fd in [2, 1, 0] {
unsafe {
let mut ws: libc::winsize = zeroed();
if libc::ioctl(fd, libc::TIOCGWINSZ, &mut ws) == 0 && ws.ws_col > 0 {
return Some(ws.ws_col as usize);
}
}
}
}
None
}
fn fit_columns_to_width(natural: &[usize], available: usize) -> Vec<usize> {
let total_natural: usize = natural.iter().sum();
if total_natural <= available {
return natural.to_vec();
}
let min_col = 4;
let mut widths: Vec<usize> = natural
.iter()
.map(|&w| {
let share = ((w as f64 / total_natural as f64) * available as f64) as usize;
share.max(min_col)
})
.collect();
let mut total: usize = widths.iter().sum();
while total > available {
if let Some(i) = widths.iter().enumerate()
.filter(|(_, &w)| w > min_col)
.max_by_key(|(_, &w)| w)
.map(|(i, _)| i)
{
widths[i] -= 1;
total -= 1;
} else {
break;
}
}
widths
}
fn truncate_str(s: &str, max_len: usize) -> String {
let s = s.replace('\n', " ");
let s = s.trim();
if s.chars().count() > max_len {
let truncated: String = s.chars().take(max_len - 3).collect();
format!("{}...", truncated)
} else {
s.to_string()
}
}