use std::fmt;
use crate::console::{Console, ConsoleOptions, Renderable};
use crate::measure::Measurement;
use crate::segment::Segment;
use crate::style::Style;
use crate::table::Table;
#[cfg(feature = "csv")]
use csv::Reader;
#[derive(Debug, thiserror::Error)]
pub enum CsvTableError {
#[error("empty CSV data")]
Empty,
#[error("no header row")]
NoHeader,
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[cfg(feature = "csv")]
#[error("CSV parse error: {0}")]
Csv(#[from] csv::Error),
}
fn parse_csv_line(line: &str) -> Vec<String> {
let mut fields = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
let mut chars = line.chars().peekable();
while let Some(ch) = chars.next() {
if in_quotes {
if ch == '"' {
if chars.peek() == Some(&'"') {
current.push('"');
chars.next(); } else {
in_quotes = false;
}
} else {
current.push(ch);
}
} else {
match ch {
'"' => {
in_quotes = true;
}
',' => {
fields.push(std::mem::take(&mut current));
}
_ => {
current.push(ch);
}
}
}
}
fields.push(current);
fields
}
fn parse_csv_text(text: &str) -> Result<(Vec<String>, Vec<Vec<String>>), CsvTableError> {
let text = text.trim();
if text.is_empty() {
return Err(CsvTableError::Empty);
}
let mut lines = text.lines();
let header_line = lines.next().ok_or(CsvTableError::NoHeader)?;
let headers = parse_csv_line(header_line);
if headers.is_empty() || (headers.len() == 1 && headers[0].is_empty()) {
return Err(CsvTableError::NoHeader);
}
let mut rows = Vec::new();
for line in lines {
if line.trim().is_empty() {
continue;
}
rows.push(parse_csv_line(line));
}
Ok((headers, rows))
}
#[derive(Debug, Clone)]
pub struct CsvTable {
headers: Vec<String>,
rows: Vec<Vec<String>>,
max_rows: Option<usize>,
header_style: Option<Style>,
title: Option<String>,
}
impl CsvTable {
fn from_parts(headers: Vec<String>, rows: Vec<Vec<String>>) -> Self {
Self {
headers,
rows,
max_rows: None,
header_style: None,
title: None,
}
}
pub fn from_csv_str(csv_text: &str) -> Result<Self, CsvTableError> {
let (headers, rows) = parse_csv_text(csv_text)?;
Ok(Self::from_parts(headers, rows))
}
#[cfg(feature = "csv")]
pub fn from_path(path: &str) -> Result<Self, CsvTableError> {
let reader = Reader::from_path(path)?;
Self::from_reader(reader)
}
#[cfg(feature = "csv")]
pub fn from_reader<R: std::io::Read>(mut reader: Reader<R>) -> Result<Self, CsvTableError> {
let headers: Vec<String> = reader.headers()?.iter().map(|h| h.to_string()).collect();
if headers.is_empty() {
return Err(CsvTableError::NoHeader);
}
let mut rows = Vec::new();
for result in reader.records() {
let record = result?;
let row: Vec<String> = record.iter().map(|f| f.to_string()).collect();
rows.push(row);
}
Ok(Self::from_parts(headers, rows))
}
#[must_use]
pub fn with_max_rows(mut self, max: usize) -> Self {
self.max_rows = Some(max);
self
}
#[must_use]
pub fn with_header_style(mut self, style: Style) -> Self {
self.header_style = Some(style);
self
}
#[must_use]
pub fn with_title(mut self, title: &str) -> Self {
self.title = Some(title.to_string());
self
}
pub fn headers(&self) -> &[String] {
&self.headers
}
pub fn rows(&self) -> &[Vec<String>] {
&self.rows
}
pub fn row_count(&self) -> usize {
self.rows.len()
}
pub fn to_table(&self) -> Table {
let header_refs: Vec<&str> = self.headers.iter().map(|s| s.as_str()).collect();
let mut table = Table::new(&header_refs);
if let Some(title) = &self.title {
table.title = Some(title.clone());
}
if let Some(style) = &self.header_style {
let style_str = format!("{}", style);
table.header_style = style_str;
}
let row_limit = self.max_rows.unwrap_or(self.rows.len());
for row in self.rows.iter().take(row_limit) {
let cells: Vec<&str> = row.iter().map(|s| s.as_str()).collect();
table.add_row(&cells);
}
table
}
pub fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
let table = self.to_table();
table.measure(console, options)
}
}
impl Renderable for CsvTable {
fn gilt_console(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
let table = self.to_table();
table.gilt_console(console, options)
}
}
impl fmt::Display for CsvTable {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut console = Console::builder()
.width(f.width().unwrap_or(80))
.force_terminal(true)
.no_color(true)
.build();
console.begin_capture();
console.print(self);
let output = console.end_capture();
write!(f, "{}", output.trim_end_matches('\n'))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_console(width: usize) -> Console {
Console::builder()
.width(width)
.force_terminal(true)
.no_color(true)
.markup(false)
.build()
}
#[test]
fn test_simple_csv() {
let csv = CsvTable::from_csv_str("Name,Age\nAlice,30\nBob,25").unwrap();
assert_eq!(csv.headers(), &["Name", "Age"]);
assert_eq!(csv.row_count(), 2);
}
#[test]
fn test_headers_only() {
let csv = CsvTable::from_csv_str("A,B,C").unwrap();
assert_eq!(csv.headers(), &["A", "B", "C"]);
assert_eq!(csv.row_count(), 0);
}
#[test]
fn test_quoted_fields() {
let csv = CsvTable::from_csv_str("Name,Bio\nAlice,\"Likes coding\"").unwrap();
assert_eq!(csv.rows()[0], vec!["Alice", "Likes coding"]);
}
#[test]
fn test_commas_in_quotes() {
let csv = CsvTable::from_csv_str("City,Pop\n\"New York, NY\",8000000").unwrap();
assert_eq!(csv.rows()[0][0], "New York, NY");
assert_eq!(csv.rows()[0][1], "8000000");
}
#[test]
fn test_escaped_quotes() {
let csv = CsvTable::from_csv_str("Name,Quote\nAlice,\"She said \"\"hi\"\"\"").unwrap();
assert_eq!(csv.rows()[0][1], "She said \"hi\"");
}
#[test]
fn test_empty_csv() {
let result = CsvTable::from_csv_str("");
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), CsvTableError::Empty));
}
#[test]
fn test_whitespace_only_csv() {
let result = CsvTable::from_csv_str(" \n \n ");
assert!(result.is_err());
}
#[test]
fn test_single_column() {
let csv = CsvTable::from_csv_str("Name\nAlice\nBob").unwrap();
assert_eq!(csv.headers().len(), 1);
assert_eq!(csv.row_count(), 2);
}
#[test]
fn test_single_row() {
let csv = CsvTable::from_csv_str("A,B\n1,2").unwrap();
assert_eq!(csv.row_count(), 1);
assert_eq!(csv.rows()[0], vec!["1", "2"]);
}
#[test]
fn test_max_rows() {
let csv = CsvTable::from_csv_str("A\n1\n2\n3\n4\n5")
.unwrap()
.with_max_rows(3);
let table = csv.to_table();
assert_eq!(table.row_count(), 3);
}
#[test]
fn test_header_style() {
let style = Style::parse("bold");
let csv = CsvTable::from_csv_str("A,B\n1,2")
.unwrap()
.with_header_style(style.clone());
assert!(csv.header_style.is_some());
}
#[test]
fn test_title() {
let csv = CsvTable::from_csv_str("A,B\n1,2")
.unwrap()
.with_title("My Data");
let table = csv.to_table();
assert_eq!(table.title.as_deref(), Some("My Data"));
}
#[test]
fn test_to_table_conversion() {
let csv = CsvTable::from_csv_str("Name,Age\nAlice,30\nBob,25").unwrap();
let table = csv.to_table();
assert_eq!(table.columns.len(), 2);
assert_eq!(table.row_count(), 2);
}
#[test]
fn test_to_table_with_title_and_limit() {
let csv = CsvTable::from_csv_str("X,Y\n1,2\n3,4\n5,6")
.unwrap()
.with_title("Test")
.with_max_rows(2);
let table = csv.to_table();
assert_eq!(table.title.as_deref(), Some("Test"));
assert_eq!(table.row_count(), 2);
}
#[test]
fn test_renderable_output() {
let csv = CsvTable::from_csv_str("Name,Age\nAlice,30").unwrap();
let console = make_console(60);
let opts = console.options();
let segments = csv.gilt_console(&console, &opts);
let text: String = segments.iter().map(|s| s.text.as_str()).collect();
assert!(text.contains("Name"));
assert!(text.contains("Alice"));
assert!(text.contains("30"));
}
#[test]
fn test_display_trait() {
let csv = CsvTable::from_csv_str("A,B\n1,2").unwrap();
let s = format!("{}", csv);
assert!(s.contains("A"));
assert!(s.contains("B"));
assert!(s.contains("1"));
assert!(s.contains("2"));
}
#[test]
fn test_measure() {
let csv = CsvTable::from_csv_str("Name,Age\nAlice,30").unwrap();
let console = make_console(80);
let opts = console.options();
let m = csv.measure(&console, &opts);
assert!(m.minimum > 0);
assert!(m.maximum > 0);
}
#[test]
fn test_parse_csv_line_simple() {
let fields = parse_csv_line("a,b,c");
assert_eq!(fields, vec!["a", "b", "c"]);
}
#[test]
fn test_parse_csv_line_quoted() {
let fields = parse_csv_line("\"hello, world\",foo");
assert_eq!(fields, vec!["hello, world", "foo"]);
}
#[test]
fn test_parse_csv_line_escaped_quote() {
let fields = parse_csv_line("\"a \"\"b\"\" c\",d");
assert_eq!(fields, vec!["a \"b\" c", "d"]);
}
#[test]
fn test_parse_csv_line_empty_fields() {
let fields = parse_csv_line(",a,,b,");
assert_eq!(fields, vec!["", "a", "", "b", ""]);
}
#[test]
fn test_builder_chain() {
let csv = CsvTable::from_csv_str("A\n1")
.unwrap()
.with_max_rows(10)
.with_header_style(Style::parse("bold"))
.with_title("Title");
assert_eq!(csv.max_rows, Some(10));
assert!(csv.header_style.is_some());
assert_eq!(csv.title.as_deref(), Some("Title"));
}
#[test]
fn test_blank_lines_skipped() {
let csv = CsvTable::from_csv_str("A,B\n1,2\n\n3,4\n").unwrap();
assert_eq!(csv.row_count(), 2);
}
#[cfg(feature = "csv")]
mod csv_crate_tests {
use super::*;
use std::io::Cursor;
#[test]
fn test_from_reader() {
let data = "Name,Age\nAlice,30\nBob,25";
let reader = csv::Reader::from_reader(Cursor::new(data));
let csv_table = CsvTable::from_reader(reader).unwrap();
assert_eq!(csv_table.headers(), &["Name", "Age"]);
assert_eq!(csv_table.row_count(), 2);
}
#[test]
fn test_from_reader_single_column() {
let data = "Name\nAlice\nBob";
let reader = csv::Reader::from_reader(Cursor::new(data));
let csv_table = CsvTable::from_reader(reader).unwrap();
assert_eq!(csv_table.headers().len(), 1);
assert_eq!(csv_table.row_count(), 2);
}
#[test]
fn test_from_path_nonexistent() {
let result = CsvTable::from_path("/tmp/gilt_nonexistent_csv_file.csv");
assert!(result.is_err());
}
}
}