use std::collections::HashMap;
use std::io::{self, Write};
use std::path::Path;
use serde_json::{Map, Value};
use crate::Result;
#[derive(Debug, Clone, Copy)]
pub struct TableOptions {
pub max_col_width: usize,
pub header: bool,
}
impl Default for TableOptions {
fn default() -> Self {
Self {
max_col_width: 40,
header: true,
}
}
}
pub fn rows_to_csv(rows: &[Vec<String>]) -> String {
let mut out = String::new();
for row in rows {
let line: Vec<String> = row.iter().map(|f| csv_field(f)).collect();
out.push_str(&line.join(","));
out.push_str("\r\n");
}
out
}
pub fn records_to_csv(records: &[HashMap<String, String>], headers: &[String]) -> String {
let mut rows: Vec<Vec<String>> = Vec::with_capacity(records.len() + 1);
rows.push(headers.to_vec());
for rec in records {
rows.push(
headers
.iter()
.map(|h| rec.get(h).cloned().unwrap_or_default())
.collect(),
);
}
rows_to_csv(&rows)
}
pub fn records_to_json(records: &[HashMap<String, String>]) -> String {
let arr: Vec<Value> = records
.iter()
.map(|r| {
let mut m = Map::new();
for (k, v) in r {
m.insert(k.clone(), Value::String(v.clone()));
}
Value::Object(m)
})
.collect();
serde_json::to_string_pretty(&Value::Array(arr)).unwrap_or_else(|_| "[]".to_string())
}
pub fn rows_to_table(rows: &[Vec<String>]) -> String {
rows_to_table_with_options(rows, &TableOptions::default())
}
pub fn rows_to_table_with_options(rows: &[Vec<String>], options: &TableOptions) -> String {
if rows.is_empty() {
return String::new();
}
let col_count = rows.iter().map(Vec::len).max().unwrap_or(0);
if col_count == 0 {
return String::new();
}
let max_col_width = options.max_col_width.max(1);
let table: Vec<Vec<String>> = rows
.iter()
.map(|row| {
(0..col_count)
.map(|i| {
row.get(i)
.map(|s| table_cell(s, max_col_width))
.unwrap_or_default()
})
.collect()
})
.collect();
let mut widths = vec![1usize; col_count];
for row in &table {
for (i, cell) in row.iter().enumerate() {
widths[i] = widths[i].max(display_width(cell));
}
}
let mut out = String::new();
out.push_str(&table_border('┌', '┬', '┐', &widths));
for (i, row) in table.iter().enumerate() {
out.push_str(&table_row(row, &widths));
if options.header && i == 0 && table.len() > 1 {
out.push_str(&table_border('├', '┼', '┤', &widths));
}
}
out.push_str(&table_border('└', '┴', '┘', &widths));
out
}
pub fn print_table(rows: &[Vec<String>]) -> Result<()> {
print_table_with_options(rows, &TableOptions::default())
}
pub fn print_table_with_options(rows: &[Vec<String>], options: &TableOptions) -> Result<()> {
let table = rows_to_table_with_options(rows, options);
let mut stdout = io::stdout().lock();
stdout.write_all(table.as_bytes())?;
stdout.flush()?;
Ok(())
}
pub async fn write_csv(path: impl AsRef<Path>, rows: &[Vec<String>]) -> Result<()> {
write_text(path, &rows_to_csv(rows)).await
}
pub async fn write_json(path: impl AsRef<Path>, records: &[HashMap<String, String>]) -> Result<()> {
write_text(path, &records_to_json(records)).await
}
async fn write_text(path: impl AsRef<Path>, content: &str) -> Result<()> {
let path = path.as_ref();
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(path, content).await?;
Ok(())
}
fn csv_field(f: &str) -> String {
if f.contains([',', '"', '\n', '\r']) {
format!("\"{}\"", f.replace('"', "\"\""))
} else {
f.to_string()
}
}
fn table_cell(cell: &str, max_width: usize) -> String {
let clean = cell.split_whitespace().collect::<Vec<_>>().join(" ");
truncate_width(&clean, max_width)
}
fn table_border(left: char, mid: char, right: char, widths: &[usize]) -> String {
let mut out = String::new();
out.push(left);
for (i, width) in widths.iter().enumerate() {
if i > 0 {
out.push(mid);
}
out.push_str(&"─".repeat(width + 2));
}
out.push(right);
out.push('\n');
out
}
fn table_row(row: &[String], widths: &[usize]) -> String {
let mut out = String::new();
out.push('│');
for (cell, width) in row.iter().zip(widths) {
out.push(' ');
out.push_str(cell);
out.push_str(&" ".repeat(width.saturating_sub(display_width(cell)) + 1));
out.push('│');
}
out.push('\n');
out
}
fn truncate_width(s: &str, max_width: usize) -> String {
if display_width(s) <= max_width {
return s.to_string();
}
let marker = if max_width >= 2 { "…" } else { "" };
let marker_width = display_width(marker);
let limit = max_width.saturating_sub(marker_width);
let mut out = String::new();
let mut width = 0usize;
for ch in s.chars() {
let w = char_width(ch);
if width + w > limit {
break;
}
out.push(ch);
width += w;
}
out.push_str(marker);
out
}
fn display_width(s: &str) -> usize {
s.chars().map(char_width).sum()
}
fn char_width(ch: char) -> usize {
if ch.is_control() {
0
} else if ch.is_ascii() {
1
} else {
2
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn csv_escaping() {
let rows = vec![
vec!["a".into(), "b,c".into(), "d\"e".into()],
vec!["x\ny".into(), "z".into(), "".into()],
];
let csv = rows_to_csv(&rows);
assert_eq!(csv, "a,\"b,c\",\"d\"\"e\"\r\n\"x\ny\",z,\r\n");
}
#[test]
fn records_csv_orders_by_headers() {
let mut r0 = HashMap::new();
r0.insert("name".to_string(), "苹果".to_string());
r0.insert("price".to_string(), "3".to_string());
let headers = vec!["name".to_string(), "price".to_string()];
let csv = records_to_csv(&[r0], &headers);
assert_eq!(csv, "name,price\r\n苹果,3\r\n");
}
#[test]
fn records_json_shape() {
let mut r0 = HashMap::new();
r0.insert("k".to_string(), "v".to_string());
let j = records_to_json(&[r0]);
let parsed: Value = serde_json::from_str(&j).unwrap();
assert_eq!(parsed[0]["k"], "v");
}
#[test]
fn rows_table_shape() {
let rows = vec![
vec!["name".into(), "price".into()],
vec!["Apple".into(), "3".into()],
];
let table = rows_to_table(&rows);
assert!(table.contains("│ name │ price │"));
assert!(table.contains("│ Apple │ 3 │"));
}
#[test]
fn rows_table_handles_cjk_width_and_truncation() {
let rows = vec![
vec!["标题".into(), "价格".into()],
vec!["非常非常长的中文标题".into(), "12".into()],
];
let table = rows_to_table_with_options(
&rows,
&TableOptions {
max_col_width: 8,
header: true,
},
);
assert!(table.contains("非常非…"));
assert!(table.contains("│ 标题 │ 价格 │"));
}
}