Skip to main content

alopex_cli/output/
tsv.rs

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