alopex_cli/output/
csv.rs

1//! CSV formatter (RFC 4180)
2//!
3//! Outputs data as CSV with proper escaping.
4//! Supports streaming output.
5
6use std::io::Write;
7
8use crate::error::Result;
9use crate::models::{Column, Row, Value};
10
11use super::formatter::Formatter;
12
13/// CSV formatter (RFC 4180 compliant).
14///
15/// Outputs data as comma-separated values with proper quoting and escaping.
16pub struct CsvFormatter {
17    /// Whether a row has been written (for potential future use)
18    _row_count: usize,
19}
20
21impl CsvFormatter {
22    /// Create a new CSV formatter.
23    pub fn new() -> Self {
24        Self { _row_count: 0 }
25    }
26}
27
28impl Default for CsvFormatter {
29    fn default() -> Self {
30        Self::new()
31    }
32}
33
34impl Formatter for CsvFormatter {
35    fn write_header(&mut self, writer: &mut dyn Write, columns: &[Column]) -> Result<()> {
36        let header: Vec<String> = columns.iter().map(|c| escape_csv(&c.name)).collect();
37        writeln!(writer, "{}", header.join(","))?;
38        Ok(())
39    }
40
41    fn write_row(&mut self, writer: &mut dyn Write, row: &Row) -> Result<()> {
42        let values: Vec<String> = row.columns.iter().map(format_value_csv).collect();
43        writeln!(writer, "{}", values.join(","))?;
44        self._row_count += 1;
45        Ok(())
46    }
47
48    fn write_footer(&mut self, _writer: &mut dyn Write) -> Result<()> {
49        // CSV has no footer
50        Ok(())
51    }
52
53    fn supports_streaming(&self) -> bool {
54        true
55    }
56}
57
58/// Format a value for CSV output.
59fn format_value_csv(value: &Value) -> String {
60    match value {
61        Value::Null => String::new(),
62        Value::Bool(b) => b.to_string(),
63        Value::Int(i) => i.to_string(),
64        Value::Float(f) => f.to_string(),
65        Value::Text(s) => escape_csv(s),
66        Value::Bytes(b) => {
67            // Format bytes as hex string
68            escape_csv(
69                &b.iter()
70                    .map(|byte| format!("{:02x}", byte))
71                    .collect::<String>(),
72            )
73        }
74        Value::Vector(v) => {
75            // Format vector as JSON array string
76            let json = serde_json::to_string(v).unwrap_or_default();
77            escape_csv(&json)
78        }
79    }
80}
81
82/// Escape a string for CSV output (RFC 4180).
83///
84/// Quotes the string if it contains special characters.
85fn escape_csv(s: &str) -> String {
86    if s.contains(',') || s.contains('"') || s.contains('\n') || s.contains('\r') {
87        // Quote and escape internal quotes
88        format!("\"{}\"", s.replace('"', "\"\""))
89    } else {
90        s.to_string()
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::models::DataType;
98
99    fn test_columns() -> Vec<Column> {
100        vec![
101            Column::new("id", DataType::Int),
102            Column::new("name", DataType::Text),
103        ]
104    }
105
106    #[test]
107    fn test_csv_basic() {
108        let mut formatter = CsvFormatter::new();
109        let mut output = Vec::new();
110
111        let columns = test_columns();
112        formatter.write_header(&mut output, &columns).unwrap();
113
114        let row = Row::new(vec![Value::Int(1), Value::Text("Alice".to_string())]);
115        formatter.write_row(&mut output, &row).unwrap();
116
117        formatter.write_footer(&mut output).unwrap();
118
119        let result = String::from_utf8(output).unwrap();
120        assert_eq!(result, "id,name\n1,Alice\n");
121    }
122
123    #[test]
124    fn test_csv_escape_comma() {
125        let mut formatter = CsvFormatter::new();
126        let mut output = Vec::new();
127
128        let columns = test_columns();
129        formatter.write_header(&mut output, &columns).unwrap();
130
131        let row = Row::new(vec![Value::Int(1), Value::Text("Alice, Bob".to_string())]);
132        formatter.write_row(&mut output, &row).unwrap();
133
134        let result = String::from_utf8(output).unwrap();
135        assert!(result.contains("\"Alice, Bob\""));
136    }
137
138    #[test]
139    fn test_csv_escape_quote() {
140        let mut formatter = CsvFormatter::new();
141        let mut output = Vec::new();
142
143        let columns = test_columns();
144        formatter.write_header(&mut output, &columns).unwrap();
145
146        let row = Row::new(vec![
147            Value::Int(1),
148            Value::Text("Alice \"The Great\"".to_string()),
149        ]);
150        formatter.write_row(&mut output, &row).unwrap();
151
152        let result = String::from_utf8(output).unwrap();
153        assert!(result.contains("\"Alice \"\"The Great\"\"\""));
154    }
155
156    #[test]
157    fn test_csv_escape_newline() {
158        let mut formatter = CsvFormatter::new();
159        let mut output = Vec::new();
160
161        let columns = test_columns();
162        formatter.write_header(&mut output, &columns).unwrap();
163
164        let row = Row::new(vec![Value::Int(1), Value::Text("Line1\nLine2".to_string())]);
165        formatter.write_row(&mut output, &row).unwrap();
166
167        let result = String::from_utf8(output).unwrap();
168        assert!(result.contains("\"Line1\nLine2\""));
169    }
170
171    #[test]
172    fn test_csv_null_value() {
173        let mut formatter = CsvFormatter::new();
174        let mut output = Vec::new();
175
176        let columns = test_columns();
177        formatter.write_header(&mut output, &columns).unwrap();
178
179        let row = Row::new(vec![Value::Int(1), Value::Null]);
180        formatter.write_row(&mut output, &row).unwrap();
181
182        let result = String::from_utf8(output).unwrap();
183        assert_eq!(result, "id,name\n1,\n");
184    }
185
186    #[test]
187    fn test_csv_supports_streaming() {
188        let formatter = CsvFormatter::new();
189        assert!(formatter.supports_streaming());
190    }
191}