Skip to main content

exarrow_rs/export/
csv.rs

1//! CSV export functionality for Exasol.
2//!
3//! This module provides functions for exporting data from Exasol tables or query results
4//! to CSV format via files, streams, in-memory lists, or custom callbacks.
5//!
6//! # Architecture
7//!
8//! The export process uses **client mode** where:
9//! 1. We connect TO Exasol (outbound connection - works through firewalls)
10//! 2. Perform EXA tunneling handshake to receive an internal address
11//! 3. Execute an EXPORT SQL statement using the internal address
12//! 4. Exasol sends data through the established connection
13//! 5. We read and process the data stream
14//!
15//! This client mode approach works with cloud Exasol instances and through NAT/firewalls.
16
17use std::future::Future;
18use std::io;
19use std::path::Path;
20
21use bzip2::read::BzDecoder;
22use flate2::read::GzDecoder;
23use thiserror::Error;
24use tokio::fs::File;
25use tokio::io::{AsyncWrite, AsyncWriteExt, BufWriter};
26use tokio::sync::mpsc;
27
28use crate::query::export::{Compression, ExportQuery, ExportSource, RowSeparator};
29use crate::transport::protocol::TransportProtocol;
30use crate::transport::HttpTransportClient;
31
32/// Default buffer size for data pipe channels (number of chunks).
33const DEFAULT_PIPE_BUFFER_SIZE: usize = 16;
34
35/// Error types for export operations.
36#[derive(Error, Debug)]
37pub enum ExportError {
38    /// I/O error during export.
39    #[error("I/O error: {0}")]
40    IoError(#[from] io::Error),
41
42    /// Transport error during export.
43    #[error("Transport error: {0}")]
44    TransportError(#[from] crate::error::TransportError),
45
46    /// HTTP transport setup error.
47    #[error("HTTP transport error: {message}")]
48    HttpTransportError { message: String },
49
50    /// SQL execution error.
51    #[error("SQL execution error: {message}")]
52    SqlExecutionError { message: String },
53
54    /// CSV parsing error.
55    #[error("CSV parsing error at row {row}: {message}")]
56    CsvParseError { row: usize, message: String },
57
58    /// Decompression error.
59    #[error("Decompression error: {0}")]
60    DecompressionError(String),
61
62    /// Channel communication error.
63    #[error("Channel error: {0}")]
64    ChannelError(String),
65
66    /// Export timeout.
67    #[error("Export timed out after {timeout_ms}ms")]
68    Timeout { timeout_ms: u64 },
69
70    /// Export was cancelled.
71    #[error("Export was cancelled")]
72    Cancelled,
73}
74
75/// Options for CSV export configuration.
76#[derive(Debug, Clone)]
77pub struct CsvExportOptions {
78    /// Column separator character (default: ',').
79    pub column_separator: char,
80
81    /// Column delimiter character for quoting (default: '"').
82    pub column_delimiter: char,
83
84    /// Row separator (default: LF).
85    pub row_separator: RowSeparator,
86
87    /// Character encoding (default: "UTF-8").
88    pub encoding: String,
89
90    /// Custom NULL value representation (default: None, empty string).
91    pub null_value: Option<String>,
92
93    /// Compression type (default: None).
94    pub compression: Compression,
95
96    /// Whether to include column headers in the output (default: false).
97    pub with_column_names: bool,
98
99    /// Use TLS for the HTTP transport (default: true).
100    pub use_tls: bool,
101
102    /// Timeout in milliseconds for the export operation (default: 300000 = 5 minutes).
103    pub timeout_ms: u64,
104
105    /// Exasol host for HTTP transport connection.
106    /// This is typically the same host as the WebSocket connection.
107    pub host: String,
108
109    /// Exasol port for HTTP transport connection.
110    /// This is typically the same port as the WebSocket connection.
111    pub port: u16,
112}
113
114impl Default for CsvExportOptions {
115    fn default() -> Self {
116        Self {
117            column_separator: ',',
118            column_delimiter: '"',
119            row_separator: RowSeparator::LF,
120            encoding: "UTF-8".to_string(),
121            null_value: None,
122            compression: Compression::None,
123            with_column_names: false,
124            use_tls: false,
125            timeout_ms: 300_000, // 5 minutes
126            host: String::new(),
127            port: 0,
128        }
129    }
130}
131
132impl CsvExportOptions {
133    /// Creates new export options with default values.
134    #[must_use]
135    pub fn new() -> Self {
136        Self::default()
137    }
138
139    #[must_use]
140    pub fn column_separator(mut self, sep: char) -> Self {
141        self.column_separator = sep;
142        self
143    }
144
145    #[must_use]
146    pub fn column_delimiter(mut self, delim: char) -> Self {
147        self.column_delimiter = delim;
148        self
149    }
150
151    #[must_use]
152    pub fn row_separator(mut self, sep: RowSeparator) -> Self {
153        self.row_separator = sep;
154        self
155    }
156
157    #[must_use]
158    pub fn encoding(mut self, enc: &str) -> Self {
159        self.encoding = enc.to_string();
160        self
161    }
162
163    #[must_use]
164    pub fn null_value(mut self, val: &str) -> Self {
165        self.null_value = Some(val.to_string());
166        self
167    }
168
169    #[must_use]
170    pub fn compression(mut self, compression: Compression) -> Self {
171        self.compression = compression;
172        self
173    }
174
175    #[must_use]
176    pub fn with_column_names(mut self, include: bool) -> Self {
177        self.with_column_names = include;
178        self
179    }
180
181    #[must_use]
182    pub fn use_tls(mut self, use_tls: bool) -> Self {
183        self.use_tls = use_tls;
184        self
185    }
186
187    #[must_use]
188    pub fn timeout_ms(mut self, timeout: u64) -> Self {
189        self.timeout_ms = timeout;
190        self
191    }
192
193    /// Sets the Exasol host for HTTP transport connection.
194    ///
195    /// This is typically the same host as the WebSocket connection.
196    #[must_use]
197    pub fn exasol_host(mut self, host: impl Into<String>) -> Self {
198        self.host = host.into();
199        self
200    }
201
202    /// Sets the Exasol port for HTTP transport connection.
203    ///
204    /// This is typically the same port as the WebSocket connection.
205    #[must_use]
206    pub fn exasol_port(mut self, port: u16) -> Self {
207        self.port = port;
208        self
209    }
210}
211
212/// Receiver end of the data pipe for processing exported data.
213pub struct DataPipeReceiver {
214    rx: mpsc::Receiver<Vec<u8>>,
215}
216
217impl DataPipeReceiver {
218    /// Receives the next chunk of data.
219    ///
220    /// Returns `None` when all data has been received.
221    pub async fn recv(&mut self) -> Option<Vec<u8>> {
222        self.rx.recv().await
223    }
224}
225
226/// Exports data from an Exasol table or query to a file.
227///
228/// # Arguments
229///
230/// * `ws_transport` - WebSocket transport for executing SQL
231/// * `source` - The data source (table or query)
232/// * `file_path` - Path to the output file
233/// * `options` - Export options
234///
235/// # Returns
236///
237/// The number of rows exported on success.
238///
239/// # Errors
240///
241/// Returns `ExportError` if the export fails.
242///
243/// # Example
244///
245pub async fn export_to_file<T: TransportProtocol + ?Sized>(
246    ws_transport: &mut T,
247    source: ExportSource,
248    file_path: &Path,
249    options: CsvExportOptions,
250) -> Result<u64, ExportError> {
251    // Create the output file
252    let file = File::create(file_path).await?;
253    let writer = BufWriter::new(file);
254
255    export_to_stream(ws_transport, source, writer, options).await
256}
257
258/// Exports data from an Exasol table or query to an async writer.
259///
260/// # Arguments
261///
262/// * `ws_transport` - WebSocket transport for executing SQL
263/// * `source` - The data source (table or query)
264/// * `writer` - Async writer to write the CSV data to
265/// * `options` - Export options
266///
267/// # Returns
268///
269/// The number of rows exported on success.
270///
271/// # Errors
272///
273/// Returns `ExportError` if the export fails.
274pub async fn export_to_stream<T: TransportProtocol + ?Sized, W: AsyncWrite + Unpin>(
275    ws_transport: &mut T,
276    source: ExportSource,
277    mut writer: W,
278    options: CsvExportOptions,
279) -> Result<u64, ExportError> {
280    let compression = options.compression;
281
282    // Use the callback variant to process data
283    export_to_callback(ws_transport, source, options, |mut receiver| async move {
284        let mut row_count = 0u64;
285        let mut buffer = Vec::new();
286
287        // Collect all data first
288        while let Some(chunk) = receiver.recv().await {
289            buffer.extend_from_slice(&chunk);
290        }
291
292        // Decompress if needed
293        let data = decompress(buffer, compression)?;
294
295        // Write to output and count rows
296        writer.write_all(&data).await?;
297        writer.flush().await?;
298
299        // Count rows by counting newlines
300        for byte in &data {
301            if *byte == b'\n' {
302                row_count += 1;
303            }
304        }
305
306        Ok(row_count)
307    })
308    .await
309}
310
311/// Exports data from an Exasol table or query to an in-memory list of rows.
312///
313/// Each row is represented as a vector of string values.
314///
315/// # Arguments
316///
317/// * `ws_transport` - WebSocket transport for executing SQL
318/// * `source` - The data source (table or query)
319/// * `options` - Export options
320///
321/// # Returns
322///
323/// A vector of rows, where each row is a vector of column values.
324///
325/// # Errors
326///
327/// Returns `ExportError` if the export fails.
328///
329/// # Example
330///
331pub async fn export_to_list<T: TransportProtocol + ?Sized>(
332    ws_transport: &mut T,
333    source: ExportSource,
334    options: CsvExportOptions,
335) -> Result<Vec<Vec<String>>, ExportError> {
336    let separator = options.column_separator;
337    let delimiter = options.column_delimiter;
338    let compression = options.compression;
339
340    export_to_callback(ws_transport, source, options, |mut receiver| async move {
341        let mut buffer = Vec::new();
342
343        // Collect all data
344        while let Some(chunk) = receiver.recv().await {
345            buffer.extend_from_slice(&chunk);
346        }
347
348        // Decompress if needed
349        let data = decompress(buffer, compression)?;
350
351        // Parse CSV data
352        let csv_string = String::from_utf8(data).map_err(|e| ExportError::CsvParseError {
353            row: 0,
354            message: format!("Invalid UTF-8: {}", e),
355        })?;
356
357        parse_csv(&csv_string, separator, delimiter)
358    })
359    .await
360}
361
362/// Exports data from an Exasol table or query using a custom callback.
363///
364/// This is the most flexible export method, allowing you to process the data
365/// stream however you need.
366///
367/// # Arguments
368///
369/// * `ws_transport` - WebSocket transport for executing SQL
370/// * `source` - The data source (table or query)
371/// * `options` - Export options
372/// * `callback` - A callback function that receives a `DataPipeReceiver` and processes the data
373///
374/// # Returns
375///
376/// The result of the callback function.
377///
378/// # Errors
379///
380/// Returns `ExportError` if the export fails.
381pub async fn export_to_callback<T, F, Fut, R>(
382    ws_transport: &mut T,
383    source: ExportSource,
384    options: CsvExportOptions,
385    callback: F,
386) -> Result<R, ExportError>
387where
388    T: TransportProtocol + ?Sized,
389    F: FnOnce(DataPipeReceiver) -> Fut,
390    Fut: Future<Output = Result<R, ExportError>>,
391{
392    // Connect to Exasol via HTTP transport client (performs handshake automatically)
393    let mut client = HttpTransportClient::connect(&options.host, options.port, options.use_tls)
394        .await
395        .map_err(|e| ExportError::HttpTransportError {
396            message: format!("Failed to connect to Exasol: {e}"),
397        })?;
398
399    // Get internal address and fingerprint from the handshake response
400    let internal_addr = client.internal_address().to_string();
401    let fingerprint = client.public_key_fingerprint().map(|s| s.to_string());
402
403    // Build the EXPORT query
404    let mut query_builder = match source {
405        ExportSource::Table {
406            ref schema,
407            ref name,
408            ref columns,
409        } => {
410            let mut builder = ExportQuery::from_table(name);
411            if let Some(s) = schema {
412                builder = builder.schema(s);
413            }
414            if !columns.is_empty() {
415                builder = builder.columns(columns.iter().map(|s| s.as_str()).collect());
416            }
417            builder
418        }
419        ExportSource::Query { ref sql } => ExportQuery::from_query(sql),
420    };
421
422    // Apply options to the query builder, using internal address from handshake
423    query_builder = query_builder
424        .at_address(&internal_addr)
425        .column_separator(options.column_separator)
426        .column_delimiter(options.column_delimiter)
427        .row_separator(options.row_separator)
428        .encoding(&options.encoding)
429        .with_column_names(options.with_column_names)
430        .compressed(options.compression);
431
432    if let Some(ref null_val) = options.null_value {
433        query_builder = query_builder.null_value(null_val);
434    }
435
436    if let Some(ref fp) = fingerprint {
437        query_builder = query_builder.with_public_key(fp);
438    }
439
440    let export_sql = query_builder.build();
441
442    // Create channel for data transfer
443    let (tx, rx) = mpsc::channel::<Vec<u8>>(DEFAULT_PIPE_BUFFER_SIZE);
444    let receiver = DataPipeReceiver { rx };
445
446    // Spawn task to handle the export request from Exasol
447    // This task:
448    // 1. Waits for HTTP PUT request from Exasol
449    // 2. Reads CSV data from PUT request body (chunked or content-length)
450    // 3. Sends HTTP 200 OK response after receiving all data
451    let http_task =
452        tokio::spawn(async move {
453            // Use handle_export_request() to properly handle the EXPORT protocol
454            let (_request, body) = client.handle_export_request().await.map_err(|e| {
455                ExportError::HttpTransportError {
456                    message: format!("Failed to handle export request: {e}"),
457                }
458            })?;
459
460            // Send all data to receiver
461            if !body.is_empty() && tx.send(body).await.is_err() {
462                return Err(ExportError::ChannelError("Receiver dropped".to_string()));
463            }
464
465            // Shutdown connection gracefully
466            let _ = client.shutdown().await;
467
468            Ok::<(), ExportError>(())
469        });
470
471    // Execute the EXPORT SQL in parallel
472    // This triggers Exasol to send data through the established connection
473    let sql_task = async {
474        ws_transport
475            .execute_query(&export_sql)
476            .await
477            .map_err(|e| ExportError::SqlExecutionError {
478                message: e.to_string(),
479            })
480    };
481
482    // Run callback with the receiver
483    let callback_task = callback(receiver);
484
485    // Use tokio::select to run all tasks concurrently
486    let timeout = tokio::time::Duration::from_millis(options.timeout_ms);
487
488    let result = tokio::time::timeout(timeout, async {
489        // Execute SQL first (this triggers Exasol to send data through our connection)
490        let sql_result = sql_task.await;
491
492        // Then wait for HTTP task and callback
493        let (http_result, callback_result) = tokio::join!(http_task, callback_task);
494
495        // Check for errors
496        sql_result?;
497        http_result.map_err(|e| ExportError::HttpTransportError {
498            message: format!("HTTP task panicked: {}", e),
499        })??;
500
501        callback_result
502    })
503    .await
504    .map_err(|_| ExportError::Timeout {
505        timeout_ms: options.timeout_ms,
506    })?;
507
508    result
509}
510
511/// Decompresses a buffer according to the configured compression.
512///
513/// Returns the buffer unchanged for `Compression::None`.
514fn decompress(buffer: Vec<u8>, compression: Compression) -> Result<Vec<u8>, ExportError> {
515    match compression {
516        Compression::Gzip => {
517            let decoder = GzDecoder::new(buffer.as_slice());
518            let mut decompressed = Vec::new();
519            std::io::Read::read_to_end(&mut std::io::BufReader::new(decoder), &mut decompressed)
520                .map_err(|e| ExportError::DecompressionError(e.to_string()))?;
521            Ok(decompressed)
522        }
523        Compression::Bzip2 => {
524            let decoder = BzDecoder::new(buffer.as_slice());
525            let mut decompressed = Vec::new();
526            std::io::Read::read_to_end(&mut std::io::BufReader::new(decoder), &mut decompressed)
527                .map_err(|e| ExportError::DecompressionError(e.to_string()))?;
528            Ok(decompressed)
529        }
530        Compression::None => Ok(buffer),
531    }
532}
533
534/// Parses CSV data into a vector of rows.
535fn parse_csv(
536    data: &str,
537    separator: char,
538    delimiter: char,
539) -> Result<Vec<Vec<String>>, ExportError> {
540    let mut rows = Vec::new();
541    let mut current_row = Vec::new();
542    let mut current_field = String::new();
543    let mut in_quotes = false;
544    let mut row_num = 0;
545
546    let chars: Vec<char> = data.chars().collect();
547    let mut i = 0;
548
549    while i < chars.len() {
550        let c = chars[i];
551
552        if in_quotes {
553            if c == delimiter {
554                // Check for escaped delimiter (two consecutive delimiters)
555                if i + 1 < chars.len() && chars[i + 1] == delimiter {
556                    current_field.push(delimiter);
557                    i += 2;
558                    continue;
559                }
560                // End of quoted field
561                in_quotes = false;
562            } else {
563                current_field.push(c);
564            }
565        } else if c == delimiter {
566            // Start of quoted field
567            in_quotes = true;
568        } else if c == separator {
569            // End of field
570            current_row.push(current_field);
571            current_field = String::new();
572        } else if c == '\n' {
573            // End of row
574            current_row.push(current_field);
575            current_field = String::new();
576            rows.push(current_row);
577            current_row = Vec::new();
578            row_num += 1;
579        } else if c == '\r' {
580            // Handle CRLF - skip the CR, the LF will handle the row end
581            if i + 1 < chars.len() && chars[i + 1] == '\n' {
582                // CRLF, skip CR and let LF handle it
583            } else {
584                // Just CR (old Mac-style)
585                current_row.push(current_field);
586                current_field = String::new();
587                rows.push(current_row);
588                current_row = Vec::new();
589                row_num += 1;
590            }
591        } else {
592            current_field.push(c);
593        }
594        i += 1;
595    }
596
597    // Handle the last field/row if not empty
598    if !current_field.is_empty() || !current_row.is_empty() {
599        current_row.push(current_field);
600        rows.push(current_row);
601    }
602
603    // Check for unclosed quotes
604    if in_quotes {
605        return Err(ExportError::CsvParseError {
606            row: row_num,
607            message: "Unclosed quote at end of data".to_string(),
608        });
609    }
610
611    Ok(rows)
612}
613
614/// Parses a single CSV line into its fields, handling RFC-style quoting.
615///
616/// A field is opened/closed by `delimiter`; a doubled delimiter inside a quoted
617/// field is an escaped delimiter. `separator` splits fields when not inside
618/// quotes. This operates on a single already-split line and therefore does not
619/// handle newlines embedded in quoted fields (callers split on newlines first).
620///
621/// Returns the parsed fields together with a flag indicating whether the line
622/// ended while still inside a quoted field (unterminated quote). Callers decide
623/// whether an unterminated quote is an error.
624pub(crate) fn parse_csv_row(line: &str, separator: char, delimiter: char) -> (Vec<String>, bool) {
625    let mut fields = Vec::new();
626    let mut current_field = String::new();
627    let mut in_quotes = false;
628    let mut chars = line.chars().peekable();
629
630    while let Some(c) = chars.next() {
631        if in_quotes {
632            if c == delimiter {
633                // Check for escaped quote (doubled delimiter)
634                if chars.peek() == Some(&delimiter) {
635                    current_field.push(delimiter);
636                    chars.next();
637                } else {
638                    in_quotes = false;
639                }
640            } else {
641                current_field.push(c);
642            }
643        } else if c == delimiter {
644            in_quotes = true;
645        } else if c == separator {
646            fields.push(current_field);
647            current_field = String::new();
648        } else {
649            current_field.push(c);
650        }
651    }
652
653    // Don't forget the last field
654    fields.push(current_field);
655
656    (fields, in_quotes)
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    // Tests for CsvExportOptions
664
665    #[test]
666    fn test_csv_export_options_default() {
667        let options = CsvExportOptions::default();
668
669        assert_eq!(options.column_separator, ',');
670        assert_eq!(options.column_delimiter, '"');
671        assert_eq!(options.row_separator, RowSeparator::LF);
672        assert_eq!(options.encoding, "UTF-8");
673        assert!(options.null_value.is_none());
674        assert_eq!(options.compression, Compression::None);
675        assert!(!options.with_column_names);
676        assert!(!options.use_tls);
677        assert_eq!(options.timeout_ms, 300_000);
678        assert_eq!(options.host, "");
679        assert_eq!(options.port, 0);
680    }
681
682    #[test]
683    fn test_csv_export_options_builder() {
684        let options = CsvExportOptions::new()
685            .column_separator(';')
686            .column_delimiter('\'')
687            .row_separator(RowSeparator::CRLF)
688            .encoding("ISO-8859-1")
689            .null_value("NULL")
690            .compression(Compression::Gzip)
691            .with_column_names(true)
692            .use_tls(false)
693            .timeout_ms(60_000)
694            .exasol_host("exasol.example.com")
695            .exasol_port(8563);
696
697        assert_eq!(options.column_separator, ';');
698        assert_eq!(options.column_delimiter, '\'');
699        assert_eq!(options.row_separator, RowSeparator::CRLF);
700        assert_eq!(options.encoding, "ISO-8859-1");
701        assert_eq!(options.null_value, Some("NULL".to_string()));
702        assert_eq!(options.compression, Compression::Gzip);
703        assert!(options.with_column_names);
704        assert!(!options.use_tls);
705        assert_eq!(options.timeout_ms, 60_000);
706        assert_eq!(options.host, "exasol.example.com");
707        assert_eq!(options.port, 8563);
708    }
709
710    // Tests for CSV parsing
711
712    #[test]
713    fn test_parse_csv_simple() {
714        let data = "a,b,c\n1,2,3\n";
715        let result = parse_csv(data, ',', '"').unwrap();
716
717        assert_eq!(result.len(), 2);
718        assert_eq!(result[0], vec!["a", "b", "c"]);
719        assert_eq!(result[1], vec!["1", "2", "3"]);
720    }
721
722    #[test]
723    fn test_parse_csv_with_quotes() {
724        let data = "\"hello\",\"world\"\n\"foo\",\"bar\"\n";
725        let result = parse_csv(data, ',', '"').unwrap();
726
727        assert_eq!(result.len(), 2);
728        assert_eq!(result[0], vec!["hello", "world"]);
729        assert_eq!(result[1], vec!["foo", "bar"]);
730    }
731
732    #[test]
733    fn test_parse_csv_with_escaped_quotes() {
734        let data = "\"hello \"\"world\"\"\",normal\n";
735        let result = parse_csv(data, ',', '"').unwrap();
736
737        assert_eq!(result.len(), 1);
738        assert_eq!(result[0], vec!["hello \"world\"", "normal"]);
739    }
740
741    #[test]
742    fn test_parse_csv_with_separator_in_quotes() {
743        let data = "\"a,b,c\",\"d,e\"\n";
744        let result = parse_csv(data, ',', '"').unwrap();
745
746        assert_eq!(result.len(), 1);
747        assert_eq!(result[0], vec!["a,b,c", "d,e"]);
748    }
749
750    #[test]
751    fn test_parse_csv_with_newline_in_quotes() {
752        let data = "\"line1\nline2\",normal\n";
753        let result = parse_csv(data, ',', '"').unwrap();
754
755        assert_eq!(result.len(), 1);
756        assert_eq!(result[0], vec!["line1\nline2", "normal"]);
757    }
758
759    #[test]
760    fn test_parse_csv_with_crlf() {
761        let data = "a,b\r\nc,d\r\n";
762        let result = parse_csv(data, ',', '"').unwrap();
763
764        assert_eq!(result.len(), 2);
765        assert_eq!(result[0], vec!["a", "b"]);
766        assert_eq!(result[1], vec!["c", "d"]);
767    }
768
769    #[test]
770    fn test_parse_csv_with_semicolon_separator() {
771        let data = "a;b;c\n1;2;3\n";
772        let result = parse_csv(data, ';', '"').unwrap();
773
774        assert_eq!(result.len(), 2);
775        assert_eq!(result[0], vec!["a", "b", "c"]);
776        assert_eq!(result[1], vec!["1", "2", "3"]);
777    }
778
779    #[test]
780    fn test_parse_csv_empty_fields() {
781        let data = "a,,c\n,b,\n";
782        let result = parse_csv(data, ',', '"').unwrap();
783
784        assert_eq!(result.len(), 2);
785        assert_eq!(result[0], vec!["a", "", "c"]);
786        assert_eq!(result[1], vec!["", "b", ""]);
787    }
788
789    #[test]
790    fn test_parse_csv_unclosed_quote_error() {
791        let data = "\"unclosed,quote\n";
792        let result = parse_csv(data, ',', '"');
793
794        assert!(result.is_err());
795        if let Err(ExportError::CsvParseError { row, message }) = result {
796            assert_eq!(row, 0);
797            assert!(message.contains("Unclosed quote"));
798        } else {
799            panic!("Expected CsvParseError");
800        }
801    }
802
803    #[test]
804    fn test_parse_csv_empty_data() {
805        let data = "";
806        let result = parse_csv(data, ',', '"').unwrap();
807
808        assert!(result.is_empty());
809    }
810
811    #[test]
812    fn test_parse_csv_single_field() {
813        let data = "single\n";
814        let result = parse_csv(data, ',', '"').unwrap();
815
816        assert_eq!(result.len(), 1);
817        assert_eq!(result[0], vec!["single"]);
818    }
819
820    #[test]
821    fn test_parse_csv_no_trailing_newline() {
822        let data = "a,b,c";
823        let result = parse_csv(data, ',', '"').unwrap();
824
825        assert_eq!(result.len(), 1);
826        assert_eq!(result[0], vec!["a", "b", "c"]);
827    }
828
829    // Tests for ExportError
830
831    #[test]
832    fn test_export_error_display() {
833        let err = ExportError::IoError(io::Error::new(io::ErrorKind::NotFound, "file not found"));
834        assert!(err.to_string().contains("I/O error"));
835
836        let err = ExportError::HttpTransportError {
837            message: "connection refused".to_string(),
838        };
839        assert!(err.to_string().contains("HTTP transport error"));
840        assert!(err.to_string().contains("connection refused"));
841
842        let err = ExportError::CsvParseError {
843            row: 5,
844            message: "invalid data".to_string(),
845        };
846        assert!(err.to_string().contains("row 5"));
847        assert!(err.to_string().contains("invalid data"));
848
849        let err = ExportError::Timeout { timeout_ms: 5000 };
850        assert!(err.to_string().contains("5000ms"));
851    }
852
853    #[test]
854    fn test_export_error_from_io_error() {
855        let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "access denied");
856        let export_err: ExportError = io_err.into();
857
858        assert!(matches!(export_err, ExportError::IoError(_)));
859    }
860
861    // Tests for DataPipeReceiver
862
863    #[tokio::test]
864    async fn test_data_pipe_receiver_recv() {
865        let (tx, rx) = mpsc::channel::<Vec<u8>>(16);
866        let mut receiver = DataPipeReceiver { rx };
867
868        tx.send(vec![1, 2, 3]).await.unwrap();
869        tx.send(vec![4, 5, 6]).await.unwrap();
870        drop(tx);
871
872        let chunk1 = receiver.recv().await;
873        assert_eq!(chunk1, Some(vec![1, 2, 3]));
874
875        let chunk2 = receiver.recv().await;
876        assert_eq!(chunk2, Some(vec![4, 5, 6]));
877
878        let chunk3 = receiver.recv().await;
879        assert!(chunk3.is_none());
880    }
881
882    #[tokio::test]
883    async fn test_data_pipe_receiver_empty() {
884        let (tx, rx) = mpsc::channel::<Vec<u8>>(16);
885        let mut receiver = DataPipeReceiver { rx };
886
887        drop(tx);
888
889        let chunk = receiver.recv().await;
890        assert!(chunk.is_none());
891    }
892
893    // Tests for CSV parsing with column headers
894
895    #[test]
896    fn test_parse_csv_with_column_headers() {
897        let data = "id,name,email\n1,Alice,alice@example.com\n2,Bob,bob@example.com\n";
898        let result = parse_csv(data, ',', '"').unwrap();
899
900        assert_eq!(result.len(), 3);
901        // First row is the header
902        assert_eq!(result[0], vec!["id", "name", "email"]);
903        // Data rows
904        assert_eq!(result[1], vec!["1", "Alice", "alice@example.com"]);
905        assert_eq!(result[2], vec!["2", "Bob", "bob@example.com"]);
906    }
907
908    #[test]
909    fn test_parse_csv_with_quoted_headers() {
910        let data = "\"Column A\",\"Column B\",\"Column C\"\n1,2,3\n";
911        let result = parse_csv(data, ',', '"').unwrap();
912
913        assert_eq!(result.len(), 2);
914        assert_eq!(result[0], vec!["Column A", "Column B", "Column C"]);
915        assert_eq!(result[1], vec!["1", "2", "3"]);
916    }
917
918    // Tests for decompression
919
920    #[test]
921    fn test_gzip_decompression() {
922        use flate2::write::GzEncoder;
923        use std::io::Write;
924
925        // Create gzip compressed data
926        let original = b"hello,world\n1,2\n";
927        let mut encoder = GzEncoder::new(Vec::new(), flate2::Compression::default());
928        encoder.write_all(original).unwrap();
929        let compressed = encoder.finish().unwrap();
930
931        // Decompress using our logic
932        let decoder = GzDecoder::new(compressed.as_slice());
933        let mut decompressed = Vec::new();
934        std::io::Read::read_to_end(&mut std::io::BufReader::new(decoder), &mut decompressed)
935            .unwrap();
936
937        assert_eq!(decompressed, original);
938
939        // Parse the decompressed CSV
940        let csv_string = String::from_utf8(decompressed).unwrap();
941        let rows = parse_csv(&csv_string, ',', '"').unwrap();
942        assert_eq!(rows.len(), 2);
943        assert_eq!(rows[0], vec!["hello", "world"]);
944        assert_eq!(rows[1], vec!["1", "2"]);
945    }
946
947    #[test]
948    fn test_bzip2_decompression() {
949        use bzip2::write::BzEncoder;
950        use std::io::Write;
951
952        // Create bzip2 compressed data
953        let original = b"foo,bar\na,b\n";
954        let mut encoder = BzEncoder::new(Vec::new(), bzip2::Compression::default());
955        encoder.write_all(original).unwrap();
956        let compressed = encoder.finish().unwrap();
957
958        // Decompress using our logic
959        let decoder = BzDecoder::new(compressed.as_slice());
960        let mut decompressed = Vec::new();
961        std::io::Read::read_to_end(&mut std::io::BufReader::new(decoder), &mut decompressed)
962            .unwrap();
963
964        assert_eq!(decompressed, original);
965
966        // Parse the decompressed CSV
967        let csv_string = String::from_utf8(decompressed).unwrap();
968        let rows = parse_csv(&csv_string, ',', '"').unwrap();
969        assert_eq!(rows.len(), 2);
970        assert_eq!(rows[0], vec!["foo", "bar"]);
971        assert_eq!(rows[1], vec!["a", "b"]);
972    }
973
974    // Tests for export options with column names
975
976    #[test]
977    fn test_csv_export_options_with_column_names() {
978        let options = CsvExportOptions::new().with_column_names(true);
979        assert!(options.with_column_names);
980
981        let options = CsvExportOptions::new().with_column_names(false);
982        assert!(!options.with_column_names);
983    }
984
985    // Tests for export options with compression
986
987    #[test]
988    fn test_csv_export_options_compression_gzip() {
989        let options = CsvExportOptions::new().compression(Compression::Gzip);
990        assert_eq!(options.compression, Compression::Gzip);
991    }
992
993    #[test]
994    fn test_csv_export_options_compression_bzip2() {
995        let options = CsvExportOptions::new().compression(Compression::Bzip2);
996        assert_eq!(options.compression, Compression::Bzip2);
997    }
998
999    #[test]
1000    fn test_csv_export_options_compression_none() {
1001        let options = CsvExportOptions::new().compression(Compression::None);
1002        assert_eq!(options.compression, Compression::None);
1003    }
1004
1005    // Tests for error types
1006
1007    #[test]
1008    fn test_export_error_decompression() {
1009        let err = ExportError::DecompressionError("invalid gzip header".to_string());
1010        assert!(err.to_string().contains("Decompression error"));
1011        assert!(err.to_string().contains("invalid gzip header"));
1012    }
1013
1014    #[test]
1015    fn test_export_error_channel() {
1016        let err = ExportError::ChannelError("receiver dropped".to_string());
1017        assert!(err.to_string().contains("Channel error"));
1018        assert!(err.to_string().contains("receiver dropped"));
1019    }
1020
1021    #[test]
1022    fn test_export_error_cancelled() {
1023        let err = ExportError::Cancelled;
1024        assert!(err.to_string().contains("cancelled"));
1025    }
1026
1027    // Test DataPipeReceiver with multiple chunks
1028
1029    #[tokio::test]
1030    async fn test_data_pipe_receiver_multiple_chunks() {
1031        let (tx, rx) = mpsc::channel::<Vec<u8>>(DEFAULT_PIPE_BUFFER_SIZE);
1032        let mut receiver = DataPipeReceiver { rx };
1033
1034        // Send CSV data in chunks
1035        let chunk1 = b"id,name\n".to_vec();
1036        let chunk2 = b"1,Alice\n".to_vec();
1037        let chunk3 = b"2,Bob\n".to_vec();
1038
1039        tx.send(chunk1.clone()).await.unwrap();
1040        tx.send(chunk2.clone()).await.unwrap();
1041        tx.send(chunk3.clone()).await.unwrap();
1042        drop(tx);
1043
1044        // Receive and concatenate
1045        let mut buffer = Vec::new();
1046        while let Some(chunk) = receiver.recv().await {
1047            buffer.extend_from_slice(&chunk);
1048        }
1049
1050        let csv_string = String::from_utf8(buffer).unwrap();
1051        let rows = parse_csv(&csv_string, ',', '"').unwrap();
1052
1053        assert_eq!(rows.len(), 3);
1054        assert_eq!(rows[0], vec!["id", "name"]);
1055        assert_eq!(rows[1], vec!["1", "Alice"]);
1056        assert_eq!(rows[2], vec!["2", "Bob"]);
1057    }
1058}